From 88ce93f68be736476a074a7abe1cc0847a6e2d3b Mon Sep 17 00:00:00 2001
From: jiayuqi7813 <1783671926@qq.com>
Date: Fri, 9 Oct 2026 11:59:53 +0800
Subject: [PATCH] release: sync v0.4.1 and macOS desktop packages
---
.github/workflows/ci.yml | 8 +
.github/workflows/release-images.yml | 145 +-
.gitignore | 2 +
CHANGELOG.md | 27 +
README.md | 12 +-
README_CN.md | 12 +-
apps/desktop/electron-builder.dev.cjs | 21 +
apps/desktop/native/speech.mm | 41 +-
apps/desktop/package-lock.json | 5 +-
apps/desktop/package.json | 63 +-
apps/desktop/renderer/App.tsx | 89 +-
.../renderer/ChatExtensionsSettings.tsx | 172 -
apps/desktop/renderer/main.tsx | 10 +-
.../renderer/settings/DesktopSettings.tsx | 17 +
apps/desktop/renderer/styles.css | 61 +-
apps/desktop/renderer/types.ts | 24 +-
apps/desktop/scripts/build-native.cjs | 2 +-
apps/desktop/scripts/dev-signing.cjs | 31 +
apps/desktop/scripts/dev.cjs | 50 +
apps/desktop/scripts/sign-macos.cjs | 21 +
apps/desktop/src/auth.cjs | 95 +
apps/desktop/src/dev-bootstrap.cjs | 12 +
apps/desktop/src/dev-control.cjs | 80 +
apps/desktop/src/environment.cjs | 83 +
apps/desktop/src/main.cjs | 755 +-
apps/desktop/src/managed-service.cjs | 169 +
apps/desktop/src/next-runtime.cjs | 60 +
apps/desktop/src/policy.cjs | 18 +-
apps/desktop/src/preferences.cjs | 27 +-
apps/desktop/src/preload.cjs | 11 +-
apps/desktop/src/preview-control.cjs | 473 +
apps/desktop/src/transport.cjs | 72 +-
apps/desktop/src/update-files.cjs | 70 +
apps/desktop/src/update-runner.cjs | 93 +
apps/desktop/src/updates.cjs | 60 +
apps/desktop/test/chrome.test.cjs | 68 +-
apps/desktop/test/fixture.cjs | 5 +-
apps/desktop/test/policy.test.cjs | 65 +
apps/desktop/test/smoke.cjs | 4 +-
apps/desktop/tsconfig.json | 3 +-
apps/desktop/vite.config.ts | 7 +-
apps/web/agent_runtime_api.py | 651 +-
apps/web/auth.py | 594 +-
apps/web/capability_management_api.py | 17 +
apps/web/chat_plugins_api.py | 50 +-
apps/web/conversation_api.py | 756 +-
apps/web/conversation_shares.py | 4 +-
apps/web/desktop_runtime.py | 107 +
apps/web/drivers.py | 24 +-
apps/web/operations_api.py | 23 +-
apps/web/platform_stack.py | 73 +-
apps/web/platform_update.py | 10 +
apps/web/routes/credentials.py | 54 +-
apps/web/routes/events.py | 7 +-
apps/web/routes/helpers.py | 57 +-
apps/web/routes/system.py | 137 +-
apps/web/routes/workers.py | 7 -
apps/web/run_recovery.py | 5 +-
apps/web/server.py | 147 +-
apps/web/storage_layout.py | 6 +
apps/web/ui/app/api/[...path]/route.ts | 21 +
apps/web/ui/app/globals.css | 8 +-
apps/web/ui/app/layout.tsx | 3 +-
apps/web/ui/app/not-found.tsx | 2 +-
apps/web/ui/app/providers.tsx | 2 +
apps/web/ui/app/settings/[page]/page.tsx | 18 +
.../ui/app/settings/agent-extensions/page.tsx | 3 -
apps/web/ui/app/settings/agents/page.tsx | 7 -
apps/web/ui/app/settings/appearance/page.tsx | 7 -
.../web/ui/app/settings/capabilities/page.tsx | 7 -
.../web/ui/app/settings/chat-plugins/page.tsx | 3 -
apps/web/ui/app/settings/credentials/page.tsx | 5 -
apps/web/ui/app/settings/extensions/page.tsx | 7 -
.../ui/app/settings/notifications/page.tsx | 7 -
apps/web/ui/app/settings/operations/page.tsx | 7 -
apps/web/ui/app/settings/page.tsx | 6 +-
apps/web/ui/app/settings/secrets/page.tsx | 5 -
apps/web/ui/app/settings/workers/page.tsx | 5 -
apps/web/ui/app/styles/base.css | 31 +-
apps/web/ui/app/styles/chat/composer.css | 234 +-
apps/web/ui/app/styles/chat/diff.css | 8 +-
apps/web/ui/app/styles/chat/markdown.css | 25 +-
apps/web/ui/app/styles/chat/refine.css | 23 +
apps/web/ui/app/styles/chat/sidebar.css | 10 +-
apps/web/ui/app/styles/chat/timeline.css | 53 +
apps/web/ui/app/styles/chat/tokens.css | 4 +-
.../web/ui/app/styles/competition-catalog.css | 2 +
apps/web/ui/app/styles/pentest.css | 535 +-
apps/web/ui/app/styles/settings-agents.css | 681 --
.../ui/app/styles/settings-conversation.css | 7 +-
apps/web/ui/app/styles/settings-hub.css | 60 -
.../settings-unified-agent-extensions.css | 116 +
.../styles/settings-unified-capabilities.css | 200 +
.../ui/app/styles/settings-unified-data.css | 31 +
.../styles/settings-unified-extensions.css | 130 +
.../styles/settings-unified-operations.css | 450 +
apps/web/ui/app/styles/settings-unified.css | 65 +
.../ui/app/styles/task-worker-settings.css | 14 +-
apps/web/ui/app/styles/workers-inspector.css | 2 +
apps/web/ui/build-info.cjs | 17 +
.../ui/components/AgentExtensionsSettings.tsx | 209 +-
apps/web/ui/components/BtwPanel.tsx | 11 +-
.../ui/components/CapabilityManagement.tsx | 36 +-
apps/web/ui/components/CommandPalette.tsx | 8 +-
apps/web/ui/components/Conversation.tsx | 10 +-
apps/web/ui/components/EngineLogo.tsx | 9 +
apps/web/ui/components/ExtensionSettings.tsx | 418 +-
apps/web/ui/components/LoginGate.tsx | 121 +-
apps/web/ui/components/MotionPreferences.tsx | 28 -
apps/web/ui/components/NotificationInbox.tsx | 44 +
.../ui/components/NotificationSettings.tsx | 281 -
apps/web/ui/components/OperationsSettings.tsx | 611 +-
apps/web/ui/components/PentestReport.tsx | 617 +-
apps/web/ui/components/ProviderManager.tsx | 885 +-
apps/web/ui/components/RunInspector.tsx | 8 +-
apps/web/ui/components/RunWorkbench.tsx | 45 +-
apps/web/ui/components/SchemeBoot.tsx | 29 +-
apps/web/ui/components/SettingsHub.tsx | 285 +-
apps/web/ui/components/UiPreferencesSync.tsx | 14 +
.../ui/components/UsageDashboard.module.css | 838 +-
apps/web/ui/components/UsageDashboard.tsx | 398 +-
apps/web/ui/components/WorkerLanes.tsx | 6 +-
.../web/ui/components/WorkerOrchestration.tsx | 384 +-
apps/web/ui/components/WorkspaceAppShell.tsx | 23 +-
apps/web/ui/components/WorkspaceNav.tsx | 53 +-
.../agentui/agents/agent-activity/index.tsx | 168 +-
.../agentui/agents/agent-activity/types.ts | 6 +-
.../agentui/agents/approval-card/index.tsx | 2 +-
.../components/agentui/agents/code-block.tsx | 4 +-
.../ui/components/agentui/agents/message.tsx | 4 +-
.../agentui/agents/tool-approval.tsx | 6 +-
.../components/agentui/agents/tool-result.tsx | 6 +-
.../ui/components/ai-native/approval-card.tsx | 78 +-
apps/web/ui/components/ai-native/chat.tsx | 4 +
.../ui/components/ai-native/code-block.tsx | 6 +-
.../ui/components/ai-native/context-cards.tsx | 6 +-
.../ui/components/ai-native/diff-table.tsx | 10 +-
.../ui/components/ai-native/loading-state.tsx | 4 +-
.../ui/components/ai-native/prompt-bar.tsx | 158 +-
.../components/ai-native/streaming-text.tsx | 69 +-
apps/web/ui/components/ai-native/thinking.tsx | 13 +-
.../ui/components/ai-native/tool-chips.tsx | 4 +-
.../chat/composer/AttachmentTray.tsx | 10 +-
.../chat/composer/CapabilityMenu.tsx | 20 +-
.../chat/composer/SubmitCluster.tsx | 41 +-
.../ui/components/chat/composer/stripPill.ts | 2 +-
apps/web/ui/components/chat/dialogs/parts.tsx | 8 +-
apps/web/ui/components/chat/diff/DiffRows.tsx | 24 +-
.../components/chat/markdown/ChatMarkdown.tsx | 7 +-
.../chat/markdown/ChatVisualization.tsx | 124 +-
.../chat/markdown/T3CODE-LICENSE.txt | 21 +
.../chat/markdown/useVisualizationTheme.ts | 42 +
.../chat/markdown/visualizationBridge.ts | 66 +-
.../chat/markdown/visualizationDocument.ts | 100 +
.../ui/components/chat/panel/RightPanel.tsx | 4 +-
apps/web/ui/components/chat/panel/types.ts | 2 +
.../ui/components/chat/preview/AddressBar.tsx | 4 +-
.../components/chat/preview/BrowserMenu.tsx | 113 +
.../ui/components/chat/preview/CodeViewer.tsx | 4 +-
.../chat/preview/ElementPickStrip.tsx | 135 +
.../components/chat/preview/NativePreview.tsx | 12 +-
.../components/chat/preview/PreviewStart.tsx | 12 +-
.../ui/components/chat/sidebar/SidebarNav.tsx | 771 +-
.../components/chat/sidebar/SnoozeDialog.tsx | 96 +
.../chat/sidebar/ThreadHoverCard.tsx | 85 +
.../ui/components/chat/sidebar/dragGhost.ts | 114 +
apps/web/ui/components/chat/sidebar/rows.tsx | 431 +-
apps/web/ui/components/chat/sidebar/types.ts | 69 +-
.../chat/surfaces/AgentsSurface.tsx | 48 +-
.../components/chat/surfaces/FileSurface.tsx | 10 +
.../components/chat/surfaces/FilesSurface.tsx | 14 +-
.../chat/surfaces/OverviewSurface.tsx | 10 +-
.../chat/surfaces/PathBreadcrumb.tsx | 2 +-
.../chat/surfaces/PreviewSurface.tsx | 139 +-
.../chat/surfaces/PullRequestSurface.tsx | 72 +-
.../chat/surfaces/TerminalSurface.tsx | 2 +-
.../ui/components/chat/surfaces/shared.tsx | 2 +-
.../components/chat/timeline/ActivityItem.tsx | 5 +-
.../chat/timeline/ChangedFilesCard.tsx | 14 +-
.../chat/timeline/LocalUrlChips.tsx | 2 +-
.../chat/timeline/MessageActions.tsx | 4 +-
.../components/chat/timeline/SourceChips.tsx | 109 +
.../ui/components/chat/timeline/ToolEntry.tsx | 33 +-
.../chat/timeline/toolPresentation.ts | 45 +
apps/web/ui/components/chat/ui/Feedback.tsx | 5 +-
apps/web/ui/components/chat/ui/Field.tsx | 5 +
apps/web/ui/components/chat/ui/Menu.tsx | 9 +-
apps/web/ui/components/chat/ui/index.ts | 2 +-
apps/web/ui/components/chat/ui/useDismiss.ts | 2 +
.../collaboration/CollaborationIndex.tsx | 5 +-
.../conversation/ComposerBranchPicker.tsx | 2 +-
.../conversation/ComposerContextStrip.tsx | 2 +-
.../conversation/ComposerEffortSlider.tsx | 373 +
.../conversation/ComposerPromptDocument.tsx | 6 +-
.../conversation/ComposerStashModal.tsx | 2 +-
.../conversation/ContextWindowMeter.tsx | 14 +-
.../conversation/ConversationApproval.tsx | 194 +-
.../ConversationComposerModes.tsx | 188 +
.../ConversationContextPicker.tsx | 293 +-
.../ConversationDetailsDrawer.tsx | 23 +-
.../conversation/ConversationDiffSurface.tsx | 56 +-
.../conversation/ConversationHeader.tsx | 268 +-
.../conversation/ConversationHome.tsx | 50 +-
.../conversation/ConversationImportModal.tsx | 22 +-
.../conversation/ConversationInfoDrawer.tsx | 26 +-
.../ConversationInteractiveTerminal.tsx | 2 +-
.../conversation/ConversationMessage.tsx | 12 +-
.../conversation/ConversationMinimap.tsx | 217 +
.../conversation/ConversationModelPicker.tsx | 1300 ++-
.../conversation/ConversationPlanPanel.tsx | 41 +-
.../conversation/ConversationQueue.tsx | 6 +-
.../conversation/ConversationQuotaResume.tsx | 141 +
.../ConversationReadinessBanner.tsx | 59 +-
.../ConversationReadingPrefsPanel.tsx | 6 +-
.../conversation/ConversationShell.tsx | 500 +-
.../ConversationShortcutsHelp.tsx | 181 +-
.../conversation/ConversationSidebar.tsx | 674 +-
.../conversation/ConversationStatsBar.tsx | 6 +-
.../ConversationSubagentGroup.tsx | 395 +
.../conversation/ConversationSummaryCard.tsx | 236 +
.../conversation/ConversationThinking.tsx | 4 +-
.../ConversationThreadDetails.tsx | 405 +
.../conversation/ConversationTimeline.tsx | 291 +-
.../conversation/ConversationTurnProcess.tsx | 202 +-
.../ConversationWorkspaceDock.tsx | 8 +-
.../conversation/DiffLineCommentPopover.tsx | 6 +-
.../conversation/DiffReviewBasket.tsx | 8 +-
.../components/conversation/ExportDialog.tsx | 10 +-
.../conversation/ImpactConfirmModal.tsx | 137 +-
.../conversation/ThreadGitActions.tsx | 645 ++
.../conversation/TypedResourcePreview.tsx | 8 +-
.../conversation/conversationAgentTree.ts | 181 +-
.../conversation/conversationEventViews.ts | 88 +-
.../conversation/conversationSources.ts | 139 +
.../conversation/threadEditorContext.tsx | 29 +
.../ui/components/settings/AccessSettings.tsx | 103 +
.../settings/AppearanceSettings.tsx | 117 +
.../settings/ArchivesSettings.tsx} | 65 +-
.../ui/components/settings/ChatSettings.tsx | 135 +
.../settings/DesktopConnectionSettings.tsx | 59 +
.../settings/ImportSettings.tsx} | 23 +-
.../settings/NotificationsSettings.tsx | 151 +
.../settings/SettingsCompatibility.tsx | 43 +
.../components/settings/SettingsContent.tsx | 28 +
.../ui/components/settings/SettingsHost.tsx | 85 +
.../components/settings/ShortcutsSettings.tsx | 86 +
.../settings/UsageProviderSettings.tsx | 64 +
.../settings/agents/AgentsSettings.tsx | 1237 +++
.../components/settings/agents/EngineList.tsx | 138 +
.../settings/agents/EnvRefsEditor.tsx | 61 +
.../settings/agents/ModelDirectory.tsx | 95 +
.../ui/components/settings/agents/shared.ts | 231 +
apps/web/ui/components/settings/catalog.ts | 44 +
.../web/ui/components/settings/primitives.tsx | 212 +
apps/web/ui/components/settings/registry.json | 981 ++
.../ui/components/settings/settingsIndex.ts | 16 +
.../ui-contributions/SchemaForm.tsx | 102 +-
.../ui/components/ui-contributions/index.tsx | 167 +-
.../ui/components/ui-contributions/types.ts | 2 +
.../components/usage/CursorAccountUsage.tsx | 306 +
.../components/usage/ProviderUsageChart.tsx | 98 +
.../ui/components/usage/UsageAreaChart.tsx | 129 +
apps/web/ui/components/usage/UsageLedger.tsx | 103 +
.../ui/components/usage/UsageLimitsView.tsx | 87 +
apps/web/ui/lib/browserControl.ts | 140 +
apps/web/ui/lib/chatPanelStore.ts | 6 +
apps/web/ui/lib/chatPreferences.ts | 165 +
apps/web/ui/lib/composerCapabilities.ts | 8 +-
apps/web/ui/lib/composerDraftStore.ts | 9 +
apps/web/ui/lib/composerRestoreBinding.ts | 5 +-
apps/web/ui/lib/contracts/index.ts | 134 +-
apps/web/ui/lib/contracts/openapi.json | 8702 +++++++++++------
apps/web/ui/lib/contracts/openapi.ts | 410 +-
apps/web/ui/lib/contracts/schema.json | 735 +-
apps/web/ui/lib/conversationDefaults.ts | 34 +-
apps/web/ui/lib/conversationReadiness.ts | 91 +-
apps/web/ui/lib/conversationReadingPrefs.ts | 23 +-
.../ui/lib/conversationServerPreferences.ts | 115 +
apps/web/ui/lib/cursorAccountUsage.ts | 107 +
apps/web/ui/lib/desktopChatBridge.ts | 65 +-
apps/web/ui/lib/desktopEnvironment.ts | 19 +
apps/web/ui/lib/i18n.tsx | 4 +-
apps/web/ui/lib/modelPickerNarrowLayout.ts | 28 +-
apps/web/ui/lib/modelReasoning.ts | 49 +-
apps/web/ui/lib/modelVisibility.ts | 27 +
apps/web/ui/lib/nativeDesktop.ts | 9 +-
apps/web/ui/lib/newConversationSelection.ts | 24 +-
apps/web/ui/lib/notificationInboxCursor.ts | 25 +
apps/web/ui/lib/palette-engine.ts | 24 +-
apps/web/ui/lib/planModeAvailability.ts | 56 +
apps/web/ui/lib/providerDescriptors.ts | 435 +
apps/web/ui/lib/providerUsage.ts | 118 +
apps/web/ui/lib/serviceAuth.ts | 165 +
apps/web/ui/lib/shortcutBindings.ts | 308 +
apps/web/ui/lib/sidebarActivity.ts | 72 +
apps/web/ui/lib/sidebarInbox.ts | 119 +
apps/web/ui/lib/sidebarPreferenceMerge.ts | 24 +-
apps/web/ui/lib/sidebarThreadBridge.ts | 59 +
apps/web/ui/lib/themePreference.ts | 32 +
apps/web/ui/lib/threadAttention.ts | 68 +-
apps/web/ui/lib/threadDetailsOverlayStore.ts | 37 +
apps/web/ui/lib/threadGitActions.ts | 237 +
apps/web/ui/lib/threadNotifications.ts | 127 +-
apps/web/ui/lib/uiPreferences.ts | 202 +
apps/web/ui/lib/usageFormat.ts | 40 +
apps/web/ui/lib/useConversation.ts | 463 +-
apps/web/ui/lib/useRun.ts | 200 +-
apps/web/ui/lib/workers.ts | 7 +-
apps/web/ui/next.config.mjs | 2 +
apps/web/ui/package-lock.json | 4 +-
apps/web/ui/package.json | 2 +-
apps/web/ui/tsconfig.json | 5 +-
apps/web/ui_preferences.py | 55 +
apps/web/usage_api.py | 285 +-
apps/web/worker_config.py | 8 +-
apps/web/worker_models.py | 316 +-
docker-compose.release.yml | 12 +-
docker/worker-slim/AGENTS.md | 2 +-
docker/worker-slim/Dockerfile | 17 +
docker/worker-slim/build.sh | 4 +-
docker/worker/AGENTS.md | 2 +-
docker/worker/Dockerfile | 21 +-
docker/worker/build.sh | 4 +-
.../muteki-control/mcp/stdio_server.py | 7 +-
.../muteki-control/references/workflows.md | 10 +-
.../muteki-control/scripts/muteki_client.py | 4 +-
.../muteki-visualize/plugin.json | 11 +
.../skills/visualize/SKILL.md | 43 +
.../skills/visualize/assets/explorer.html | 49 +
muteki/capability_bindings/http_jsonrpc.py | 21 +-
muteki/capability_bindings/mcp_server.py | 20 +-
muteki/capability_bindings/native_tools.py | 4 +-
muteki/capability_management.py | 2 +-
muteki/cli.py | 2 +-
muteki/competition/advisor.py | 8 +-
muteki/conversation/agents.py | 95 +-
muteki/conversation/approval_queue.py | 93 +-
muteki/conversation/browser_control.py | 563 ++
muteki/conversation/chat_plugin_components.py | 54 +-
muteki/conversation/chat_plugin_gateway.py | 93 +-
muteki/conversation/chat_plugins.py | 253 +-
muteki/conversation/chat_providers.py | 90 +-
muteki/conversation/checkpoints.py | 200 +
muteki/conversation/commands.py | 245 +-
muteki/conversation/composer_capabilities.py | 33 +-
muteki/conversation/computer_bootstrap.js | 94 +
muteki/conversation/computer_control.py | 819 ++
muteki/conversation/engine_commands.py | 1 +
muteki/conversation/events.py | 16 +
muteki/conversation/executor.py | 1173 ++-
muteki/conversation/git_workspace.py | 272 +-
muteki/conversation/inbox.py | 100 +-
muteki/conversation/manager.py | 457 +-
muteki/conversation/models.py | 133 +-
muteki/conversation/module.py | 165 +-
muteki/conversation/native_environment.py | 25 +-
muteki/conversation/plan.py | 9 +
muteki/conversation/projections.py | 214 +-
muteki/conversation/receipt_recovery.py | 785 ++
muteki/conversation/session_handoff.py | 6 +
muteki/conversation/store.py | 7 +
muteki/conversation/subagents.py | 1681 ++++
muteki/conversation/visualizations.py | 148 +
muteki/core/cursor_usage.py | 721 ++
muteki/core/dotenv_boot.py | 8 +
muteki/core/droid_models.py | 92 +
muteki/core/provider_limits.LICENSE | 21 +
muteki/core/provider_limits.py | 492 +
muteki/core/provider_usage.py | 799 ++
muteki/core/usage.py | 99 +-
muteki/extensions/api.py | 9 +-
muteki/extensions/handlers.py | 59 +-
muteki/extensions/isolation.py | 5 +
muteki/extensions/registry.py | 6 +
muteki/external_agents/__init__.py | 27 +-
muteki/external_agents/acp.py | 2016 +++-
muteki/external_agents/approvals.py | 178 +-
muteki/external_agents/attachment_input.py | 117 +-
muteki/external_agents/base.py | 617 +-
muteki/external_agents/c24_cu_fixtures.py | 218 -
muteki/external_agents/capabilities.py | 302 +-
muteki/external_agents/claude.py | 1390 ++-
muteki/external_agents/claude_transport.py | 100 +
muteki/external_agents/codex.py | 1557 ++-
muteki/external_agents/command_providers.py | 37 +-
muteki/external_agents/cursor.py | 260 +-
muteki/external_agents/cursor_sdk.py | 1525 +++
.../cursor_sdk_bridge/bridge.mjs | 651 ++
.../cursor_sdk_bridge/package-lock.json | 170 +
.../cursor_sdk_bridge/package.json | 13 +
muteki/external_agents/descriptors.py | 1244 +++
muteki/external_agents/devin.py | 598 +-
muteki/external_agents/droid.py | 1446 +++
muteki/external_agents/droid_acp.py | 249 +
muteki/external_agents/droid_mcp.py | 139 +
muteki/external_agents/events.py | 15 +-
muteki/external_agents/factory.py | 109 +-
muteki/external_agents/grok.py | 618 +-
muteki/external_agents/interaction_matrix.py | 220 +-
muteki/external_agents/kimi.py | 1181 ++-
muteki/external_agents/omp.py | 1073 +-
muteki/external_agents/opencode.py | 2602 ++++-
muteki/external_agents/opencode2.py | 1221 +++
muteki/external_agents/pi.py | 1258 ++-
muteki/external_agents/pi_acp.py | 57 +-
muteki/external_agents/probe_cache.py | 465 +
muteki/external_agents/process_guardian.py | 174 +
muteki/external_agents/process_supervisor.py | 878 ++
muteki/external_agents/process_tree.py | 147 +
muteki/external_agents/registry.py | 209 +-
muteki/external_agents/rpc.py | 362 +-
muteki/external_agents/rpc_commands.py | 29 +-
muteki/external_agents/sessions.py | 6 +
muteki/external_agents/unavailable.py | 23 +
muteki/external_agents/version_check.py | 147 +-
muteki/platform/capability_catalog.py | 148 +-
muteki/platform/capability_gateway.py | 106 +-
muteki/platform/command_api.py | 94 +-
muteki/platform/command_handlers/base.py | 2 +
.../platform/command_handlers/pagination.py | 79 +
.../platform/command_handlers/run_handlers.py | 5 +-
.../command_handlers/task_handlers.py | 6 +-
muteki/platform/contracts/__init__.py | 28 +-
muteki/platform/contracts/agent_events.py | 704 ++
muteki/platform/contracts/capabilities.py | 11 +
muteki/platform/contracts/external_agents.py | 217 +-
muteki/platform/contracts/protocols.py | 80 +-
muteki/platform/migrations.py | 16 +
muteki/platform/reconciler.py | 24 +-
muteki/solver/cli_engines/__init__.py | 2 -
muteki/solver/cli_engines/adapter.py | 45 +-
muteki/solver/cli_engines/argv.py | 63 +-
muteki/solver/cli_engines/bins.py | 6 +
muteki/solver/cli_engines/engines/droid.py | 219 +
muteki/solver/cli_engines/engines/kimi.py | 6 +-
muteki/solver/cli_engines/engines/pi.py | 8 +-
muteki/solver/cli_engines/registry.py | 2 +
muteki/solver/container_exec.py | 13 +-
muteki/solver/control_receiver.py | 1 +
muteki/solver/credential_accounts.py | 1281 ++-
muteki/solver/engine_registry.py | 1 +
muteki/solver/pi_model_catalog.mjs | 1 +
muteki/solver/shared_credentials.py | 88 +
muteki/solver/worker_profiles.py | 3 +
muteki/version.py | 2 +-
pyproject.toml | 15 +-
run.sh | 15 +-
scripts/build_desktop_runtime.py | 180 +
scripts/check_chat_commands.py | 23 +-
scripts/check_chat_mcp_http.py | 12 +-
scripts/check_chat_plugins.py | 27 +-
scripts/check_conversation_selection.cjs | 130 +-
scripts/check_conversation_verification.py | 33 +-
scripts/install-ubuntu.sh | 21 +
scripts/measure_builtin_tools.py | 638 ++
scripts/smoke_worker_release.py | 1 +
scripts/verify_worker_image.sh | 7 +-
uv.lock | 19 +-
458 files changed, 67122 insertions(+), 15482 deletions(-)
create mode 100644 apps/desktop/electron-builder.dev.cjs
delete mode 100644 apps/desktop/renderer/ChatExtensionsSettings.tsx
create mode 100644 apps/desktop/renderer/settings/DesktopSettings.tsx
create mode 100644 apps/desktop/scripts/dev-signing.cjs
create mode 100644 apps/desktop/scripts/dev.cjs
create mode 100644 apps/desktop/scripts/sign-macos.cjs
create mode 100644 apps/desktop/src/auth.cjs
create mode 100644 apps/desktop/src/dev-bootstrap.cjs
create mode 100644 apps/desktop/src/dev-control.cjs
create mode 100644 apps/desktop/src/environment.cjs
create mode 100644 apps/desktop/src/managed-service.cjs
create mode 100644 apps/desktop/src/next-runtime.cjs
create mode 100644 apps/desktop/src/preview-control.cjs
create mode 100644 apps/desktop/src/update-files.cjs
create mode 100644 apps/desktop/src/update-runner.cjs
create mode 100644 apps/desktop/src/updates.cjs
create mode 100644 apps/web/desktop_runtime.py
create mode 100644 apps/web/ui/app/settings/[page]/page.tsx
delete mode 100644 apps/web/ui/app/settings/agent-extensions/page.tsx
delete mode 100644 apps/web/ui/app/settings/agents/page.tsx
delete mode 100644 apps/web/ui/app/settings/appearance/page.tsx
delete mode 100644 apps/web/ui/app/settings/capabilities/page.tsx
delete mode 100644 apps/web/ui/app/settings/chat-plugins/page.tsx
delete mode 100644 apps/web/ui/app/settings/credentials/page.tsx
delete mode 100644 apps/web/ui/app/settings/extensions/page.tsx
delete mode 100644 apps/web/ui/app/settings/notifications/page.tsx
delete mode 100644 apps/web/ui/app/settings/operations/page.tsx
delete mode 100644 apps/web/ui/app/settings/secrets/page.tsx
delete mode 100644 apps/web/ui/app/settings/workers/page.tsx
create mode 100644 apps/web/ui/app/styles/chat/refine.css
delete mode 100644 apps/web/ui/app/styles/settings-agents.css
create mode 100644 apps/web/ui/app/styles/settings-unified-agent-extensions.css
create mode 100644 apps/web/ui/app/styles/settings-unified-capabilities.css
create mode 100644 apps/web/ui/app/styles/settings-unified-data.css
create mode 100644 apps/web/ui/app/styles/settings-unified-extensions.css
create mode 100644 apps/web/ui/app/styles/settings-unified-operations.css
create mode 100644 apps/web/ui/app/styles/settings-unified.css
create mode 100644 apps/web/ui/build-info.cjs
delete mode 100644 apps/web/ui/components/MotionPreferences.tsx
create mode 100644 apps/web/ui/components/NotificationInbox.tsx
delete mode 100644 apps/web/ui/components/NotificationSettings.tsx
create mode 100644 apps/web/ui/components/UiPreferencesSync.tsx
create mode 100644 apps/web/ui/components/chat/markdown/T3CODE-LICENSE.txt
create mode 100644 apps/web/ui/components/chat/markdown/useVisualizationTheme.ts
create mode 100644 apps/web/ui/components/chat/markdown/visualizationDocument.ts
create mode 100644 apps/web/ui/components/chat/preview/BrowserMenu.tsx
create mode 100644 apps/web/ui/components/chat/preview/ElementPickStrip.tsx
create mode 100644 apps/web/ui/components/chat/sidebar/SnoozeDialog.tsx
create mode 100644 apps/web/ui/components/chat/sidebar/ThreadHoverCard.tsx
create mode 100644 apps/web/ui/components/chat/sidebar/dragGhost.ts
create mode 100644 apps/web/ui/components/chat/timeline/SourceChips.tsx
create mode 100644 apps/web/ui/components/conversation/ComposerEffortSlider.tsx
create mode 100644 apps/web/ui/components/conversation/ConversationComposerModes.tsx
create mode 100644 apps/web/ui/components/conversation/ConversationMinimap.tsx
create mode 100644 apps/web/ui/components/conversation/ConversationQuotaResume.tsx
create mode 100644 apps/web/ui/components/conversation/ConversationSubagentGroup.tsx
create mode 100644 apps/web/ui/components/conversation/ConversationSummaryCard.tsx
create mode 100644 apps/web/ui/components/conversation/ConversationThreadDetails.tsx
create mode 100644 apps/web/ui/components/conversation/ThreadGitActions.tsx
create mode 100644 apps/web/ui/components/conversation/conversationSources.ts
create mode 100644 apps/web/ui/components/conversation/threadEditorContext.tsx
create mode 100644 apps/web/ui/components/settings/AccessSettings.tsx
create mode 100644 apps/web/ui/components/settings/AppearanceSettings.tsx
rename apps/web/ui/{app/settings/archives/page.tsx => components/settings/ArchivesSettings.tsx} (60%)
create mode 100644 apps/web/ui/components/settings/ChatSettings.tsx
create mode 100644 apps/web/ui/components/settings/DesktopConnectionSettings.tsx
rename apps/web/ui/{app/settings/import/page.tsx => components/settings/ImportSettings.tsx} (52%)
create mode 100644 apps/web/ui/components/settings/NotificationsSettings.tsx
create mode 100644 apps/web/ui/components/settings/SettingsCompatibility.tsx
create mode 100644 apps/web/ui/components/settings/SettingsContent.tsx
create mode 100644 apps/web/ui/components/settings/SettingsHost.tsx
create mode 100644 apps/web/ui/components/settings/ShortcutsSettings.tsx
create mode 100644 apps/web/ui/components/settings/UsageProviderSettings.tsx
create mode 100644 apps/web/ui/components/settings/agents/AgentsSettings.tsx
create mode 100644 apps/web/ui/components/settings/agents/EngineList.tsx
create mode 100644 apps/web/ui/components/settings/agents/EnvRefsEditor.tsx
create mode 100644 apps/web/ui/components/settings/agents/ModelDirectory.tsx
create mode 100644 apps/web/ui/components/settings/agents/shared.ts
create mode 100644 apps/web/ui/components/settings/catalog.ts
create mode 100644 apps/web/ui/components/settings/primitives.tsx
create mode 100644 apps/web/ui/components/settings/registry.json
create mode 100644 apps/web/ui/components/settings/settingsIndex.ts
create mode 100644 apps/web/ui/components/usage/CursorAccountUsage.tsx
create mode 100644 apps/web/ui/components/usage/ProviderUsageChart.tsx
create mode 100644 apps/web/ui/components/usage/UsageAreaChart.tsx
create mode 100644 apps/web/ui/components/usage/UsageLedger.tsx
create mode 100644 apps/web/ui/components/usage/UsageLimitsView.tsx
create mode 100644 apps/web/ui/lib/browserControl.ts
create mode 100644 apps/web/ui/lib/chatPreferences.ts
create mode 100644 apps/web/ui/lib/conversationServerPreferences.ts
create mode 100644 apps/web/ui/lib/cursorAccountUsage.ts
create mode 100644 apps/web/ui/lib/desktopEnvironment.ts
create mode 100644 apps/web/ui/lib/modelVisibility.ts
create mode 100644 apps/web/ui/lib/notificationInboxCursor.ts
create mode 100644 apps/web/ui/lib/planModeAvailability.ts
create mode 100644 apps/web/ui/lib/providerDescriptors.ts
create mode 100644 apps/web/ui/lib/providerUsage.ts
create mode 100644 apps/web/ui/lib/serviceAuth.ts
create mode 100644 apps/web/ui/lib/shortcutBindings.ts
create mode 100644 apps/web/ui/lib/sidebarActivity.ts
create mode 100644 apps/web/ui/lib/sidebarInbox.ts
create mode 100644 apps/web/ui/lib/sidebarThreadBridge.ts
create mode 100644 apps/web/ui/lib/themePreference.ts
create mode 100644 apps/web/ui/lib/threadDetailsOverlayStore.ts
create mode 100644 apps/web/ui/lib/threadGitActions.ts
create mode 100644 apps/web/ui/lib/uiPreferences.ts
create mode 100644 apps/web/ui/lib/usageFormat.ts
create mode 100644 apps/web/ui_preferences.py
create mode 100644 muteki/agent_plugins/muteki-visualize/plugin.json
create mode 100644 muteki/agent_plugins/muteki-visualize/skills/visualize/SKILL.md
create mode 100644 muteki/agent_plugins/muteki-visualize/skills/visualize/assets/explorer.html
create mode 100644 muteki/conversation/browser_control.py
create mode 100644 muteki/conversation/checkpoints.py
create mode 100644 muteki/conversation/computer_bootstrap.js
create mode 100644 muteki/conversation/computer_control.py
create mode 100644 muteki/conversation/receipt_recovery.py
create mode 100644 muteki/conversation/subagents.py
create mode 100644 muteki/conversation/visualizations.py
create mode 100644 muteki/core/cursor_usage.py
create mode 100644 muteki/core/droid_models.py
create mode 100644 muteki/core/provider_limits.LICENSE
create mode 100644 muteki/core/provider_limits.py
create mode 100644 muteki/core/provider_usage.py
delete mode 100644 muteki/external_agents/c24_cu_fixtures.py
create mode 100644 muteki/external_agents/claude_transport.py
create mode 100644 muteki/external_agents/cursor_sdk.py
create mode 100644 muteki/external_agents/cursor_sdk_bridge/bridge.mjs
create mode 100644 muteki/external_agents/cursor_sdk_bridge/package-lock.json
create mode 100644 muteki/external_agents/cursor_sdk_bridge/package.json
create mode 100644 muteki/external_agents/descriptors.py
create mode 100644 muteki/external_agents/droid.py
create mode 100644 muteki/external_agents/droid_acp.py
create mode 100644 muteki/external_agents/droid_mcp.py
create mode 100644 muteki/external_agents/opencode2.py
create mode 100644 muteki/external_agents/probe_cache.py
create mode 100644 muteki/external_agents/process_guardian.py
create mode 100644 muteki/external_agents/process_supervisor.py
create mode 100644 muteki/external_agents/process_tree.py
create mode 100644 muteki/external_agents/unavailable.py
create mode 100644 muteki/platform/command_handlers/pagination.py
create mode 100644 muteki/platform/contracts/agent_events.py
create mode 100644 muteki/solver/cli_engines/engines/droid.py
create mode 100644 muteki/solver/shared_credentials.py
create mode 100644 scripts/build_desktop_runtime.py
create mode 100644 scripts/measure_builtin_tools.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1e6ebfa..a90aec7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,6 +44,8 @@ jobs:
- '.github/actions/**'
web:
- 'apps/web/ui/**'
+ - 'apps/desktop/**'
+ - 'scripts/check_conversation_selection.cjs'
- '.github/workflows/ci.yml'
- '.github/actions/**'
@@ -87,6 +89,12 @@ jobs:
working-directory: apps/web/ui
run: npm ci
+ - name: Check shared settings and client preferences
+ if: ${{ !cancelled() && steps.ui-deps.outcome == 'success' }}
+ run: |
+ node --test apps/desktop/test/policy.test.cjs
+ node scripts/check_conversation_selection.cjs
+
- name: Check TypeScript
id: typecheck
if: ${{ !cancelled() && steps.ui-deps.outcome == 'success' }}
diff --git a/.github/workflows/release-images.yml b/.github/workflows/release-images.yml
index 134d73a..db4cdf9 100644
--- a/.github/workflows/release-images.yml
+++ b/.github/workflows/release-images.yml
@@ -55,6 +55,11 @@ jobs:
TAG="${TAG#refs/tags/}"
TAG="${TAG//\//-}"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ - name: Check settings interface policy before release
+ run: node --test apps/desktop/test/policy.test.cjs
- name: Build application bundle
run: python scripts/build_release_bundle.py --version "${{ steps.version.outputs.version }}" --repository "${{ github.repository }}"
- uses: actions/upload-artifact@v4
@@ -68,6 +73,7 @@ jobs:
images:
name: ${{ matrix.image }} (${{ matrix.target.arch }})
+ needs: bundle
if: ${{ github.run_attempt == 1 && github.run_number <= fromJSON(vars.MUTEKI_RELEASE_LAST_RUN_NUMBER || '0') }}
timeout-minutes: 30
strategy:
@@ -270,6 +276,131 @@ jobs:
if-no-files-found: error
retention-days: 7
+ desktop-macos:
+ name: Muteki Desktop macOS (${{ matrix.target.arch }})
+ needs: bundle
+ if: ${{ github.run_attempt == 1 && github.run_number <= fromJSON(vars.MUTEKI_RELEASE_LAST_RUN_NUMBER || '0') }}
+ timeout-minutes: 90
+ strategy:
+ fail-fast: false
+ matrix:
+ target:
+ - arch: arm64
+ runner: macos-15
+ - arch: x64
+ runner: macos-15-intel
+ runs-on: ${{ matrix.target.runner }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "24.21.0"
+ cache: npm
+ cache-dependency-path: |
+ apps/desktop/package-lock.json
+ apps/web/ui/package-lock.json
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13.13"
+ - uses: astral-sh/setup-uv@v6
+ with:
+ enable-cache: true
+ cache-dependency-glob: uv.lock
+ - name: Verify runner architecture
+ env:
+ EXPECTED_ARCH: ${{ matrix.target.arch }}
+ run: |
+ set -euo pipefail
+ case "$EXPECTED_ARCH:$(uname -m)" in
+ arm64:arm64|x64:x86_64) ;;
+ *) echo "Runner architecture does not match $EXPECTED_ARCH" >&2; exit 1 ;;
+ esac
+ - name: Install desktop dependencies
+ working-directory: apps/desktop
+ run: npm ci
+ - name: Install web dependencies
+ working-directory: apps/web/ui
+ run: npm ci
+ - name: Check desktop
+ working-directory: apps/desktop
+ run: |
+ npm test
+ npm run typecheck
+ npm run build
+ - name: Build relocatable desktop runtime
+ run: python scripts/build_desktop_runtime.py
+ - name: Package macOS desktop release
+ working-directory: apps/desktop
+ env:
+ CSC_IDENTITY_AUTO_DISCOVERY: "false"
+ run: npx electron-builder --mac dmg zip --${{ matrix.target.arch }} --publish never -c.mac.identity=-
+ - name: Verify packaged application
+ env:
+ EXPECTED_ARCH: ${{ matrix.target.arch }}
+ run: |
+ set -euo pipefail
+ app="$(find apps/desktop/dist -maxdepth 3 -name Muteki.app -print -quit)"
+ test -n "$app"
+ codesign --verify --deep --strict "$app"
+ file "$app/Contents/Resources/native/macos-speech.node"
+ case "$EXPECTED_ARCH" in
+ arm64) file "$app/Contents/Resources/runtime/node" | grep -F 'arm64' ;;
+ x64) file "$app/Contents/Resources/runtime/node" | grep -F 'x86_64' ;;
+ esac
+ test -n "$(find apps/desktop/dist -maxdepth 1 -name '*.dmg' -print -quit)"
+ test -n "$(find apps/desktop/dist -maxdepth 1 -name '*.zip' -print -quit)"
+ - name: Run packaged desktop acceptance check
+ run: |
+ set -euo pipefail
+ python3 - <<'PY'
+ import json
+ import os
+ from pathlib import Path
+ import subprocess
+
+ apps = list(Path("apps/desktop/dist").glob("mac*/Muteki.app"))
+ if len(apps) != 1:
+ raise SystemExit(f"Expected one packaged Muteki.app, found {apps!r}")
+ app = apps[0]
+ expected_version = json.loads(Path("apps/desktop/package.json").read_text())["version"]
+ profile = Path(os.environ["RUNNER_TEMP"]) / "muteki-desktop-acceptance"
+ profile.mkdir(parents=True, exist_ok=True)
+ env = os.environ.copy()
+ env["MUTEKI_DESKTOP_USER_DATA"] = str(profile / "desktop")
+ env["MUTEKI_DESKTOP_REGISTER_PROTOCOL"] = "0"
+ for key in (
+ "MUTEKI_DESKTOP_URL", "MUTEKI_ENVIRONMENT_ROOT", "MUTEKI_DESKTOP_WORKSPACE",
+ "NODE_OPTIONS", "NODE_PATH", "ELECTRON_RUN_AS_NODE", "PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV",
+ ):
+ env.pop(key, None)
+ executable = app / "Contents/MacOS/Muteki"
+ log_path = profile / "acceptance.log"
+ try:
+ with log_path.open("wb") as log:
+ result = subprocess.run(
+ [str(executable), "--muteki-channel=candidate", f"--muteki-environment-root={profile}", "--muteki-self-check"],
+ stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, env=env, timeout=180, check=False,
+ )
+ except subprocess.TimeoutExpired:
+ tail = log_path.read_text(errors="replace")[-12000:]
+ raise SystemExit(f"Packaged app acceptance exceeded 180 seconds\\n{tail}")
+ result_path = profile / "candidate-result.json"
+ payload = json.loads(result_path.read_text()) if result_path.exists() else {}
+ if result.returncode != 0 or payload.get("status") != "passed" or payload.get("version") != expected_version:
+ tail = log_path.read_text(errors="replace")[-12000:]
+ raise SystemExit(f"Packaged app acceptance failed (exit {result.returncode}); expected version={expected_version!r}; result={payload!r}\\n{tail}")
+ print(f"Packaged desktop acceptance passed for version {payload.get('version')}")
+ PY
+ - name: Upload macOS desktop assets
+ uses: actions/upload-artifact@v4
+ with:
+ name: desktop-macos-${{ matrix.target.arch }}
+ path: |
+ apps/desktop/dist/*.dmg
+ apps/desktop/dist/*.zip
+ if-no-files-found: error
+ retention-days: 7
+
worker-base:
name: muteki-worker base (${{ matrix.target.arch }})
timeout-minutes: 45
@@ -685,15 +816,21 @@ jobs:
publish:
timeout-minutes: 5
- needs: [bundle, merge, merge-worker]
+ needs: [bundle, images, desktop-macos, merge, merge-worker]
# Tag push only — workflow_dispatch must never create a formal GitHub Release.
- if: ${{ always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.bundle.result == 'success' && needs.merge.result == 'success' && needs.merge-worker.result == 'success' }}
+ if: ${{ always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.bundle.result == 'success' && needs.images.result == 'success' && needs.desktop-macos.result == 'success' && needs.merge.result == 'success' && needs.merge-worker.result == 'success' }}
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
with:
name: muteki-release-bundle
path: dist
+ - name: Download macOS desktop assets
+ uses: actions/download-artifact@v4
+ with:
+ pattern: desktop-macos-*
+ path: dist/desktop
+ merge-multiple: true
- name: Download merged image digests
uses: actions/download-artifact@v4
with:
@@ -729,11 +866,11 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
- # Attach bundle + image-identity so the GitHub Release lines up with GHCR digests.
+ # Attach desktop, bundle + image-identity so the GitHub Release lines up with GHCR digests.
TAG="${{ github.ref_name }}"
RELEASE_ARGS=(--repo "${{ github.repository }}" --verify-tag --generate-notes --title "Muteki $TAG")
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
RELEASE_ARGS+=(--prerelease)
fi
- gh release create "$TAG" dist/muteki-*.tar.gz dist/release-manifest.json dist/image-identity.json \
+ gh release create "$TAG" dist/muteki-*.tar.gz dist/release-manifest.json dist/image-identity.json dist/desktop/*.dmg dist/desktop/*.zip \
"${RELEASE_ARGS[@]}"
diff --git a/.gitignore b/.gitignore
index 6be4707..cd2c6fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -109,3 +109,5 @@ data/
/docker/tsec-hosted/.build-ctx/
/apps/web/ui/.next-readme/
+/apps/web/ui/.next-desktop/
+/apps/desktop/dist-dev/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 055d6cb..c95430c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,33 @@
All notable public release changes are tracked here.
+## 0.4.1 - 2026-10-09
+
+### Added
+
+- Added macOS desktop packages for Apple silicon and Intel, with an isolated local workspace, bundled Python/Node/Web runtime, and remote service connections.
+- Added shared settings for Web and desktop, including Agent accounts, model visibility, permissions, keyboard shortcuts, notifications, and appearance preferences.
+- Added provider usage and limit views, a usage ledger, notification inbox, conversation checkpoints, subagent activity, and scoped browser and computer-control tools.
+
+### Changed
+
+- Updated the CTF and penetration-testing workspaces with shared navigation, Worker extensions, evidence-backed reports, and clearer report printing and export.
+- Improved Agent compatibility, model discovery, session recovery, approvals, and process cleanup across supported runtimes.
+- Refined conversation navigation, drag-and-drop ordering, message rendering, previews, and context controls. The default Web view continues to show CTF and penetration testing; conversation and competition workspaces remain optional.
+- Release versions and default image tags now use `0.4.1` / `v0.4.1`. Container images continue to support Linux amd64 and arm64; desktop packages in this release are macOS only.
+
+### Fixed
+
+- Fixed the Web interface freezing while deleting an Agent credential and detaching its conversation and Worker references, as reported with Ubuntu container deployments ([#199](https://github.com/FishCodeTech/muteki/issues/199)).
+- Fixed desktop notification ownership, permission persistence, foreground read tracking, and controlled overlay interactions.
+- Fixed Worker settings navigation, stale draft runtime panels, local directory error feedback, and findings counts for older penetration-testing reports.
+
+### Upgrade
+
+- Use `muteki upgrade v0.4.1` or `./run.sh upgrade v0.4.1` for a managed Web installation, and add `--compose` for a container deployment. Existing `.env`, `state/`, credentials, and sessions remain outside the release bundle.
+- Download the macOS `.dmg` or `.zip` matching your Mac from the release page. Windows and Linux desktop packages are deferred.
+- The macOS packages are ad-hoc signed and are not notarized. Install desktop updates manually; the signed-candidate update flow requires a stable Developer ID signature.
+
## 0.4.0 - 2026-09-28
### Added
diff --git a/README.md b/README.md
index 5199fa9..c77615a 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
- English · 简体中文 · Changelog
+ English · 简体中文 · Changelog · Dev build guide
@@ -74,13 +74,17 @@ After a month of engineering work, tuning, and fixes, Muteki is open source with
## Release notes
-The current version is **0.4.0**. See [CHANGELOG.md](CHANGELOG.md) for new features, behavior changes, removals, and upgrade notes.
+The current version is **0.4.1**. See [CHANGELOG.md](CHANGELOG.md) for new features, behavior changes, removals, and upgrade notes.
+
+The [0.4.1 release](https://github.com/FishCodeTech/muteki/releases/tag/v0.4.1) includes a macOS desktop app for Apple silicon and Intel, with a bundled local service and support for remote Muteki services. Windows and Linux desktop packages are not included in this release. Web and container deployments remain available.
+
+The macOS packages are ad-hoc signed and are not notarized. Install desktop updates manually; the in-app signed-candidate update flow requires a stable Developer ID signature.
---
## Quick start
-> **Start with the single-challenge CTF workspace.** The home page shows this workspace by default. Conversation, competitions, and custom extensions are still being tested and can be enabled as described below. The Web pentest mode is being reworked and is currently unavailable.
+> **Start with the CTF or penetration-testing workspace.** Both appear on the home page by default. Conversation and competition workspaces are optional and can be enabled in appearance settings as described below.
### 1. Prepare the environment and start the Web app
@@ -291,7 +295,7 @@ The UI remains at `http://localhost:3001` by default. `MUTEKI_HOST_DATA_ROOT` mu
```bash
./run.sh upgrade --check # Check the latest stable release
./run.sh install # Set up a managed installation
-muteki upgrade v0.4.0 # Install this release
+muteki upgrade v0.4.1 # Install this release
muteki rollback # Return to the previous installed release
muteki version # Show version and installation kind
```
diff --git a/README_CN.md b/README_CN.md
index 8a2e4b1..ba4a9e1 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -23,7 +23,7 @@
- English · 简体中文 · 版本更新
+ English · 简体中文 · 版本更新 · 开发版编译与更新
@@ -95,13 +95,17 @@ tsecbench 托管模式下+deepseek-flash,排名13
## 版本更新
-当前版本为 **0.4.0**。新增功能、行为变化、移除内容及升级说明见 [CHANGELOG.md](CHANGELOG.md)。
+当前版本为 **0.4.1**。新增功能、行为变化、移除内容及升级说明见 [CHANGELOG.md](CHANGELOG.md)。
+
+[0.4.1 发布页](https://github.com/FishCodeTech/muteki/releases/tag/v0.4.1) 提供 Apple 芯片和 Intel Mac 的 macOS 客户端,内置本地服务,也可连接远程 Muteki 服务。本次暂不提供 Windows、Linux 桌面安装包,Web 和容器部署继续支持。
+
+本次 macOS 安装包使用临时签名,尚未公证。桌面更新请手动安装;应用内候选版本更新流程需要稳定的 Developer ID 签名。
---
## 快速开始
-> **当前优先体验:CTF 单题模式。** 首页默认只显示“单题任务”;对话、比赛及自定义插件仍在测试中,按下文步骤开启。Web 页面的“渗透”模式正在重写,当前不可选。
+> **从 CTF 或渗透测试工作区开始。** 首页默认显示这两个工作区。对话和比赛工作区可按下文说明,在外观设置中开启。
### 1. 准备环境并启动
@@ -315,7 +319,7 @@ MUTEKI_WEB_PASSWORD='请替换为强密码' \
```bash
./run.sh upgrade --check # 检查稳定版
./run.sh install # 安装托管版本
-muteki upgrade v0.4.0 # 更新到本次版本
+muteki upgrade v0.4.1 # 更新到本次版本
muteki rollback # 回滚到上一个已安装版本
muteki version # 查看版本与安装形态
```
diff --git a/apps/desktop/electron-builder.dev.cjs b/apps/desktop/electron-builder.dev.cjs
new file mode 100644
index 0000000..2970479
--- /dev/null
+++ b/apps/desktop/electron-builder.dev.cjs
@@ -0,0 +1,21 @@
+const config = require('./package.json').build;
+const path = require('node:path');
+const { resolveDevSigningIdentity, requireDevSigningIdentity } = require('./scripts/dev-signing.cjs');
+const signingIdentity = process.platform === 'darwin'
+ ? resolveDevSigningIdentity(path.resolve(__dirname, '../../state/_secrets/mac-signing.json'))
+ : undefined;
+module.exports = {
+ ...config,
+ appId: 'tech.fishcode.muteki.desktop.dev',
+ productName: 'Muteki Dev',
+ extraMetadata: { mutekiChannel: 'dev', main: 'src/dev-bootstrap.cjs', mutekiWorkspace: path.resolve(__dirname, '../..') },
+ protocols: [],
+ mac: { ...config.mac, identity: signingIdentity, forceCodeSigning: true },
+ beforePack: async context => {
+ if (context.electronPlatformName !== 'darwin') return;
+ const { findIdentity } = require('app-builder-lib/out/codeSign/macCodeSign');
+ const { keychainFile } = await context.packager.codeSigningInfo.value;
+ await requireDevSigningIdentity(signingIdentity, keychainFile, findIdentity);
+ },
+ directories: { output: 'dist-dev' },
+};
diff --git a/apps/desktop/native/speech.mm b/apps/desktop/native/speech.mm
index d60cf92..d6e9ce6 100644
--- a/apps/desktop/native/speech.mm
+++ b/apps/desktop/native/speech.mm
@@ -3,6 +3,7 @@
#include
#import
#import
+#import
// Node-API keeps this module independent of Electron's Node/V8 ABI. All Cocoa
// state belongs to the main queue; only the TSFN crosses into JavaScript.
@@ -188,8 +189,44 @@ static napi_value Start(napi_env env, napi_callback_info info) {
static napi_value Finish(napi_env, napi_callback_info) { [activeSession endAudio]; return nullptr; }
static napi_value Cancel(napi_env, napi_callback_info) { [activeSession finish:@{@"type": @"cancelled"}]; return nullptr; }
static void Cleanup(void *) { [activeSession finish:@{@"type": @"cancelled"}]; }
+static napi_value NotificationSettings(napi_env env, napi_callback_info info) {
+ size_t argc = 1; napi_value args[1], resource; napi_valuetype type;
+ napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
+ if (argc != 1 || napi_typeof(env, args[0], &type) != napi_ok || type != napi_function) {
+ napi_throw_type_error(env, nullptr, "notificationSettings requires a callback"); return nullptr;
+ }
+ napi_create_string_utf8(env, "Muteki notification settings", NAPI_AUTO_LENGTH, &resource);
+ napi_threadsafe_function callback;
+ if (napi_create_threadsafe_function(env, args[0], nullptr, resource, 0, 1, nullptr, nullptr, nullptr, Deliver, &callback) != napi_ok) {
+ napi_throw_error(env, "desktop.notification_status_bridge_failed", "Cannot create notification settings callback"); return nullptr;
+ }
+ auto deliver = ^(NSDictionary *result) {
+ NSData *bytes = [NSJSONSerialization dataWithJSONObject:result options:0 error:nil];
+ auto *json = new std::string(static_cast(bytes.bytes), bytes.length);
+ if (napi_call_threadsafe_function(callback, json, napi_tsfn_nonblocking) != napi_ok) delete json;
+ napi_release_threadsafe_function(callback, napi_tsfn_release);
+ };
+ @try {
+ // Read only. Never call requestAuthorization or schedule a notification.
+ [UNUserNotificationCenter.currentNotificationCenter getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) {
+ NSString *status;
+ switch (settings.authorizationStatus) {
+ case UNAuthorizationStatusNotDetermined: status = @"notDetermined"; break;
+ case UNAuthorizationStatusDenied: status = @"denied"; break;
+ case UNAuthorizationStatusAuthorized: status = @"authorized"; break;
+ case UNAuthorizationStatusProvisional: status = @"provisional"; break;
+ default: status = @"unknown"; break;
+ }
+ deliver(@{@"authorizationStatus": status, @"authorizationStatusRaw": @(settings.authorizationStatus),
+ @"alertSettingRaw": @(settings.alertSetting), @"soundSettingRaw": @(settings.soundSetting), @"notificationCenterSettingRaw": @(settings.notificationCenterSetting), @"lockScreenSettingRaw": @(settings.lockScreenSetting), @"alertStyleRaw": @(settings.alertStyle)});
+ }];
+ } @catch (NSException *exception) {
+ deliver(@{@"error": @{@"code": @"desktop.notification_status_failed", @"message": exception.reason ?: exception.description, @"nativeName": exception.name}});
+ }
+ return nullptr;
+}
static napi_value Init(napi_env env, napi_value exports) {
- napi_property_descriptor methods[] = {{"start", nullptr, Start, nullptr, nullptr, nullptr, napi_default, nullptr}, {"finish", nullptr, Finish, nullptr, nullptr, nullptr, napi_default, nullptr}, {"cancel", nullptr, Cancel, nullptr, nullptr, nullptr, napi_default, nullptr}};
- napi_define_properties(env, exports, 3, methods); napi_add_env_cleanup_hook(env, Cleanup, nullptr); return exports;
+ napi_property_descriptor methods[] = {{"start", nullptr, Start, nullptr, nullptr, nullptr, napi_default, nullptr}, {"finish", nullptr, Finish, nullptr, nullptr, nullptr, napi_default, nullptr}, {"cancel", nullptr, Cancel, nullptr, nullptr, nullptr, napi_default, nullptr}, {"notificationSettings", nullptr, NotificationSettings, nullptr, nullptr, nullptr, napi_default, nullptr}};
+ napi_define_properties(env, exports, 4, methods); napi_add_env_cleanup_hook(env, Cleanup, nullptr); return exports;
}
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json
index e6c7af0..228dc70 100644
--- a/apps/desktop/package-lock.json
+++ b/apps/desktop/package-lock.json
@@ -1,14 +1,15 @@
{
"name": "muteki-desktop",
- "version": "0.4.0",
+ "version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "muteki-desktop",
- "version": "0.4.0",
+ "version": "0.4.1",
"license": "AGPL-3.0-only",
"devDependencies": {
+ "@electron/osx-sign": "1.3.3",
"@heroui/react": "3.2.4",
"@heroui/styles": "3.2.4",
"@tailwindcss/postcss": "^4.3.3",
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index c7e071d..5ae7a23 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,10 +1,10 @@
{
"name": "muteki-desktop",
"productName": "Muteki",
- "version": "0.4.0",
+ "version": "0.4.1",
"private": true,
"type": "module",
- "description": "Desktop client for an existing local or remote Muteki service",
+ "description": "Muteki desktop with an isolated local workspace and remote service connections",
"license": "AGPL-3.0-only",
"author": "Muteki contributors",
"main": "src/main.cjs",
@@ -12,46 +12,50 @@
"start": "npm run build && electron .",
"test": "node --test test/*.test.cjs",
"test:smoke": "node test/smoke.cjs",
- "pack": "npm run build && electron-builder --dir --publish never",
- "dist": "npm run build && electron-builder --publish never",
+ "pack": "npm test && npm run typecheck && npm run build && npm run runtime && electron-builder --dir --publish never",
+ "dist": "npm test && npm run typecheck && npm run build && npm run runtime && electron-builder --publish never",
"build": "npm run build:native && vite build",
"build:native": "node scripts/build-native.cjs",
"typecheck": "tsc --noEmit",
- "dev": "vite --host 127.0.0.1"
+ "dev": "vite --host 127.0.0.1",
+ "runtime": "python3 ../../scripts/build_desktop_runtime.py",
+ "pack:dev": "npm test && npm run typecheck && npm run build && npm run runtime -- --allow-dirty && electron-builder --config electron-builder.dev.cjs --dir --publish never"
},
"devDependencies": {
- "@types/react": "19.3.0",
- "@types/react-dom": "19.3.0",
- "@vitejs/plugin-react": "6.1.1",
- "electron": "44.4.5",
- "electron-builder": "26.15.3",
- "lucide-react": "1.48.0",
- "playwright-core": "1.63.0",
- "react": "19.3.0",
- "react-dom": "19.3.0",
- "typescript": "7.0.2",
- "vite": "8.3.1",
- "@types/node": "^20.14.0",
+ "@electron/osx-sign": "1.3.3",
"@heroui/react": "3.2.4",
"@heroui/styles": "3.2.4",
+ "@tailwindcss/postcss": "^4.3.3",
"@tanstack/react-virtual": "^3.14.13",
+ "@types/node": "^20.14.0",
+ "@types/react": "19.3.0",
+ "@types/react-dom": "19.3.0",
+ "@vitejs/plugin-react": "6.1.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.11.0",
"clsx": "^2.1.1",
+ "electron": "44.4.5",
+ "electron-builder": "26.15.3",
"katex": "0.18.9",
+ "lucide-react": "1.48.0",
"motion": "^13.4.4",
+ "playwright-core": "1.63.0",
+ "react": "19.3.0",
+ "react-dom": "19.3.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"remark-math": "6.0.0",
"shiki": "^4.4.3",
"tailwind-merge": "^3.7.0",
- "@tailwindcss/postcss": "^4.3.3",
- "tailwindcss": "^4.3.3"
+ "tailwindcss": "^4.3.3",
+ "typescript": "7.0.2",
+ "vite": "8.3.1"
},
"build": {
"appId": "tech.fishcode.muteki.desktop",
"asar": true,
+ "artifactName": "${productName}-${version}-${arch}.${ext}",
"directories": {
"output": "dist"
},
@@ -64,6 +68,10 @@
{
"from": "../../LICENSE",
"to": "LICENSE"
+ },
+ {
+ "from": "build/runtime",
+ "to": "runtime"
}
],
"mac": {
@@ -77,7 +85,13 @@
"NSMicrophoneUsageDescription": "Muteki uses the microphone only when you start voice input.",
"NSSpeechRecognitionUsageDescription": "Muteki uses macOS speech recognition to add your spoken words to the current draft."
},
- "extraResources": [{ "from": "native/build/macos-speech.node", "to": "native/macos-speech.node" }]
+ "extraResources": [
+ {
+ "from": "native/build/macos-speech.node",
+ "to": "native/macos-speech.node"
+ }
+ ],
+ "sign": "scripts/sign-macos.cjs"
},
"win": {
"icon": "../web/ui/public/favicon.ico",
@@ -104,6 +118,13 @@
"muteki"
]
}
- ]
+ ],
+ "electronFuses": {
+ "runAsNode": false,
+ "enableNodeOptionsEnvironmentVariable": false,
+ "enableNodeCliInspectArguments": false,
+ "enableEmbeddedAsarIntegrityValidation": true,
+ "onlyLoadAppFromAsar": true
+ }
}
}
diff --git a/apps/desktop/renderer/App.tsx b/apps/desktop/renderer/App.tsx
index 7375986..52914b2 100644
--- a/apps/desktop/renderer/App.tsx
+++ b/apps/desktop/renderer/App.tsx
@@ -1,5 +1,6 @@
-import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { ArrowLeft, ArrowRight, CircleHelp, Crosshair, Home, Layers, LoaderCircle, Maximize2, MessagesSquare, Minimize2, Moon, PanelLeft, Plug, RefreshCw, Search, Settings, Sun, Target, Trophy, X } from 'lucide-react';
+import { invalidateAuth } from "@/lib/serviceAuth";
+import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
+import { ArrowLeft, ArrowRight, ChartNoAxesCombined, CircleHelp, Crosshair, Home, Layers, LoaderCircle, Maximize2, MessagesSquare, Minimize2, Moon, PanelLeft, Plug, RefreshCw, Search, Settings, Sun, Target, Trophy, X } from 'lucide-react';
import { ConversationShell, type ConversationNavigation } from '@/components/conversation/ConversationShell';
import { ConversationChromeContext } from '@/components/conversationChrome';
import { LoginGate } from '@/components/LoginGate';
@@ -11,20 +12,21 @@ import { flushUserInputDraftStore } from '@/lib/userInputDraftStore';
import { flushQueueEditDraftStore } from '@/lib/queueEditDraftStore';
import { flushSendIntentStore } from '@/lib/sendIntentStore';
import { flushTaskReceiptStore } from '@/lib/task-center';
-import { readSavedTheme, readSavedSelection, applySelection } from '@/lib/palette-engine';
+import { readSavedSelection, readSavedTheme } from '@/lib/palette-engine';
import { IconButton } from './ui';
import type { DesktopState } from './types';
-import { ConversationShortcutsHelp } from '@/components/conversation/ConversationShortcutsHelp';
-import { NativeCapabilitiesPanel } from '@/components/NativeCapabilitiesPanel';
+import { DesktopSettings } from './settings/DesktopSettings';
+import { readThemePreference, subscribeThemePreference } from '@/lib/themePreference';
+import { refreshUiPreferences } from '@/lib/uiPreferences';
+import { applyTheme } from '@/components/settings/AppearanceSettings';
+import { clampRailWidth, CONVERSATION_SIDEBAR_COLLAPSED_STORAGE_KEY, CONVERSATION_SIDEBAR_WIDTH_STORAGE_KEY, RAIL_WIDTH_DEFAULT } from '@/lib/railSizing';
+
+const readStored = (key: string) => { try { return window.localStorage.getItem(key); } catch { return null; } };
+const writeStored = (key: string, value: string) => { try { window.localStorage.setItem(key, value); } catch { /* keep the in-memory value */ } };
-const WorkerSettings = lazy(() => import('@/components/WorkerOrchestration').then(module => ({ default: module.WorkerOrchestration })));
-const Appearance = lazy(() => import('@/components/WorkerOrchestration').then(module => ({ default: module.AppearanceWorkspace })));
-const AgentExtensions = lazy(() => import('./ChatExtensionsSettings').then(module => ({ default: module.ChatExtensionsSettings })));
-const Notifications = lazy(() => import('@/components/NotificationSettings').then(module => ({ default: module.NotificationSettings })));
const desktop = window.mutekiDesktop;
const initial: DesktopState = { origin: '', transportOrigin: '', status: 'idle', message: '', configuring: true, platform: '', route: '/chat' };
-const entries = [{ href: '/chat', label: ['对话', 'Chat'], icon: MessagesSquare }, { href: '/ctf', label: ['CTF 工作台', 'CTF workspace'], icon: Crosshair }, { href: '/pentest', label: ['渗透测试', 'Pentest'], icon: Target }, { href: '/competitions', label: ['比赛', 'Competitions'], icon: Trophy }];
-const settingsEntries = [{href:'/settings/appearance',label:['外观和语言','Appearance and language']},{href:'/settings/agents',label:['Agents 和接入点','Agents and providers']},{href:'/settings/agent-extensions',label:['Agent 扩展','Agent extensions']},{href:'/settings/notifications',label:['通知','Notifications']},{href:'/settings/shortcuts',label:['聊天快捷键','Chat shortcuts']},{href:'/settings/desktop',label:['桌面能力','Desktop capabilities']}];
+const entries = [{ href: '/chat', label: ['对话', 'Chat'], icon: MessagesSquare }, { href: '/ctf', label: ['CTF 工作台', 'CTF workspace'], icon: Crosshair }, { href: '/pentest', label: ['渗透测试', 'Pentest'], icon: Target }, { href: '/competitions', label: ['比赛', 'Competitions'], icon: Trophy }, { href: '/usage', label: ['全局用量', 'Usage'], icon: ChartNoAxesCombined }];
export function App() {
const { lang } = useLang(), en = lang === 'en';
@@ -36,11 +38,17 @@ export function App() {
const [authStatus, setAuthStatus] = useState({open: false, transportOrigin: ''});
const authOpen = authStatus.open && authStatus.transportOrigin === state.transportOrigin;
const acceptAuthState = useCallback((open: boolean) => setAuthStatus({open, transportOrigin: state.transportOrigin}), [state.transportOrigin]);
- const [sidebarCollapsed, setSidebarCollapsed] = useState(false), [sidebarWidth, setSidebarWidth] = useState(280);
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(() => readStored(CONVERSATION_SIDEBAR_COLLAPSED_STORAGE_KEY) === '1');
+ const [sidebarWidth, setSidebarWidthState] = useState(() => {
+ const stored = Number(readStored(CONVERSATION_SIDEBAR_WIDTH_STORAGE_KEY));
+ return Number.isFinite(stored) && stored > 0 ? clampRailWidth(stored, window.innerWidth) : RAIL_WIDTH_DEFAULT;
+ });
+ const setSidebarWidth = useCallback((width: number) => setSidebarWidthState(clampRailWidth(width, window.innerWidth)), []);
+ useEffect(() => { writeStored(CONVERSATION_SIDEBAR_WIDTH_STORAGE_KEY, String(sidebarWidth)); }, [sidebarWidth]);
+ useEffect(() => { writeStored(CONVERSATION_SIDEBAR_COLLAPSED_STORAGE_KEY, sidebarCollapsed ? '1' : '0'); }, [sidebarCollapsed]);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false), [theme, setTheme] = useState<'light' | 'dark'>(() => readSavedTheme());
- const [reload, setReload] = useState(0), [settingsQuery, setSettingsQuery] = useState('');
+ const [reload, setReload] = useState(0);
const route = useMemo(() => new URL(state.route || '/chat', window.location.href), [state.route]);
- const settingsPathname = route.pathname === '/settings/chat-plugins' ? '/settings/agent-extensions' : route.pathname;
const chat = route.pathname.startsWith('/chat'), settings = route.pathname.startsWith('/settings');
const connected = state.status === 'connected', busy = state.status === 'connecting';
const perform = useCallback(async (fn: () => Promise) => {
@@ -51,11 +59,12 @@ export function App() {
await desktop.navigate(href, replace ? 'replace' : 'push');
});
}, [perform]);
- const navigation = useMemo(() => ({ router: { push: href => go(href), replace: href => go(href, true) }, pathname: route.pathname, searchParams: route.searchParams }), [go, route]);
+ const navigation = useMemo(() => ({ router: { push: href => go(href), replace: href => go(href, true) }, pathname: route.pathname, searchParams: route.searchParams, hash: state.anchorTarget?.hash, anchorRequestId: state.anchorTarget?.id, consumeAnchor: (id: number) => { void desktop.consumeAnchor(id).catch(error => setError(error.message)); } }), [go, route, state.anchorTarget]);
useEffect(() => {
let updates = 0;
const accept = (next: DesktopState) => {
updates++;
+ if (next.authSessionVersion !== stateRef.current.authSessionVersion && next.transportOrigin === stateRef.current.transportOrigin) invalidateAuth("peer_changed");
if (next.transportOrigin && next.transportOrigin !== stateRef.current.transportOrigin) {
resetAuthForServiceChange(next.origin, next.transportOrigin);
setMobileSidebarOpen(false);
@@ -67,18 +76,40 @@ export function App() {
return off;
}, []);
useEffect(() => { if (state.origin) setOrigin(state.origin); }, [state.origin]);
+ useEffect(() => desktop.onFocus(() => { void refreshUiPreferences(); }), []);
useEffect(() => { void desktop.setLocale(lang).catch(error => setError(error.message)); }, [lang]);
useEffect(() => {
const observer = new MutationObserver(() => { const current = document.documentElement.dataset.theme; if (current === 'light' || current === 'dark') setTheme(current); });
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); return () => observer.disconnect();
}, []);
+ useEffect(() => {
+ // Remote pages (CTF, pentest, competitions) lost their own theme toggle with the web rail, so they follow this shell.
+ let last = '';
+ const sync = () => {
+ const payload = { preference: readThemePreference(), resolvedTheme: readSavedTheme(), selection: readSavedSelection() };
+ const key = JSON.stringify(payload);
+ if (key === last) return;
+ last = key;
+ void desktop.syncAppearance(payload).catch(error => { last = ''; setError(error.message); });
+ };
+ sync();
+ const offTheme = subscribeThemePreference(sync);
+ const observer = new MutationObserver(sync);
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-scheme', 'style'] });
+ return () => { observer.disconnect(); offTheme(); };
+ }, []);
useEffect(() => {
const off = desktop.onCommand(({ name }) => {
- if (name === 'settings') go('/settings/appearance');
+ if (name === 'settings') go('/settings');
else if (name === 'shortcuts') go('/settings/shortcuts');
- else if (name === 'sidebar') { if (stateRef.current.configuring) return; setSidebarCollapsed(value => !value); }
- else if (name === 'reload') setReload(value => value + 1);
- else if (name === 'theme') { const next = theme === 'dark' ? 'light' : 'dark'; try { localStorage.setItem('muteki.theme', next); } catch (error) { setError(String(error)); } applySelection(readSavedSelection(), next); document.documentElement.dataset.theme = next; }
+ else if (name === 'sidebar') {
+ if (stateRef.current.configuring) return;
+ // Below the shell's 769px breakpoint the sidebar is an overlay drawer that ignores sidebarCollapsed.
+ if (window.matchMedia('(max-width: 768px)').matches) setMobileSidebarOpen(value => !value);
+ else setSidebarCollapsed(value => !value);
+ }
+ else if (name === 'reload') { void refreshUiPreferences(); setReload(value => value + 1); }
+ else if (name === 'theme') applyTheme(theme === 'dark' ? 'light' : 'dark');
else if (chat && !stateRef.current.configuring) window.dispatchEvent(new CustomEvent('muteki:command', { detail: { name } }));
else if (name === 'new-chat' || name === 'search') go('/chat');
});
@@ -91,24 +122,32 @@ export function App() {
return desktop.acknowledgeClose({ token, persisted: results.every(result => result.persisted), error: results.flatMap(result => result.error ? [result.error] : []).join('\n') });
}).catch(error => { void desktop.acknowledgeClose({ token, persisted: false, error: error.message }).catch(() => {}); setError(error.message); });
}), [chat, state.transportOrigin, authOpen]);
- const chrome = useMemo(() => ({ sidebarCollapsed, toggleSidebarCollapsed: () => setSidebarCollapsed(value => !value), sidebarWidth, setSidebarWidth, mobileSidebarOpen, setMobileSidebarOpen }), [sidebarCollapsed, sidebarWidth, mobileSidebarOpen]);
+ const chrome = useMemo(() => ({ sidebarCollapsed, toggleSidebarCollapsed: () => setSidebarCollapsed(value => !value), sidebarWidth, setSidebarWidth, mobileSidebarOpen, setMobileSidebarOpen }), [sidebarCollapsed, sidebarWidth, setSidebarWidth, mobileSidebarOpen]);
const pageTitle = settings ? label('设置', 'Settings') : entries.find(item => route.pathname.startsWith(item.href))?.label[en ? 1 : 0] || 'Muteki';
const noticeKey = `${state.transportOrigin}:${state.noticeVersion}:${state.persistenceWarning}:${state.message}`;
const notice = error || (dismissedNotice !== noticeKey ? state.persistenceWarning || (!state.configuring ? state.message : '') : '');
return
-
go('/chat')} />{entries.map(item => go(item.href)} />)}
void perform(() => desktop.action('search'))} /> void perform(() => desktop.action('theme'))} /> go('/settings/appearance')} /> { setHelp(false); void perform(() => desktop.configure()); }} /> { setHelp(true); void perform(() => desktop.configure()); }} />
+
go('/chat')} />{entries.map(item => go(item.href)} />)}
void perform(() => desktop.action('search'))} /> void perform(() => desktop.action('theme'))} /> go('/settings')} /> { setHelp(false); void perform(() => desktop.configure()); }} /> { setHelp(true); void perform(() => desktop.configure()); }} />
- {state.transportOrigin &&
+ {state.transportOrigin &&
- {chat ? : settings ? setSettingsQuery(event.target.value)} />{settingsEntries.filter(item => item.label.join(' ').toLowerCase().includes(settingsQuery.toLowerCase())).map(item => go(item.href)}>{item.label[en ? 1 : 0]} )}{label('加载设置…', 'Loading settings…')}
}>{settingsPathname === '/settings/agents' ?
: settingsPathname === '/settings/agent-extensions' ?
: settingsPathname === '/settings/notifications' ?
: settingsPathname === '/settings/shortcuts' ?
{ if (state.canGoBack) void perform(() => desktop.action('back')); else go('/settings/appearance', true); }} /> : settingsPathname === '/settings/desktop' ? : } : null}
+ {chat ?
: settings ?
{ setHelp(false); void perform(() => desktop.configure()); }} onHelp={() => { setHelp(true); void perform(() => desktop.configure()); }} onBack={() => { if (state.canGoBack) void perform(() => desktop.action('back')); else go('/chat', true); }} /> : null}
}
- {state.configuring && {help ?
Muteki Desktop {label('对话、搜索和设置在本地窗口运行。预览在隔离的页面中打开;项目路径需要区分桌面客户端和服务端宿主。', 'Chat, search, and settings run in this local window. Previews are isolated. Project paths distinguish the desktop client from the service host.')}
{ setHelp(false); if (connected) void perform(() => desktop.resume()); }}>{label('返回工作台', 'Return to workspace')} :
MUTEKI DESKTOP
{label('连接你的工作台', 'Connect your workspace')} }
}
+ {state.configuring && {help ?
Muteki Desktop {label('对话、搜索和设置在本地窗口运行。预览在隔离的页面中打开;项目路径需要区分桌面客户端和服务端宿主。', 'Chat, search, and settings run in this local window. Previews are isolated. Project paths distinguish the desktop client from the service host.')}
{ setHelp(false); if (connected) void perform(() => desktop.resume()); }}>{label('返回工作台', 'Return to workspace')} :
MUTEKI DESKTOP
{label('打开你的工作台', 'Open your workspace')}
+
+
void perform(() => desktop.connectLocal())}>
+ {busy && }{label(state.managedService?.state === 'starting' ? '正在启动本机工作台…' : '使用本机工作台', state.managedService?.state === 'starting' ? 'Starting local workspace…' : 'Use local workspace')}
+
+
{label('自动启动配套服务,数据保存在此 App 的独立环境中。', 'Starts the bundled service with data in this App’s separate environment.')}
+ {state.managedService?.state === 'failed' &&
{state.managedService.error} {label('日志:', 'Logs: ')}{state.managedService.logs}
}
+
+ }
}
{notice &&
{notice} { setError(''); setDismissedNotice(noticeKey); }} aria-label={label('关闭错误提示', 'Dismiss error')}>
}
;
diff --git a/apps/desktop/renderer/ChatExtensionsSettings.tsx b/apps/desktop/renderer/ChatExtensionsSettings.tsx
deleted file mode 100644
index 3ead18c..0000000
--- a/apps/desktop/renderer/ChatExtensionsSettings.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
-import { Button, Callout, Dialog, Input, Switch, TextArea } from '@/components/chat/ui';
-import { Icon } from '@/components/Icon';
-import { NativePathPicker } from '@/components/NativePathPicker';
-import { NativeCapabilitiesPanel } from '@/components/NativeCapabilitiesPanel';
-import { apiFetch, currentAuthScope } from '@/lib/useRun';
-import { conversationStorageScope, subscribeConversationStorageScope } from '@/lib/conversationStorageScope';
-import { useLang } from '@/lib/i18n';
-
-type SourceKind = 'local-dir' | 'archive' | 'git';
-type ExtensionPackage = {
- id: string; name: string; description: string; version: string; enabled: boolean;
- skills: Array<{ name: string; description: string }>; mcp_servers: string[];
- native_components: string[]; diagnostics: string[]; can_rollback: boolean; digest: string;
- native_hooks_enabled?: boolean; hook_commands?: string[]; hook_definitions?: unknown[];
- compatibility?: Record;
-};
-type Catalog = {
- engine: string; packages: ExtensionPackage[]; control_enabled: boolean;
- host_discovery_enabled?: boolean;
- native_skills: Array<{ id: string; name: string; description: string; source: string }>;
- native_mcp: Array<{ name: string; enabled: boolean; source: string; transport: string }>;
-};
-export interface ChatExtensionsNavigation {
- pathname: string; searchParams: URLSearchParams; router: { replace: (href: string) => void };
-}
-const ENGINES: Record = { claude: 'Claude', codex: 'Codex', cursor: 'Cursor', pi: 'Pi', omp: 'OMP', kimi: 'Kimi', grok: 'Grok', opencode: 'OpenCode' };
-const record = (value: unknown): value is Record => Boolean(value && typeof value === 'object' && !Array.isArray(value));
-const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === 'string');
-
-export class ChatExtensionsError extends Error {
- constructor(readonly code: string, message: string, readonly rawBody = '', readonly httpStatus?: number) {
- super(message); this.name = 'ChatExtensionsError';
- }
-}
-export async function requestChatExtensions(path: string, method = 'GET', body?: unknown, signal?: AbortSignal): Promise {
- const response = await apiFetch(`/api/chat-plugins${path}`, { method, signal,
- headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
- const raw = await response.text();
- let data: unknown;
- try { data = JSON.parse(raw); } catch {
- throw new ChatExtensionsError('chat_extensions.response_invalid', `HTTP ${response.status}: ${raw}`, raw, response.status);
- }
- if (!response.ok) {
- const error = record(data) && record(data.error) ? data.error : record(data) && record(data.detail) ? data.detail : null;
- const detail = error?.message ?? (record(data) ? data.detail : undefined);
- throw new ChatExtensionsError(typeof error?.code === 'string' ? error.code : 'chat_extensions.http_error',
- typeof detail === 'string' ? detail : `HTTP ${response.status}`, raw, response.status);
- }
- return data;
-}
-export function chatExtensionsCatalog(value: unknown, engine: string): Catalog {
- if (!record(value) || value.engine !== engine || typeof value.control_enabled !== 'boolean'
- || (value.host_discovery_enabled != null && typeof value.host_discovery_enabled !== 'boolean')
- || !Array.isArray(value.packages) || !Array.isArray(value.native_skills) || !Array.isArray(value.native_mcp)
- || value.packages.some(item => !record(item) || !['id', 'name', 'description', 'version', 'digest'].every(key => typeof item[key] === 'string')
- || !item.id || typeof item.enabled !== 'boolean' || typeof item.can_rollback !== 'boolean'
- || !strings(item.mcp_servers) || !strings(item.native_components) || !strings(item.diagnostics)
- || !Array.isArray(item.skills) || item.skills.some(skill => !record(skill) || typeof skill.name !== 'string' || typeof skill.description !== 'string'))
- || value.native_skills.some(item => !record(item) || !['id', 'name', 'description', 'source'].every(key => typeof item[key] === 'string'))
- || value.native_mcp.some(item => !record(item) || !['name', 'source', 'transport'].every(key => typeof item[key] === 'string') || typeof item.enabled !== 'boolean')) {
- throw new ChatExtensionsError('chat_extensions.catalog_invalid', 'chat_extensions.catalog_invalid', JSON.stringify(value, null, 2));
- }
- const ids = (value.packages as ExtensionPackage[]).map(item => item.id);
- if (new Set(ids).size !== ids.length) throw new ChatExtensionsError('chat_extensions.catalog_invalid', 'Duplicate package IDs', JSON.stringify(value, null, 2));
- return value as unknown as Catalog;
-}
-
-/** Chat-only desktop UI for the existing main-branch /api/chat-plugins contract. */
-export function ChatExtensionsSettings({ navigation }: { navigation: ChatExtensionsNavigation }) {
- const { lang } = useLang(); const en = lang === 'en'; const t = (zh: string, english: string) => en ? english : zh;
- const scope = useSyncExternalStore(subscribeConversationStorageScope, conversationStorageScope, () => '');
- const [engine, setEngine] = useState(ENGINES[navigation.searchParams.get('engine') || ''] ? navigation.searchParams.get('engine')! : 'codex');
- const [sourceTab, setSourceTab] = useState(navigation.searchParams.get('source') === 'native' ? 'native' : 'managed');
- const [query, setQuery] = useState(''); const [catalogRecord, setCatalogRecord] = useState<{ owner: string; data: Catalog } | null>(null);
- const [error, setError] = useState(null); const [notice, setNotice] = useState('');
- const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [picking, setPicking] = useState(false);
- const [installOpen, setInstallOpen] = useState(false); const [installOwner, setInstallOwner] = useState(''); const [installType, setInstallType] = useState<'package' | 'mcp'>('package');
- const [kind, setKind] = useState('local-dir'); const [source, setSource] = useState(''); const [ref, setRef] = useState(''); const [name, setName] = useState('');
- const [config, setConfig] = useState('{\n "mcpServers": {}\n}'); const [detailId, setDetailId] = useState(''); const [removeRecord, setRemoveRecord] = useState<{ owner: string; item: ExtensionPackage } | null>(null);
- const owner = JSON.stringify([scope, engine]); const ownerRef = useRef(owner); ownerRef.current = owner;
- const requestId = useRef(0); const operation = useRef<{ owner: string } | null>(null); const abort = useRef(null);
- const catalog = catalogRecord?.owner === owner ? catalogRecord.data : null;
- const remove = removeRecord?.owner === owner ? removeRecord.item : null;
- const detail = catalog?.packages.find(item => item.id === detailId);
- const isCurrent = useCallback((snapshot: string) => ownerRef.current === snapshot && Boolean(scope) && currentAuthScope() === scope, [scope]);
- const reportError = (cause: unknown) => cause instanceof ChatExtensionsError ? cause : new ChatExtensionsError('chat_extensions.request_failed', cause instanceof Error ? cause.message : String(cause));
- const load = useCallback(async (snapshot = owner): Promise => {
- if (!isCurrent(snapshot)) return null;
- abort.current?.abort(); const controller = new AbortController(); abort.current = controller;
- const id = ++requestId.current; setLoading(true);
- try {
- const data = chatExtensionsCatalog(await requestChatExtensions(`?engine=${encodeURIComponent(engine)}`, 'GET', undefined, controller.signal), engine);
- if (isCurrent(snapshot) && id === requestId.current) { setCatalogRecord({ owner: snapshot, data }); setError(null); return data; }
- return null;
- } catch (cause) {
- if (controller.signal.aborted || id !== requestId.current || !isCurrent(snapshot)) return null;
- throw cause;
- } finally { if (isCurrent(snapshot) && id === requestId.current) setLoading(false); }
- }, [engine, isCurrent, owner]);
- useEffect(() => {
- setCatalogRecord(null); setError(null); setNotice(''); setLoading(false); setBusy(false); setPicking(false);
- setInstallOpen(false); setDetailId(''); setRemoveRecord(null); setSource(''); setName(''); setRef(''); setConfig('{\n "mcpServers": {}\n}'); setKind('local-dir'); setInstallType('package');
- operation.current = null;
- if (scope) void load().catch(cause => { if (isCurrent(owner)) setError(reportError(cause)); });
- return () => { abort.current?.abort(); ++requestId.current; };
- }, [owner, scope, load, isCurrent]);
- const refresh = () => { setNotice(''); void load().catch(cause => { if (isCurrent(owner)) setError(reportError(cause)); }); };
- const mutate = async (action: () => Promise, success: string, completed?: () => void, confirm?: (data: Catalog) => boolean) => {
- if (!isCurrent(owner) || operation.current) return;
- const ticket = { owner }; operation.current = ticket; setBusy(true); setError(null); setNotice(''); let applied = false;
- try {
- await action(); applied = true;
- if (!isCurrent(owner) || operation.current !== ticket) return;
- completed?.(); const refreshed = await load(owner);
- if (refreshed && confirm && !confirm(refreshed)) throw new ChatExtensionsError('chat_extensions.readback_mismatch', t('目录尚未确认本次变更,请重载核对。', 'The catalog did not confirm this change. Reload to verify it.'));
- if (refreshed && isCurrent(owner) && operation.current === ticket) setNotice(success);
- } catch (cause) {
- if (isCurrent(owner) && operation.current === ticket) {
- const failure = reportError(cause);
- setError(applied ? new ChatExtensionsError('chat_extensions.readback_failed', t('变更已返回,但目录刷新失败;请重载确认结果。', 'The change returned, but catalog refresh failed. Reload to verify the outcome.'), failure.rawBody || failure.message, failure.httpStatus) : failure);
- }
- } finally { if (operation.current === ticket) { operation.current = null; if (isCurrent(owner)) setBusy(false); } }
- };
- const install = () => {
- let installedId = '';
- void mutate(async () => {
- let result: unknown;
- if (installType === 'mcp') {
- let parsed: unknown; try { parsed = JSON.parse(config); } catch { throw new ChatExtensionsError('chat_extensions.mcp_invalid', t('MCP 配置不是有效 JSON。', 'MCP configuration is not valid JSON.')); }
- if (!record(parsed)) throw new ChatExtensionsError('chat_extensions.mcp_invalid', t('MCP 配置须为对象。', 'MCP configuration must be an object.'));
- result = await requestChatExtensions('/mcp', 'POST', { name: name.trim(), servers: parsed.mcpServers ?? parsed });
- } else {
- if (!source.trim()) throw new ChatExtensionsError('chat_extensions.source_missing', t('请填写服务可访问的来源。', 'Enter a source accessible to the service.'));
- result = await requestChatExtensions('/install', 'POST', { source: { kind, ...(kind === 'git' ? { url: source.trim(), ref: ref.trim() } : { path: source.trim() }) } });
- }
- if (!record(result) || typeof result.id !== 'string' || !result.id) throw new ChatExtensionsError('chat_extensions.mutation_response_invalid', t('服务未确认已导入的扩展,请重载核对。', 'The service did not confirm the imported package. Reload to verify it.'), JSON.stringify(result, null, 2));
- installedId = result.id;
- }, t('扩展已添加,聊天会在下一轮读取变更。', 'Extension added. Chat sessions read changes on their next turn.'), () => { setInstallOpen(false); setSource(''); setName(''); setRef(''); }, data => data.packages.some(item => item.id === installedId));
- };
- const update = (item: ExtensionPackage, body: Record, success: string) => void mutate(async () => {
- const result = await requestChatExtensions(`/packages/${encodeURIComponent(item.id)}`, 'PATCH', body);
- if (!record(result) || result.id !== item.id || (typeof body.enabled === 'boolean' && result.enabled !== body.enabled)
- || (typeof body.native_hooks === 'boolean' && result.native_hooks_enabled !== body.native_hooks)) throw new ChatExtensionsError('chat_extensions.mutation_response_invalid', t('服务未确认扩展变更,请重载核对。', 'The service did not confirm the package change. Reload to verify it.'), JSON.stringify(result, null, 2));
- }, success, undefined, data => data.packages.some(row => row.id === item.id && (typeof body.enabled !== 'boolean' || row.enabled === body.enabled)
- && (typeof body.native_hooks !== 'boolean' || Boolean(row.native_hooks_enabled) === body.native_hooks)));
- const check = (item: ExtensionPackage) => void mutate(async () => {
- const result = await requestChatExtensions(`/check/${encodeURIComponent(item.id)}`, 'POST');
- if (!record(result) || !Number.isSafeInteger(result.tool_count) || Number(result.tool_count) < 0 || !strings(result.diagnostics)) throw new ChatExtensionsError('chat_extensions.check_invalid', 'chat_extensions.check_invalid', JSON.stringify(result, null, 2));
- if (result.diagnostics.length) throw new ChatExtensionsError('chat_extensions.check_failed', result.diagnostics.join('\n'), JSON.stringify(result, null, 2));
- }, t('MCP 检查已返回;目录已刷新。', 'MCP check returned; the catalog was refreshed.'));
- const errorView = error ? {error.message}
{t('完整诊断', 'Full diagnostics')} {error.code}{error.httpStatus != null ? ` · HTTP ${error.httpStatus}` : ''}{error.rawBody ? `\n${error.rawBody}` : ''} : null;
- const filtered = catalog?.packages.filter(item => `${item.name} ${item.description}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())) || [];
- const locked = busy || picking || !scope;
- return
-
{t('聊天扩展', 'Chat extensions')} {t('重载目录', 'Reload catalog')} { setError(null); setSource(''); setName(''); setRef(''); setInstallOwner(owner); setInstallOpen(true); }}>{t('添加扩展', 'Add extension')}
-
{t('仅管理聊天 Agent 的扩展。包与本机能力来自当前服务宿主;桌面客户端的文件不会自动同步至服务。', 'Manage chat Agent extensions only. Packages and native capabilities belong to the connected service host; client files do not automatically synchronize with the service.')}
-
- {!scope ?
{t('尚未确认服务身份,请先连接并登录。', 'Connect and sign in to confirm the service identity.')} : null}
-
- {!installOpen && !detail && !remove ? errorView : null}{notice ?
{notice} : null}{loading ?
{t('读取目录中…', 'Loading catalog…')}
: null}
- {sourceTab === 'managed' && catalog ?
{t('Muteki 平台工具', 'Muteki platform tools')} {t('聊天中访问授权的平台能力。', 'Access authorized platform capabilities from chat.')}
void mutate(() => requestChatExtensions('/control', 'PUT', { enabled }).then(result => { if (!record(result) || result.enabled !== enabled) throw new ChatExtensionsError('chat_extensions.mutation_response_invalid', t('服务未确认平台工具变更。', 'The service did not confirm the platform tool change.'), JSON.stringify(result, null, 2)); return result; }), t('平台工具状态已更新。', 'Platform tool state updated.'), undefined, data => data.control_enabled === enabled)} /> {filtered.map(item =>
{ setError(null); setDetailId(item.id); }}>{item.name} {item.description}
{item.version} · {item.skills.length} Skills · {item.mcp_servers.length} MCP
{item.diagnostics.map((diagnostic, index) =>
{diagnostic}
)}
update(item, { enabled }, t('扩展启用状态已更新。', 'Package enabled state updated.'))} /> )}{!filtered.length ?
{t('没有匹配的扩展。', 'No matching packages.')}
: null}
: null}
- {sourceTab === 'native' && catalog ?
{catalog.host_discovery_enabled === false ?
{t('当前服务已禁用宿主能力发现。请使用已登记凭据和手动导入扩展;这不表示 Agent 未登录。', 'This service disables host discovery. Use registered credentials and manually imported packages; this does not mean the Agent is signed out.')} :
{t('只读查看服务宿主已发现的 Skill 和 MCP 配置;配置存在不等于已连接。', 'Read-only discovered skills and MCP configuration on the service host. Configuration presence does not prove a connection.')}
}{catalog.native_skills.filter(item => `${item.name} ${item.description}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())).map(item =>
{item.name} {item.description}
{item.source}
)}{catalog.native_mcp.filter(item => item.name.toLocaleLowerCase().includes(query.toLocaleLowerCase())).map((item, index) =>
{item.name} {item.enabled ? t('配置已启用', 'Configuration enabled') : t('配置已禁用', 'Configuration disabled')} · {item.transport} · {item.source}
)}
: null}
-
{ if (!busy && !picking) setInstallOpen(next); }} title={t('添加聊天扩展', 'Add chat extension')} size="md" dismissable={!busy && !picking} footer={<> setInstallOpen(false)}>{t('取消', 'Cancel')} {t('导入', 'Import')} >}>
-
-
-
{ if (!next && !busy) setDetailId(''); }} title={detail?.name || ''} size="md" dismissable={!busy} footer={<> setDetailId('')}>{t('关闭', 'Close')} {detail?.can_rollback ? update(detail, { rollback: true }, t('版本已回滚。', 'Version rolled back.'))}>{t('回滚', 'Roll back')} : null}{detail?.mcp_servers.length ? check(detail)}>{t('检查 MCP', 'Check MCP')} : null} { if (detail) { setRemoveRecord({ owner, item: detail }); setDetailId(''); } }}>{t('移除', 'Remove')} >}>
- {detail ? {detail.description}
{errorView}
{t('版本与指纹', 'Version and digest')}: {detail.version} · {detail.digest}
{Boolean(detail.hook_commands?.length) ? <>
{detail.hook_commands?.join('\n')} update(detail, { native_hooks, digest: detail.digest }, t('此版本 hooks 状态已更新。', 'Hooks for this version updated.'))} />> : null}{t('完整兼容性与组件信息', 'Full compatibility and component information')} {JSON.stringify({ skills: detail.skills, mcp: detail.mcp_servers, components: detail.native_components, compatibility: detail.compatibility, hooks: detail.hook_definitions, diagnostics: detail.diagnostics }, null, 2)} : null}
-
-
{ if (!next && !busy) setRemoveRecord(null); }} title={t('移除聊天扩展', 'Remove chat extension')} size="sm" dismissable={!busy} footer={<> setRemoveRecord(null)}>{t('取消', 'Cancel')} { if (remove) void mutate(async () => { const result = await requestChatExtensions(`/packages/${encodeURIComponent(remove.id)}`, 'DELETE'); if (!record(result) || result.removed !== true) throw new ChatExtensionsError('chat_extensions.mutation_response_invalid', t('服务未确认移除,请重载核对。', 'The service did not confirm removal. Reload to verify it.'), JSON.stringify(result, null, 2)); }, t('扩展已移除。', 'Package removed.'), () => setRemoveRecord(null), data => !data.packages.some(item => item.id === remove.id)); }}>{t('确认移除', 'Confirm removal')} >}>{remove?.name}
{errorView}
-
;
-}
diff --git a/apps/desktop/renderer/main.tsx b/apps/desktop/renderer/main.tsx
index fa105f5..c0f8bca 100644
--- a/apps/desktop/renderer/main.tsx
+++ b/apps/desktop/renderer/main.tsx
@@ -28,8 +28,15 @@ import "@/app/styles/settings-model-picker.css";
import "@/app/styles/settings-workspace.css";
import "@/app/styles/settings-hub.css";
import "@/app/styles/settings-notifications.css";
-import "@/app/styles/settings-agents.css";
import "@/app/styles/task-worker-settings.css";
+import "@/app/styles/settings-unified.css";
+import "@/app/styles/settings-agent-extensions.css";
+import "@/app/styles/settings-unified-agent-extensions.css";
+import "@/app/styles/settings-unified-capabilities.css";
+import "@/app/styles/settings-unified-extensions.css";
+import "@/app/styles/settings-unified-operations.css";
+import "@/app/styles/settings-unified-data.css";
+
import "@/app/styles/pentest.css";
import "@/app/styles/ux.css";
import "@/app/styles/brand.css";
@@ -43,6 +50,7 @@ import "@/app/styles/chat/surfaces.css";
import "@/app/styles/chat/dialogs.css";
import "@/app/styles/chat/interaction.css";
import "@/app/styles/workspace-rail.css";
+import "@/app/styles/chat/refine.css";
import '@/styles/transitions/_root.css';
import '@/styles/transitions/index.css';
import '@/styles/transitions/surfaces.css';
diff --git a/apps/desktop/renderer/settings/DesktopSettings.tsx b/apps/desktop/renderer/settings/DesktopSettings.tsx
new file mode 100644
index 0000000..0449fba
--- /dev/null
+++ b/apps/desktop/renderer/settings/DesktopSettings.tsx
@@ -0,0 +1,17 @@
+import { useEffect } from 'react';
+import { SettingsHost, type SettingsNavigation } from '@/components/settings/SettingsHost';
+import { SettingsContent } from '@/components/settings/SettingsContent';
+import { settingsPageFromPath, settingsRedirect } from '@/components/settings/catalog';
+export interface DesktopSettingsProps {
+ pathname: string; navigation: SettingsNavigation; go(href: string, replace?: boolean): void;
+ origin: string; connected: boolean; onConfigure(): void; onHelp(): void; onBack(): void;
+}
+export function DesktopSettings({pathname, navigation, go, origin, connected, onConfigure, onHelp, onBack}: DesktopSettingsProps) {
+ const redirect = settingsRedirect(pathname, navigation.searchParams);
+ useEffect(() => { if (redirect) go(redirect + (navigation.hash || ''), true); }, [redirect, navigation.hash, go]);
+ const active = settingsPageFromPath(pathname);
+ if (redirect) return 正在打开设置…
;
+ return
+ {!redirect && (active ? : 此设置页面不存在。
)}
+ ;
+}
diff --git a/apps/desktop/renderer/styles.css b/apps/desktop/renderer/styles.css
index 6ade1ac..0e4b9be 100644
--- a/apps/desktop/renderer/styles.css
+++ b/apps/desktop/renderer/styles.css
@@ -1,12 +1,15 @@
-:root { color-scheme: light; --shell: #f3f3f3; --surface: #fff; --fg: #292929; --muted: #858585; --border: #e7e7e7; --hover: #e9e9e9; --accent: #387bf3; --error: #ae3737; font: 13px -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", "PingFang SC", sans-serif; color: var(--fg); background: var(--shell); -webkit-font-smoothing: antialiased; }
-:root[data-theme="dark"] { color-scheme: dark; --shell: #202020; --surface: #17191d; --fg: #e8e8e8; --muted: #a0a0a0; --border: #353535; --hover: #343434; --error: #f59b9b; }
-* { box-sizing: border-box; }
-body { margin: 0; overflow: hidden; }
-button, .connection-panel input { font: inherit; color: inherit; }
-button { border: 0; background: none; cursor: pointer; -webkit-app-region: no-drag; }
-button:disabled { opacity: .35; cursor: default; }
-:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
-svg { flex-shrink: 0; }
+:root { color-scheme: light; --shell: #f3f3f3; --surface: #fff; --fg: #292929; --muted: #858585; --border: #e7e7e7; --hover: #e9e9e9; --accent: #387bf3; --error: #ae3737; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", "PingFang SC", sans-serif; color: var(--fg); background: var(--shell); -webkit-font-smoothing: antialiased; }
+:root[data-theme="dark"] { color-scheme: dark; --shell: #111214; --surface: #17191d; --fg: #e8e8e8; --muted: #a0a0a0; --border: #353535; --hover: #343434; --error: #f59b9b; }
+/* Element resets stay in Tailwind's base layer so utility classes on shared components win. */
+@layer base {
+ * { box-sizing: border-box; }
+ body { margin: 0; overflow: hidden; }
+ button, .connection-panel input { font: inherit; color: inherit; }
+ button { border: 0; background: none; cursor: pointer; -webkit-app-region: no-drag; }
+ button:disabled { opacity: .35; cursor: default; }
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
+ svg { flex-shrink: 0; }
+}
.desktop-shell { display: grid; grid-template-columns: 56px minmax(0, 1fr); grid-template-rows: 44px minmax(0, 1fr); width: 100vw; height: 100dvh; overflow: hidden; }
.titlebar { grid-column: 1 / -1; display: flex; align-items: center; gap: 7px; padding: 0 10px; -webkit-app-region: drag; user-select: none; }
.titlebar.mac { padding-left: 92px; }
@@ -24,7 +27,7 @@ svg { flex-shrink: 0; }
.rail-item { position: relative; }
.nav-badge { position: absolute; top: 1px; right: -1px; min-width: 14px; height: 14px; border-radius: 8px; padding: 0 3px; font-size: 9px; line-height: 14px; background: var(--accent); color: #fff; text-align: center; pointer-events: none; }
.rail-divider { height: 1px; width: 22px; background: var(--border); margin: 2px 0; }
-.service-surface { grid-row: 2; grid-column: 2; border-radius: 14px 0 0 0; min-width: 0; min-height: 0; background: var(--surface); overflow: hidden; }
+.service-surface { grid-row: 2; grid-column: 2; min-width: 0; min-height: 0; overflow: hidden; }
.connection-stage { display: grid; place-items: center; height: 100%; padding: 30px; }
.connection-panel, .help-panel { width: min(450px, 100%); }
.eyebrow { margin: 0 0 17px; color: var(--muted); font-size: 11px; letter-spacing: .18em; }
@@ -46,16 +49,32 @@ svg { flex-shrink: 0; }
.local-workspace { height: 100%; min-width: 0; min-height: 0; overflow: hidden; }
.local-workspace[hidden] { display: none; }
-.local-workspace .login-gate { position: absolute; inset: 44px 0 0 56px; }
-.desktop-settings { height: 100%; overflow: hidden; display: flex; flex-direction: column; }
-.desktop-settings-content { flex: 1; min-height: 0; overflow: auto; }
-.desktop-settings-content > .wsettings-simple-page { height: auto; min-height: 100%; overflow: visible; }
-.desktop-settings>nav { display: flex; flex-wrap: wrap; gap: 12px; padding: 12px 20px; border-bottom: 1px solid var(--border); flex: none; }
-.desktop-settings>nav button { padding: 8px; }
-.desktop-settings .workers-page, .desktop-settings .settings-workspace { min-height: 0; flex: 1; }
-.desktop-notice { position: fixed; z-index: 10001; top: 48px; right: 12px; max-width: min(560px,calc(100vw - 80px)); padding: 12px 16px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; color: var(--red); display: flex; align-items: center; gap: 10px; }
-.desktop-notice button { flex: none; }
+.local-workspace .login-gate { position: absolute; inset: 0; }
.local-workspace .ai-conv-shell { height: 100%; }
-.desktop-settings>nav input { flex: 1; min-width: 160px; max-width: 240px; width: auto; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
-.desktop-settings>nav button[aria-current="page"] { background: var(--hover); border-radius: 6px; }
+
+/* Floating workspace: every mode renders inside one raised rounded card that
+ stands off the window frame. Remote pages (CTF, pentest, competitions) are
+ native views placed on the same rectangle; keep SURFACE_INSET and
+ SURFACE_RADIUS in src/main.cjs in sync with these values. */
+.desktop-shell {
+ --desktop-card-radius: 12px;
+ --desktop-card-shadow: 0 0 0 1px var(--cx-border-subtle, var(--border)), 0 1px 3px hsl(var(--cx-shadow-color, 220 40% 8%) / .07), 0 10px 30px -10px hsl(var(--cx-shadow-color, 220 40% 8%) / .2);
+}
+:root[data-theme="dark"] .desktop-shell {
+ --desktop-card-shadow: 0 0 0 1px var(--cx-border, var(--border)), 0 1px 3px hsl(0 0% 0% / .4), 0 12px 34px -10px hsl(0 0% 0% / .7);
+}
+.service-surface {
+ position: relative;
+ margin: 2px 8px 8px 0;
+ border-radius: var(--desktop-card-radius);
+ background: var(--cx-bg, var(--surface));
+ box-shadow: var(--desktop-card-shadow);
+}
+
+.desktop-notice { position: fixed; z-index: 10001; top: 48px; right: 12px; max-width: min(560px, calc(100vw - 80px)); padding: 10px 12px 10px 14px; background: var(--cx-elevated, var(--surface)); border: 1px solid var(--cx-border, var(--border)); border-radius: 12px; box-shadow: var(--cx-shadow-md, 0 8px 24px rgb(0 0 0 / .12)); color: var(--cx-fg, var(--fg)); display: flex; align-items: center; gap: 10px; font-size: 13px; line-height: 1.5; }
+.desktop-notice button { flex: none; }
+
+
+.local-service-choice { margin: 20px 0 28px; }
+.environment-label { margin-left: 10px; font-size: 11px; font-weight: 600; opacity: .65; }
diff --git a/apps/desktop/renderer/types.ts b/apps/desktop/renderer/types.ts
index ab1b52e..128407b 100644
--- a/apps/desktop/renderer/types.ts
+++ b/apps/desktop/renderer/types.ts
@@ -3,17 +3,25 @@ export type DesktopWindowAction = 'minimize' | 'maximize' | 'close';
import type { DesktopSpeechInput, DesktopSpeechEvent } from '../../web/ui/lib/desktopChatBridge';
export interface DesktopNotificationScope { connectionVersion: number; serviceId: string; identityId: string }
export interface DesktopNotificationEvent extends DesktopNotificationScope {
- id: string; threadId: string; eventId: string; dedupeKey: string; seq: number;
+ id: string; threadId: string; eventId: string; requestedEventId?: string; dedupeKey: string; seq: number;
status: 'submitted' | 'awaiting_show' | 'shown' | 'failed' | 'outcome_unknown' | 'clicked' | 'closed';
shown: boolean; code?: string; message?: string;
history: Array<{status: DesktopNotificationEvent['status']; at: string; code?: string; message?: string}>;
}
export interface DesktopNotificationStatus extends DesktopNotificationScope {
permission: NotificationPermission | 'unsupported'; workspacePermission: NotificationPermission;
- host: 'desktop-client'; systemPermission: 'unknown'; code: string;
+ host: 'desktop-client'; systemPermission: 'unknown' | 'default' | 'granted' | 'denied' | 'provisional' | 'ephemeral'; code: string;
+ systemNotificationSettings?: { authorizationStatus: string; authorizationStatusRaw: number; alertSettingRaw?: number; soundSettingRaw?: number };
+ systemPermissionError?: { code: string; message: string; detail?: string };
delivery?: DesktopNotificationEvent;
}
export type DesktopState = {
+ environment?: { id: string; channel: 'stable' | 'dev' | 'candidate'; name: string };
+ managedService?: { state: 'stopped' | 'starting' | 'maintenance' | 'ready' | 'failed'; error?: string; origin?: string; portChanged?: boolean; logs: string };
+ notificationOwner?: boolean; notificationWorkspaceKey?: string; notificationVisibleThreadIds?: string[];
+ authSessionVersion?: number;
+ anchorTarget?: {hash: string; id: number};
+ serviceVersion?: string; serviceFeatures?: Record; desktopVersion?: string; remoteUiBuild?: string; remoteAppearanceContract?: string;
origin: string; transportOrigin: string; status: 'idle' | 'connecting' | 'connected' | 'error';
message: string; configuring: boolean; platform: string; route: string; connectionVersion?: number;
serviceId?: string; identityId?: string; persistenceWarning?: string;
@@ -23,13 +31,17 @@ export type DesktopState = {
capabilities?: {version: number; host: string; entries?: Record; [key: string]: unknown};
};
export interface DesktopBridge {
+ connectLocal(): Promise;
+ consumeAnchor(id: number): Promise;
getState(): Promise; connect(origin: string): Promise;
configure(): Promise; resume(): Promise; navigate(href: string, mode?: 'push' | 'replace'): Promise;
action(name: DesktopCommand): Promise; setLocale(lang: 'zh' | 'en'): Promise;
+ syncAppearance(input: {preference: 'system' | 'light' | 'dark'; resolvedTheme: 'light' | 'dark'; selection: {kind: 'preset'; id: string} | {kind: 'custom'; hue: number}}): Promise;
windowAction(name: DesktopWindowAction): Promise; newWindow(route: string): Promise<{windowId: number}>;
openExternal(url: string): Promise;
- openPreview(input: {surfaceId: string; url: string; reload?: boolean; rect: {x: number; y: number; width: number; height: number}; threadId: string}): Promise<{id: string}>; closePreview(input?: {id?: string; surfaceId?: string; hide?: boolean}): Promise;
- previewAction(input: {id: string; action: 'back' | 'forward' | 'reload' | 'stop'}): Promise;
+ openPreview(input: {surfaceId: string; url: string; reload?: boolean; rect: {x: number; y: number; width: number; height: number}; threadId: string; persistent?: boolean}): Promise<{id: string}>; closePreview(input?: {id?: string; surfaceId?: string; hide?: boolean}): Promise;
+ previewAction(input: {id: string; action: 'back' | 'forward' | 'reload' | 'stop' | 'pick' | 'pick-cancel' | 'reload-hard' | 'devtools' | 'clear-data' | 'screenshot' | 'zoom' | 'color-scheme'; factor?: number; scheme?: string}): Promise; // eslint-disable-line @typescript-eslint/no-explicit-any
+ browserControl(input: {threadId: string; action: string; timeoutMs: number; [key: string]: unknown}): Promise>;
createVisualization(input: {threadId: string; html: string}): Promise<{id: string; url: string}>;
releaseVisualization(id: string): Promise;
onPreview(callback: (event: {id: string; threadId: string; url: string; title?: string; status: 'loading' | 'loaded' | 'error'; message?: string; canGoBack?: boolean; canGoForward?: boolean}) => void): () => void;
@@ -45,9 +57,10 @@ export interface DesktopBridge {
startSpeech(input: DesktopSpeechInput): Promise<{id: string}>;
finishSpeech(input: {id: string}): Promise; cancelSpeech(input: {id: string}): Promise;
onSpeech(callback: (event: DesktopSpeechEvent) => void): () => void;
+ playNotificationSound(input: DesktopNotificationScope & {threadId: string; eventId: string; dedupeKey: string}): Promise;
notificationStatus(input: DesktopNotificationScope): Promise;
requestNotifications(input: DesktopNotificationScope): Promise;
- sendNotification(input: DesktopNotificationScope & {threadId: string; eventId: string; dedupeKey: string; title: string; body: string}): Promise;
+ sendNotification(input: DesktopNotificationScope & {threadId: string; eventId: string; dedupeKey: string; title: string; body: string; wantSound?: boolean}): Promise;
onNotification(callback: (event: DesktopNotificationEvent) => void): () => void;
openPermissionSettings(name: 'microphone' | 'speechRecognition' | 'notifications'): Promise;
acknowledgeClose(input: {token: string; persisted: boolean; error?: string}): Promise;
@@ -56,6 +69,7 @@ export interface DesktopBridge {
onTerminal(callback: (event: {id: string; type: 'open' | 'message' | 'error' | 'close'; data?: string; code?: string | number; message?: string}) => void): () => void;
onBeforeClose(callback: (event: {token: string}) => void): () => void;
onCommand(callback: (command: {name: DesktopCommand}) => void): () => void;
+ onFocus(callback: () => void): () => void;
onState(callback: (state: DesktopState) => void): () => void;
}
declare global { interface Window { mutekiDesktop: DesktopBridge } }
diff --git a/apps/desktop/scripts/build-native.cjs b/apps/desktop/scripts/build-native.cjs
index df22bfb..52e51f6 100644
--- a/apps/desktop/scripts/build-native.cjs
+++ b/apps/desktop/scripts/build-native.cjs
@@ -8,7 +8,7 @@ const headers = prefixes.map(prefix => path.join(prefix, 'include', 'node')).fin
if (!headers) throw new Error('Node development headers are required to build Apple Speech. Set npm_config_nodedir to the Node installation containing include/node/node_api.h.');
const output = path.join(root, 'native', 'build', 'macos-speech.node');
fs.mkdirSync(path.dirname(output), { recursive: true });
-const result = spawnSync('xcrun', ['clang++', '-std=c++17', '-fobjc-arc', '-fblocks', '-mmacosx-version-min=11.0', '-arch', 'arm64', '-arch', 'x86_64', '-bundle', '-undefined', 'dynamic_lookup', '-I', headers, '-framework', 'Foundation', '-framework', 'AVFoundation', '-framework', 'Speech', path.join(root, 'native', 'speech.mm'), '-o', output], { stdio: 'inherit' });
+const result = spawnSync('xcrun', ['clang++', '-std=c++17', '-fobjc-arc', '-fblocks', '-mmacosx-version-min=11.0', '-arch', 'arm64', '-arch', 'x86_64', '-bundle', '-undefined', 'dynamic_lookup', '-I', headers, '-framework', 'Foundation', '-framework', 'AVFoundation', '-framework', 'Speech', '-framework', 'UserNotifications', path.join(root, 'native', 'speech.mm'), '-o', output], { stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status || 1);
console.log('Built universal Apple Speech Node-API module.');
diff --git a/apps/desktop/scripts/dev-signing.cjs b/apps/desktop/scripts/dev-signing.cjs
new file mode 100644
index 0000000..77e54fb
--- /dev/null
+++ b/apps/desktop/scripts/dev-signing.cjs
@@ -0,0 +1,31 @@
+const fs = require('node:fs');
+
+function resolveDevSigningIdentity(configPath, env = process.env) {
+ const override = env.CSC_NAME?.trim() || env.MUTEKI_CODESIGN_IDENTITY?.trim();
+ if (override) {
+ if (override === '-') throw new Error('Muteki Dev requires a certificate identity; ad-hoc signing is disabled.');
+ return override;
+ }
+ let config;
+ try {
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ } catch (error) {
+ throw new Error(`Muteki Dev signing configuration unavailable: ${configPath}. Set CSC_NAME or MUTEKI_CODESIGN_IDENTITY, or configure identityFingerprint in this JSON file.`, { cause: error });
+ }
+ if (typeof config.identityFingerprint !== 'string' || !/^[a-fA-F0-9]{40}$/.test(config.identityFingerprint)) {
+ throw new Error(`Muteki Dev signing configuration invalid: ${configPath}. identityFingerprint must be a 40-character certificate SHA-1 fingerprint.`);
+ }
+ return config.identityFingerprint.toUpperCase();
+}
+
+async function requireDevSigningIdentity(identity, keychainFile, findIdentity) {
+ // Match electron-builder's non-MAS certificate lookup, but fail explicitly:
+ // its custom-sign branch otherwise permits a missing identity to skip signing.
+ const certificate = await findIdentity('Developer ID Application', identity, keychainFile)
+ || await findIdentity('Mac Developer', identity, keychainFile);
+ if (!certificate) {
+ throw new Error(`Muteki Dev signing certificate is unavailable: ${identity}. Install the matching valid certificate and private key, or explicitly select another identity. Automatic or ad-hoc fallback is disabled.`);
+ }
+}
+
+module.exports = { resolveDevSigningIdentity, requireDevSigningIdentity };
diff --git a/apps/desktop/scripts/dev.cjs b/apps/desktop/scripts/dev.cjs
new file mode 100644
index 0000000..e40c8b3
--- /dev/null
+++ b/apps/desktop/scripts/dev.cjs
@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+// Machine-readable Dev launcher/control. Tokens stay in the private profile.
+const fs = require('node:fs');
+const path = require('node:path');
+const { spawn } = require('node:child_process');
+const { desktopEnvironment } = require('../src/environment.cjs');
+
+async function main() {
+ const [command, ...args] = process.argv.slice(2);
+ const option = name => { const index = args.indexOf(`--${name}`); return index < 0 ? undefined : args[index + 1]; };
+ if (!['start', 'stop', 'status', 'snapshot', 'screenshot', 'click', 'type'].includes(command) || !option('root')) throw new Error('Usage: node scripts/dev.cjs start|stop|status|snapshot|screenshot|click|type --root [--generation --target --ref --text --output ]');
+ const root = path.resolve(option('root'));
+ const descriptor = path.join(root, 'dev-control.json');
+ if (command === 'start') {
+ const workspace = path.resolve(__dirname, '../../..');
+ desktopEnvironment({appData: path.dirname(root), root, workspace, packaged: false, channel: 'dev'});
+ const built = path.resolve(__dirname, '../dist-dev/mac-arm64/Muteki Dev.app/Contents/MacOS/Muteki Dev');
+ const packaged = option('executable') || (process.platform === 'darwin' && process.arch === 'arm64' && fs.existsSync(built) ? built : undefined);
+ const executable = packaged || require('electron');
+ const log = fs.openSync(path.join(root, 'logs', 'desktop.log'), 'a', 0o600);
+ const env = {...process.env, MUTEKI_DESKTOP_WORKSPACE: workspace, MUTEKI_DESKTOP_NODE: process.execPath, MUTEKI_DESKTOP_REGISTER_PROTOCOL: '0'};
+ for (const key of ['ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS', 'NODE_PATH', 'MUTEKI_DESKTOP_URL', 'MUTEKI_DESKTOP_USER_DATA']) delete env[key];
+ const child = spawn(executable, [...(packaged ? [] : [path.resolve(__dirname, '..')]), '--muteki-channel=dev', `--muteki-environment-root=${root}`, '--muteki-control'], {env, detached: true, stdio: ['ignore', log, log]});
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
+ fs.closeSync(log); child.unref();
+ const deadline = Date.now() + 30000;
+ while (Date.now() < deadline) {
+ if (fs.existsSync(descriptor)) {
+ const value = JSON.parse(fs.readFileSync(descriptor, 'utf8'));
+ try {
+ const response = await fetch(value.origin + '/status', {headers: {Authorization: `Bearer ${value.token}`}, signal: AbortSignal.timeout(2000)});
+ if (response.ok) { const state = await response.json(); if (state.targets.some(target => target.url.startsWith('muteki-desktop://app/'))) { console.log(JSON.stringify(state)); return; } }
+ } catch { /* the new process has not published its ready listener yet */ }
+ }
+ await new Promise(resolve => setTimeout(resolve, 200));
+ }
+ throw new Error(`Dev did not become ready; inspect ${path.join(root, 'logs', 'desktop.log')}`);
+ }
+ const value = JSON.parse(fs.readFileSync(descriptor, 'utf8'));
+ if (command !== 'status' && (!option('generation') || (command !== 'stop' && !option('target')))) throw new Error('Read status first, then supply its generation and target');
+ const body = command === 'status' ? undefined : JSON.stringify({action: command, generation: option('generation'), target: option('target'), ref: Number(option('ref')), text: option('text')});
+ const response = await fetch(value.origin + (command === 'stop' ? '/stop' : body ? '/action' : '/status'), {method: body ? 'POST' : 'GET', headers: {Authorization: `Bearer ${value.token}`, 'Content-Type': 'application/json'}, body, signal: AbortSignal.timeout(30000)});
+ const result = await response.json();
+ if (!response.ok) throw Object.assign(new Error(result.message || result.code), {code: result.code});
+ if (command === 'screenshot') {
+ if (!option('output')) throw new Error('screenshot requires --output ');
+ const file = path.resolve(option('output')); fs.writeFileSync(file, Buffer.from(result.png, 'base64')); console.log(JSON.stringify({path: file}));
+ } else console.log(JSON.stringify(result));
+}
+main().catch(error => { console.error(JSON.stringify({code: error.code || 'dev.launch_failed', message: error.message})); process.exitCode = 1; });
diff --git a/apps/desktop/scripts/sign-macos.cjs b/apps/desktop/scripts/sign-macos.cjs
new file mode 100644
index 0000000..d8a5d78
--- /dev/null
+++ b/apps/desktop/scripts/sign-macos.cjs
@@ -0,0 +1,21 @@
+const fs = require('node:fs');
+const { signAsync } = require('@electron/osx-sign');
+const MACH_O = new Set(['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca']);
+
+// osx-sign's generic binary detector includes .pak, .pyc, fonts and model data.
+// Their integrity is sealed by the containing bundle; only actual Mach-O files
+// and nested bundles need individual executable signatures.
+function resourceOnly(file) {
+ const stat = fs.lstatSync(file);
+ if (stat.isSymbolicLink()) return true;
+ if (stat.isDirectory()) return false;
+ const fd = fs.openSync(file, 'r'), magic = Buffer.alloc(4);
+ try { return fs.readSync(fd, magic, 0, 4, 0) !== 4 || !MACH_O.has(magic.toString('hex')); }
+ finally { fs.closeSync(fd); }
+}
+module.exports = async options => {
+ const ignores = Array.isArray(options.ignore) ? options.ignore : options.ignore ? [options.ignore] : [];
+ // osx-sign 1.3.3 normalizes a function correctly but drops an ignore array.
+ const ignore = file => resourceOnly(file) || ignores.some(rule => typeof rule === 'function' ? rule(file) : file.match(rule));
+ return signAsync({...options, ignore});
+};
diff --git a/apps/desktop/src/auth.cjs b/apps/desktop/src/auth.cjs
new file mode 100644
index 0000000..1ca1f8e
--- /dev/null
+++ b/apps/desktop/src/auth.cjs
@@ -0,0 +1,95 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const { randomUUID } = require('node:crypto');
+
+// The renderer never receives or stores a bearer token. Native sessions are
+// keyed by both the requested address and the service's installation identity.
+class ServiceAuthSessions {
+ constructor(file, safeStorage, onChange = () => {}) {
+ this.file = file; this.safeStorage = safeStorage; this.onChange = onChange;
+ this.sessions = new Map(); this.saved = new Map(); this.restoreWarning = '';
+ try {
+ if (!fs.existsSync(file)) return;
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
+ if (data.version !== 1 || !Array.isArray(data.entries)) throw new Error('invalid session cache');
+ for (const entry of data.entries) {
+ if (!entry || typeof entry.origin !== 'string' || typeof entry.serviceId !== 'string' || typeof entry.encrypted !== 'string') throw new Error('invalid session entry');
+ this.saved.set(this.key(entry.origin, entry.serviceId), entry);
+ }
+ } catch {
+ this.restoreWarning = '记住的登录无法读取;请重新登录。新的会话会在保存成功后恢复记住登录。';
+ }
+ }
+ key(origin, serviceId) { return `${origin}|${serviceId}`; }
+ canEncrypt() {
+ try {
+ return this.safeStorage.isEncryptionAvailable()
+ && !(process.platform === 'linux' && this.safeStorage.getSelectedStorageBackend() === 'basic_text');
+ } catch { return false; }
+ }
+ forService(origin, serviceId) {
+ const key = this.key(origin, serviceId);
+ if (this.sessions.has(key)) return this.sessions.get(key);
+ const owner = { origin, serviceId, token: '', expiresAt: 0, revision: 0,
+ persistenceWarning: this.restoreWarning,
+ snapshot: () => ({ token: owner.token, revision: owner.revision }),
+ accept: (data, remember, source) => this.accept(owner, data, remember, source),
+ clear: (snapshot, source) => this.clear(owner, snapshot, source) };
+ const entry = this.saved.get(key);
+ if (entry) {
+ try {
+ if (!this.canEncrypt()) throw new Error('secure storage unavailable');
+ const data = JSON.parse(this.safeStorage.decryptString(Buffer.from(entry.encrypted, 'base64')));
+ if (data.origin !== origin || data.serviceId !== serviceId || typeof data.token !== 'string' || !Number.isFinite(data.expiresAt)) throw new Error('invalid private session');
+ if (data.expiresAt > Date.now() / 1000) { owner.token = data.token; owner.expiresAt = data.expiresAt; }
+ } catch {
+ owner.persistenceWarning = '记住的登录无法解密,请重新登录。';
+ }
+ }
+ this.sessions.set(key, owner);
+ return owner;
+ }
+ persist() {
+ const temporary = `${this.file}.${randomUUID()}.tmp`;
+ try {
+ fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
+ fs.writeFileSync(temporary, JSON.stringify({ version: 1, entries: [...this.saved.values()] }), { mode: 0o600 });
+ fs.renameSync(temporary, this.file);
+ fs.chmodSync(this.file, 0o600);
+ } finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); }
+ }
+ accept(owner, data, remember, source) {
+ if (data.session_protocol !== 2 || data.service_id !== owner.serviceId || data.session_owner !== 'desktop'
+ || typeof data.auth_required !== 'boolean' || (data.auth_required && (typeof data.token !== 'string' || !data.token || !Number.isFinite(data.expires_at)))) {
+ const error = new Error('服务登录协议或身份不匹配,请重新连接当前服务。');
+ error.code = 'desktop.auth_protocol'; throw error;
+ }
+ owner.token = data.auth_required ? data.token : '';
+ owner.expiresAt = data.auth_required ? data.expires_at : 0;
+ owner.revision += 1; owner.persistenceWarning = '';
+ const key = this.key(owner.origin, owner.serviceId);
+ this.saved.delete(key);
+ if (remember && owner.token) {
+ if (this.canEncrypt()) {
+ try {
+ const plaintext = JSON.stringify({ origin: owner.origin, serviceId: owner.serviceId, token: owner.token, expiresAt: owner.expiresAt });
+ const encrypted = this.safeStorage.encryptString(plaintext).toString('base64');
+ this.saved.set(key, { origin: owner.origin, serviceId: owner.serviceId, encrypted });
+ } catch { owner.persistenceWarning = '登录仅保存在本次桌面运行中:系统安全存储不可用。'; }
+ } else owner.persistenceWarning = '登录仅保存在本次桌面运行中:系统安全存储不可用。';
+ }
+ try { this.persist(); }
+ catch { owner.persistenceWarning = '登录仅保存在本次桌面运行中:会话文件保存失败。'; this.saved.delete(key); }
+ this.onChange(owner, source);
+ }
+ clear(owner, snapshot, source) {
+ // An old request may clear only the token it actually used.
+ if (snapshot && (snapshot.revision !== owner.revision || snapshot.token !== owner.token)) return;
+ owner.token = ''; owner.expiresAt = 0; owner.revision += 1;
+ this.saved.delete(this.key(owner.origin, owner.serviceId));
+ try { this.persist(); owner.persistenceWarning = ''; }
+ catch { owner.persistenceWarning = '本次登录已退出,但本机会话文件清理失败;服务已撤销的令牌无法再次登录。'; }
+ this.onChange(owner, source);
+ }
+}
+module.exports = { ServiceAuthSessions };
diff --git a/apps/desktop/src/dev-bootstrap.cjs b/apps/desktop/src/dev-bootstrap.cjs
new file mode 100644
index 0000000..bea436b
--- /dev/null
+++ b/apps/desktop/src/dev-bootstrap.cjs
@@ -0,0 +1,12 @@
+// The Dev executable keeps its signed identity while application code comes
+// from an explicitly configured checkout. Stable builds never use this entry.
+const fs = require('node:fs');
+const path = require('node:path');
+const metadata = require('../package.json');
+if (metadata.mutekiChannel !== 'dev') throw new Error('Invalid development application');
+const workspace = fs.realpathSync(process.env.MUTEKI_DESKTOP_WORKSPACE || metadata.mutekiWorkspace);
+const entry = path.join(workspace, 'apps/desktop/src/main.cjs');
+if (!fs.existsSync(entry)) throw new Error('Development workspace does not contain Muteki; select it with the Dev launcher');
+process.env.MUTEKI_DESKTOP_WORKSPACE = workspace;
+process.env.MUTEKI_DESKTOP_NODE ||= path.join(process.resourcesPath, 'runtime', process.platform === 'win32' ? 'node.exe' : 'node');
+require(entry);
diff --git a/apps/desktop/src/dev-control.cjs b/apps/desktop/src/dev-control.cjs
new file mode 100644
index 0000000..09c0299
--- /dev/null
+++ b/apps/desktop/src/dev-control.cjs
@@ -0,0 +1,80 @@
+// Explicitly enabled only in a Dev environment. No production debug listener.
+const http = require('node:http');
+const { randomBytes, timingSafeEqual } = require('node:crypto');
+const fs = require('node:fs');
+const path = require('node:path');
+const { atomicJson } = require('./environment.cjs');
+const { pageRead, pageTarget } = require('./preview-control.cjs');
+
+async function startDevControl(environment, targets, status, stop) {
+ if (environment.channel !== 'dev') throw new Error('Desktop automation is available only in a Dev environment');
+ const token = randomBytes(32).toString('hex');
+ const descriptor = path.join(environment.root, 'dev-control.json');
+ const epochs = new WeakMap();
+ const publicTarget = target => {
+ if (!epochs.has(target.contents)) {
+ epochs.set(target.contents, 0);
+ target.contents.on('did-start-navigation', details => { if (details.isMainFrame) epochs.set(target.contents, epochs.get(target.contents) + 1); });
+ }
+ return { id: String(target.contents.id), windowId: target.windowId, kind: target.kind,
+ url: target.contents.getURL(), title: target.contents.getTitle(), environmentId: environment.id,
+ generation: `${environment.generation}:${target.contents.id}:${epochs.get(target.contents)}:${target.revision || ''}`, managed: target.managed, errors: target.errors || [],
+ ...(target.windowState ? { window: target.windowState } : {}) };
+ };
+ const server = http.createServer(async (request, response) => {
+ const reply = (code, value) => { response.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); response.end(JSON.stringify(value)); };
+ const supplied = Buffer.from(request.headers.authorization || '');
+ const expected = Buffer.from(`Bearer ${token}`);
+ if (request.headers.origin || supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) return reply(403, {code: 'dev.control_unauthorized'});
+ try {
+ if (request.method === 'GET' && request.url === '/status') return reply(200, { environmentId: environment.id, generation: environment.generation, pid: process.pid, targets: targets().map(publicTarget), service: status() });
+ if (request.method !== 'POST' || !['/action', '/stop'].includes(request.url)) return reply(404, {code: 'dev.control_not_found'});
+ let body = '';
+ for await (const chunk of request) {
+ body += chunk;
+ if (Buffer.byteLength(body) > 1024 * 1024) return reply(413, {code: 'dev.control_request_too_large'});
+ }
+ const input = JSON.parse(body);
+ if (request.url === '/stop') {
+ if (input.generation !== environment.generation) return reply(409, {code: 'dev.target_expired'});
+ await stop(); return reply(200, {stopping: true});
+ }
+ const current = () => {
+ const target = targets().find(item => String(item.contents.id) === input.target);
+ if (!target || target.contents.isDestroyed()) throw Object.assign(new Error('Dev target is no longer available'), {code: 'dev.target_missing'});
+ if (input.generation !== publicTarget(target).generation) throw Object.assign(new Error('Dev has restarted or navigated; acquire a new target and snapshot'), {code: 'dev.target_expired'});
+ if (!target.managed) throw Object.assign(new Error('This window is connected to an external service; Dev automation is unavailable'), {code: 'dev.external_service'});
+ return target;
+ };
+ const target = current(), contents = target.contents;
+ let result;
+ if (input.action === 'snapshot') {
+ result = await contents.executeJavaScript(`(${pageRead.toString()})(${JSON.stringify({ mode: 'elements' })})`);
+ current();
+ } else if (input.action === 'screenshot') {
+ result = { png: (await contents.capturePage()).toPNG().toString('base64') };
+ current();
+ } else if (['click', 'type'].includes(input.action)) {
+ if (!Number.isSafeInteger(input.ref) || input.ref < 1) throw new Error('Use an element ref from a fresh snapshot');
+ const point = await contents.executeJavaScript(`(${pageTarget.toString()})(${JSON.stringify({ ref: input.ref })})`);
+ current();
+ if (point.missing || point.covered_by) throw Object.assign(new Error('Target element is missing or covered; take a new snapshot'), {code: 'dev.element_unavailable'});
+ const position = {x: Math.round(point.x), y: Math.round(point.y), button: 'left', clickCount: 1};
+ contents.sendInputEvent({type: 'mouseDown', ...position}); contents.sendInputEvent({type: 'mouseUp', ...position});
+ if (input.action === 'type') {
+ if (typeof input.text !== 'string') throw new Error('type requires text');
+ await contents.insertText(input.text);
+ }
+ result = { performed: true, target: publicTarget(current()) };
+ } else throw new Error('Supported actions: snapshot, screenshot, click, type');
+ return reply(200, result);
+ } catch (error) { reply(409, {code: error.code || 'dev.control_failed', message: error.message}); }
+ });
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
+ atomicJson(descriptor, {version: 1, origin: `http://127.0.0.1:${server.address().port}`, token, environmentId: environment.id, generation: environment.generation, pid: process.pid});
+ return () => {
+ server.close(); server.closeAllConnections();
+ if (fs.existsSync(descriptor) && JSON.parse(fs.readFileSync(descriptor, 'utf8')).pid === process.pid) fs.unlinkSync(descriptor);
+ };
+}
+module.exports = { startDevControl };
diff --git a/apps/desktop/src/environment.cjs b/apps/desktop/src/environment.cjs
new file mode 100644
index 0000000..17b0470
--- /dev/null
+++ b/apps/desktop/src/environment.cjs
@@ -0,0 +1,83 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const { randomUUID, createHash } = require('node:crypto');
+
+function atomicJson(file, value) {
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
+ const temporary = `${file}.${randomUUID()}.tmp`;
+ try {
+ fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
+ fs.renameSync(temporary, file);
+ } finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); }
+}
+
+function desktopEnvironment({ appData, packaged, channel, root, workspace }) {
+ channel ||= packaged ? 'stable' : 'dev';
+ if (!['stable', 'dev', 'candidate'].includes(channel)) throw new Error('Invalid desktop environment channel');
+ if (channel === 'candidate' && !root) throw new Error('Candidate requires an explicit, isolated environment directory');
+ workspace = channel === 'dev' ? fs.realpathSync(workspace) : '';
+ const suffix = channel === 'dev' ? `dev-${createHash('sha256').update(workspace).digest('hex').slice(0, 16)}` : channel;
+ root = path.resolve(root || path.join(appData, 'Muteki', 'environments', suffix));
+ if (workspace && (root === workspace || root.startsWith(workspace + path.sep))) throw new Error('Desktop data must be outside the development checkout');
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
+ root = fs.realpathSync(root);
+ const file = path.join(root, 'environment.json');
+ if (!fs.existsSync(file)) {
+ const value = { version: 1, id: randomUUID(), channel, workspace, createdAt: new Date().toISOString() };
+ try { fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); }
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
+ }
+ const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
+ if (manifest.version !== 1 || !/^[a-f0-9-]{36}$/.test(manifest.id) || manifest.channel !== channel || manifest.workspace !== workspace) {
+ throw new Error(`Desktop environment identity does not match this launch: ${file}`);
+ }
+ const paths = Object.fromEntries(['state', 'sessions', 'desktop', 'home', 'logs', 'tmp', 'tools'].map(name => [name, path.join(root, name)]));
+ for (const directory of Object.values(paths)) fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
+ return { ...manifest, root, paths, sharedCredentials: path.join(appData, 'Muteki', 'shared', 'credentials'), generation: randomUUID(), name: channel === 'dev' ? 'Muteki Dev' : channel === 'candidate' ? 'Muteki Candidate' : 'Muteki' };
+}
+
+function serviceEnvironment(context, parent = process.env) {
+ // No inherited Python/Node injection, provider secrets, MUTEKI_* settings or
+ // native CLI homes. Tool binaries are discovered only in explicit locations.
+ const env = {};
+ for (const key of ['SYSTEMROOT', 'SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TZ', 'DISPLAY', 'WAYLAND_DISPLAY', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'SSL_CERT_FILE', 'SSL_CERT_DIR']) {
+ if (parent[key]) env[key] = parent[key];
+ }
+ Object.assign(env, {
+ HOME: context.paths.home, USERPROFILE: context.paths.home,
+ XDG_CONFIG_HOME: path.join(context.paths.home, '.config'), XDG_CACHE_HOME: path.join(context.paths.home, '.cache'), XDG_DATA_HOME: path.join(context.paths.home, '.local', 'share'),
+ TMPDIR: context.paths.tmp, TMP: context.paths.tmp, TEMP: context.paths.tmp,
+ PATH: [path.join(context.paths.tools, 'bin'), '/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin', ...(process.platform === 'win32' ? [path.join(parent.SystemRoot || 'C:\\Windows', 'System32')] : [])].join(path.delimiter),
+ MUTEKI_ENVIRONMENT_ROOT: context.root, MUTEKI_ENVIRONMENT_ID: context.id,
+ MUTEKI_ENVIRONMENT_CHANNEL: context.channel, MUTEKI_MANAGED_DESKTOP: '1',
+ MUTEKI_SESSIONS_ROOT: context.paths.sessions, MUTEKI_STATE_ROOT: context.paths.state,
+ MUTEKI_COORDINATOR_CONTROL_ROOT: path.join(context.paths.state, 'control'),
+ MUTEKI_COORDINATOR_GRAPH_ROOT: path.join(context.paths.state, 'control', 'graphs'),
+ MUTEKI_ENV_FILE: path.join(context.root, 'config', '.env'), MUTEKI_HOST_DISCOVERY: '0',
+ MUTEKI_WEB_BIND: '127.0.0.1', MUTEKI_UI_HOST: '127.0.0.1', MUTEKI_CONTROL_BIND: '127.0.0.1', MUTEKI_CONTROL_PORT: '0',
+ PYTHONDONTWRITEBYTECODE: '1', PYTHONNOUSERSITE: '1', PYTHONUNBUFFERED: '1',
+ NODE_ENV: 'production', NEXT_TELEMETRY_DISABLED: '1',
+ NPM_CONFIG_PREFIX: context.paths.tools, NPM_CONFIG_CACHE: path.join(context.paths.home, '.npm'),
+ });
+ if (context.channel !== 'candidate') env.MUTEKI_SHARED_CREDENTIALS_ROOT = context.sharedCredentials;
+ if (context.maintenance) env.MUTEKI_DESKTOP_MAINTENANCE = '1';
+ return env;
+}
+
+function runtimePaths({ packaged, resources, workspace, node = process.env.MUTEKI_DESKTOP_NODE || process.env.npm_node_execpath || process.execPath }) {
+ if (!packaged) {
+ return { code: workspace, python: path.join(workspace, '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'), node,
+ next: path.join(workspace, 'apps/desktop/build/runtime/web/server.js'), release: 'development' };
+ }
+ const root = path.join(resources, 'runtime');
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8'));
+ if (manifest.version !== 1 || manifest.platform !== process.platform || manifest.arch !== process.arch) throw new Error('Bundled runtime does not match this platform');
+ const inside = relative => {
+ const result = path.resolve(root, relative);
+ if (!result.startsWith(root + path.sep)) throw new Error('Invalid bundled runtime path');
+ return result;
+ };
+ return { code: inside(manifest.code), python: inside(manifest.python), node: inside(manifest.node), next: inside(manifest.next), release: manifest.release };
+}
+
+module.exports = { atomicJson, desktopEnvironment, serviceEnvironment, runtimePaths };
diff --git a/apps/desktop/src/main.cjs b/apps/desktop/src/main.cjs
index a99eeb7..0274bf4 100644
--- a/apps/desktop/src/main.cjs
+++ b/apps/desktop/src/main.cjs
@@ -1,20 +1,68 @@
-const { app, BrowserWindow, WebContentsView, Menu, Notification: NativeNotification, dialog, ipcMain, protocol, session, shell, screen, systemPreferences } = require('electron');
+const { app, BrowserWindow, WebContentsView, Menu, Notification: NativeNotification, dialog, ipcMain, protocol, session, shell, screen, systemPreferences, safeStorage } = require('electron');
const fs = require('node:fs');
const path = require('node:path');
-const { randomUUID } = require('node:crypto');
-const { SHELL_URL, normalizeOrigin, normalizeDesktopRoute, parseWebUrl, partitionFor, webPreferences } = require('./policy.cjs');
-const { readPreferences, writePreferences } = require('./preferences.cjs');
+const { spawn } = require('node:child_process');
+const { randomUUID, createHash } = require('node:crypto');
+const { SHELL_URL, normalizeOrigin, normalizeDesktopRoute, desktopNavigationTarget, parseWebUrl, partitionFor, webPreferences } = require('./policy.cjs');
+const { readPreferences, writePreferences, notificationConsentKey } = require('./preferences.cjs');
const { DesktopError, transportError, errorResponse, probeService, forwardService, closeScope } = require('./transport.cjs');
const { saveAttachment, restoreAttachment, removeAttachment, cacheUsage } = require('./attachment-cache.cjs');
const { NativeSpeech } = require('./speech.cjs');
+const { ServiceAuthSessions } = require('./auth.cjs');
+let authSessions;
+const { createPreviewControl, pickElement, cancelPick } = require('./preview-control.cjs');
+const { desktopEnvironment, runtimePaths, atomicJson } = require('./environment.cjs');
+const { ManagedService } = require('./managed-service.cjs');
-app.setName('Muteki');
-if (process.env.MUTEKI_DESKTOP_USER_DATA) app.setPath('userData', path.resolve(process.env.MUTEKI_DESKTOP_USER_DATA));
+const launchOption = name => process.argv.find(value => value.startsWith(`--muteki-${name}=`))?.split('=').slice(1).join('=');
+const workspace = process.env.MUTEKI_DESKTOP_WORKSPACE || path.resolve(__dirname, '../../..');
+const buildChannel = require(path.join(app.getAppPath(), 'package.json')).mutekiChannel;
+if (app.isPackaged && buildChannel !== 'dev' && launchOption('channel') === 'dev') throw new Error('Use the dedicated Muteki Dev build for desktop automation');
+const environment = desktopEnvironment({ appData: process.env.MUTEKI_DESKTOP_USER_DATA || app.getPath('appData'), packaged: app.isPackaged,
+ channel: launchOption('channel') || buildChannel,
+ root: launchOption('environment-root') || process.env.MUTEKI_ENVIRONMENT_ROOT || (process.env.MUTEKI_DESKTOP_USER_DATA ? path.join(process.env.MUTEKI_DESKTOP_USER_DATA, 'environment') : undefined), workspace });
+app.setName(environment.name);
+app.setPath('userData', process.env.MUTEKI_DESKTOP_USER_DATA ? path.resolve(process.env.MUTEKI_DESKTOP_USER_DATA) : environment.paths.desktop);
+const selfCheck = process.argv.includes('--muteki-self-check');
+if (selfCheck && environment.channel !== 'candidate') throw new Error('Candidate self-check requires an isolated candidate environment');
+let updateJournal = launchOption('update-journal');
+const {unfinishedUpdate, helperRunning, atomicJson: writeUpdateJournal} = require('./update-files.cjs');
+const interruptedUpdate = app.isPackaged && environment.channel === 'stable' && !updateJournal ? unfinishedUpdate(environment.root) : null;
+const repairingUpdate = interruptedUpdate?.journal.activatedAt && !helperRunning(interruptedUpdate.journal);
+if (repairingUpdate) {
+ updateJournal = interruptedUpdate.file;
+ writeUpdateJournal(updateJournal, {...interruptedUpdate.journal, phase: 'validating'});
+}
+if (updateJournal) {
+ const allowed = path.join(environment.root, 'updates') + path.sep;
+ if (!path.resolve(updateJournal).startsWith(allowed) || environment.channel !== 'stable') throw new Error('Invalid update recovery journal');
+ const journal = JSON.parse(fs.readFileSync(updateJournal, 'utf8'));
+ if (journal.environmentRoot !== environment.root || journal.phase !== 'validating') throw new Error('Update recovery context does not match this environment');
+ environment.maintenance = true;
+}
+const patchUpdate = patch => writeUpdateJournal(updateJournal, { ...JSON.parse(fs.readFileSync(updateJournal, 'utf8')), ...patch });
+let desktopUpdates, applyUpdate = false, quitApproved = false;
+let managedService;
+function managedRuntime() {
+ if (!managedService) {
+ managedService = new ManagedService(environment, runtimePaths({ packaged: app.isPackaged && buildChannel !== 'dev', resources: process.resourcesPath, workspace }));
+ managedService.on('state', state => {
+ for (const record of windows.values()) {
+ const failed = state.state === 'failed' && record.scope?.environmentId === environment.id;
+ if (failed) closeScope(record.scope);
+ emit(record, { managedService: state, ...(failed ? { status: 'error', configuring: true, message: state.error } : {}) });
+ }
+ });
+ }
+ return managedService;
+}
app.enableSandbox();
protocol.registerSchemesAsPrivileged([{ scheme: 'muteki-desktop', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true } }]);
const preferencesFile = path.join(app.getPath('userData'), 'connection.json');
let preferences = readPreferences(preferencesFile), language = 'zh', quitting = false;
const windows = new Map(), scopes = new Map(), documents = new Map();
+const deliveredNotificationEvents = new Map();
+const requestedNotificationSounds = new Set();
const nativeSpeech = new NativeSpeech({ platform: process.platform,
load: () => require(app.isPackaged ? path.join(process.resourcesPath, 'native', 'macos-speech.node') : path.join(__dirname, '..', 'native', 'build', 'macos-speech.node')),
send: (record, event) => record.window.webContents.send('desktop:speech', event),
@@ -37,10 +85,38 @@ const CAPABILITIES = Object.freeze({ version: 1, host: 'desktop-client', localCh
} });
const local = value => { try { const u = new URL(value); return u.protocol === 'muteki-desktop:' && u.host === 'app'; } catch { return false; } };
const text = (zh, en) => language === 'en' ? en : zh;
+function isNotificationOwner(record) {
+ const scope = record.scope;
+ if (!scope || scope.closed || !scope.serviceId || !scope.identityId || record.window.isDestroyed()) return false;
+ const key = notificationConsentKey(scope);
+ return [...windows.values()].find(owner => owner.scope && !owner.scope.closed && !owner.window.isDestroyed()
+ && notificationConsentKey(owner.scope) === key) === record;
+}
+function notificationWindowFocused(record) {
+ return (process.platform !== 'darwin' || app.isActive()) && record.window.isFocused();
+}
+function refreshNotificationOwners() {
+ for (const owner of windows.values()) {
+ const enabled = isNotificationOwner(owner);
+ const workspaceKey = owner.scope && !owner.scope.closed ? notificationConsentKey(owner.scope) : '';
+ const visibleThreads = workspaceKey ? [...windows.values()].filter(other => other.scope && !other.scope.closed && !other.window.isDestroyed()
+ && notificationConsentKey(other.scope) === workspaceKey && !other.state.configuring && !other.remote && other.window.isVisible() && !other.window.isMinimized() && notificationWindowFocused(other)).flatMap(other => {
+ try { const route = new URL(other.state.route, SHELL_URL); return route.pathname.startsWith('/chat/') ? [decodeURIComponent(route.pathname.split('/')[2] || '')] : []; } catch { return []; }
+ }) : [];
+ if (owner.state.notificationOwner === enabled && owner.state.notificationWorkspaceKey === workspaceKey
+ && JSON.stringify(owner.state.notificationVisibleThreadIds) === JSON.stringify(visibleThreads)) continue;
+ if (!enabled && owner.state.notificationOwner) disposeNotifications(owner, 'desktop.notification_owner_changed');
+ owner.state.notificationWorkspaceKey = workspaceKey;
+ owner.state.notificationOwner = enabled;
+ owner.state.notificationVisibleThreadIds = visibleThreads;
+ if (!owner.window.isDestroyed()) owner.window.webContents.send('desktop:state-changed', { ...owner.state });
+ }
+}
function emit(record, patch = {}) {
if (patch.route && patch.route !== record.state.route) nativeSpeech.cancel(record);
if (patch.message || patch.persistenceWarning) patch.noticeVersion = (record.state.noticeVersion || 0) + 1;
Object.assign(record.state, patch);
+ refreshNotificationOwners();
if (!record.window.isDestroyed()) record.window.webContents.send('desktop:state-changed', { ...record.state });
}
function focused() { return windows.get(BrowserWindow.getFocusedWindow()?.id) || windows.values().next().value; }
@@ -57,10 +133,12 @@ function command(name, record = focused()) {
record.window.webContents.send('desktop:command', { name });
}
function resetNavigation(record, route) {
+ route = safeRoute(route);
record.navigation = { entries: [route], index: 0 };
return { route, canGoBack: false, canGoForward: false };
}
function commitRoute(record, route, mode, historyIndex) {
+ route = safeRoute(route);
const history = record.navigation || { entries: [record.state.route], index: 0 };
if (mode === 'traverse') history.index = historyIndex;
else if (mode === 'replace') history.entries[history.index] = route;
@@ -90,6 +168,7 @@ async function executeCommand(name, record = focused()) {
record.remote.webContents.reload();
return;
}
+ if (name === 'reload') emit(record, {anchorTarget: undefined});
if (name === 'reload' && record.scope && (record.state.route.startsWith('/chat') || record.state.route.startsWith('/settings'))) {
const scope = record.scope, route = record.state.route, generation = record.generation;
const saved = await persistenceBarrier(record, 'reload');
@@ -127,9 +206,25 @@ function disposeRemote(record) {
if (!record.window.isDestroyed()) record.window.contentView.removeChildView(remote);
if (!remote.webContents.isDestroyed()) remote.webContents.close();
}
+function handoffNotificationEntries(record) {
+ for (const entry of [...(record.notifications?.values() || [])]) {
+ const key = notificationConsentKey(entry.scope);
+ const next = [...windows.values()].find(owner => owner !== record && owner.scope && !owner.scope.closed
+ && !owner.window.isDestroyed() && notificationConsentKey(owner.scope) === key && isNotificationOwner(owner));
+ if (!next || !notificationsAllowed(next)) continue;
+ next.notifications ||= new Map();
+ if (next.notifications.has(entry.threadId)) continue;
+ record.notifications.delete(entry.threadId);
+ entry.record = next; entry.scope = next.scope;
+ if (entry.last) entry.last = { ...entry.last, connectionVersion: next.scope.version, serviceId: next.scope.serviceId,
+ identityId: next.scope.identityId, seq: next.notificationSequence = (next.notificationSequence || 0) + 1 };
+ next.notifications.set(entry.threadId, entry);
+ }
+}
function disposeConnection(record) {
nativeSpeech.cancel(record);
closeScope(record.scope);
+ handoffNotificationEntries(record);
disposeNotifications(record, 'desktop.notification_scope_closed');
if (record.scope) scopes.delete(record.scope.host);
record.scope = undefined;
@@ -139,14 +234,15 @@ function disposeConnection(record) {
record.notificationPermission = undefined;
record.notificationDiagnostic = undefined;
record.microphoneGranted = false;
+ refreshNotificationOwners();
for (const [host, document] of documents) if (document.record === record) documents.delete(host);
}
function persist(record) {
try {
if (!record.state.origin) return;
- preferences = { ...preferences, origin: record.state.origin, bounds: record.window.getNormalBounds() };
+ preferences = { ...preferences, origin: record.state.origin, mode: record.state.connectionMode || preferences.mode || 'external', bounds: record.window.getNormalBounds() };
if (record.scope?.serviceId && record.scope.identityId) {
- const key = `${record.scope.origin}|${record.scope.serviceId}|${record.scope.identityId}`;
+ const key = `${record.scope.environmentId ? 'managed:' + record.scope.environmentId : record.scope.origin}|${record.scope.serviceId}|${record.scope.identityId}`;
preferences.routes = { ...preferences.routes, [key]: record.state.route };
}
writePreferences(preferencesFile, preferences.origin, preferences);
@@ -162,7 +258,9 @@ async function connect(record, value) {
const timeout = setTimeout(() => controller.abort(), 20000);
emit(record, { status: 'connecting', candidateOrigin: origin, message: '', error: undefined, configuring: true });
try {
- await probeService(origin, controller.signal);
+ const service = await probeService(origin, controller.signal);
+ const managed = managedService?.ready?.origin === origin ? managedService.ready : null;
+ if (managed && (service.health.desktop_runtime?.environment_id !== environment.id || service.health.desktop_runtime?.generation !== environment.generation || service.auth.service_id !== managed.service_id)) throw new DesktopError('desktop.managed_identity_mismatch', '本机工作台身份校验失败。');
if (record.window.isDestroyed() || attempt !== record.generation) throw new DesktopError('desktop.connection_cancelled', '连接已取消。');
// Commit the candidate only after it is usable. The previous scope remains
// live on a failed probe, including its authentication and private drafts.
@@ -173,12 +271,16 @@ async function connect(record, value) {
if (record.window.isDestroyed() || attempt !== record.generation || record.scope !== previous) throw new DesktopError('desktop.connection_cancelled', '连接已取消。');
const host = `service-${record.window.id}-${attempt}`;
disposeConnection(record);
- const scope = { origin, host, version: attempt, requests: new Set(), closed: false };
+ const scope = { origin, host, version: attempt, requests: new Set(), closed: false, auth: authSessions.forService(origin, service.auth.service_id) };
+ if (managed) {
+ scope.environmentId = environment.id;
+ scope.auth.accept({ ...managed, auth_required: true, session_protocol: 2, session_owner: 'desktop' }, false, scope);
+ }
record.scope = scope; scopes.set(host, { record, scope });
record.navigationGeneration = (record.navigationGeneration || 0) + 1;
record.routeRequested = false;
- emit(record, { origin, transportOrigin: `muteki-desktop://${host}`, connectionVersion: attempt,
- status: 'connected', configuring: false, ...resetNavigation(record, '/chat'), serviceId: '', identityId: '', message: '', candidateOrigin: '', draftRecovery: false });
+ emit(record, { origin, connectionMode: managed ? 'local' : 'external', transportOrigin: `muteki-desktop://${host}`, connectionVersion: attempt,
+ status: 'connected', configuring: false, serviceVersion: service.health.version, serviceFeatures: service.health.features, anchorTarget: undefined, remoteUiBuild: undefined, ...resetNavigation(record, '/chat'), serviceId: '', identityId: '', message: '', candidateOrigin: '', draftRecovery: false });
persist(record);
} catch (cause) {
const error = cause instanceof DesktopError ? cause : new DesktopError('desktop.connection_failed', text('连接失败,请重试。', 'Connection failed. Please retry.'), { retryable: true, cause });
@@ -189,6 +291,32 @@ async function connect(record, value) {
} finally { clearTimeout(timeout); record.connecting = false; record.connectionAbort = undefined; }
return { ...record.state };
}
+async function connectLocal(record) {
+ if (record.connecting) throw new DesktopError('desktop.connection_busy', '正在连接,请稍候。');
+ emit(record, { configuring: true, status: 'connecting', message: '' });
+ try {
+ await managedRuntime().start();
+ const runtime = await managedRuntime().refreshSession();
+ if (record.window.isDestroyed()) return;
+ return await connect(record, runtime.origin);
+ } catch (error) {
+ if (!record.window.isDestroyed()) emit(record, { status: 'error', configuring: true, message: error.message });
+ throw error;
+ }
+}
+async function renewManagedSessions() {
+ if (!managedService?.ready || environment.maintenance) return;
+ const records = [...windows.values()].filter(record => record.scope?.environmentId === environment.id && record.scope.auth.token && record.scope.auth.expiresAt < Date.now() / 1000 + 600);
+ if (!records.length) return;
+ const generation = environment.generation;
+ const runtime = await managedService.refreshSession();
+ for (const record of records) {
+ const scope = record.scope;
+ if (generation !== environment.generation || scope?.environmentId !== environment.id || scope.closed || !scope.auth.token) continue;
+ scope.auth.accept({...runtime, auth_required: true, session_protocol: 2, session_owner: 'desktop'}, false, scope);
+ if (record.remote && !record.remote.webContents.isDestroyed()) await record.remote.webContents.session.cookies.set({url: runtime.origin + '/api', name: runtime.cookie_name, value: runtime.token, httpOnly: true, sameSite: 'strict', path: '/api'});
+ }
+}
async function openExternal(record, value) {
const url = parseWebUrl(value);
if (!url) throw new DesktopError('desktop.external_url_invalid', '外链地址无效。');
@@ -199,32 +327,92 @@ async function openExternal(record, value) {
function safeRoute(value) {
return normalizeDesktopRoute(value);
}
+// Must match the `.service-surface` card geometry in renderer/styles.css so the
+// native page sits exactly on the raised card and its CSS shadow shows around it.
+const SURFACE_INSET = { left: 56, top: 46, right: 8, bottom: 8 }, SURFACE_RADIUS = 12;
+// The desktop activity rail replaces the web app's own rail on remote pages,
+// which also hides that rail's theme toggle; applyRemoteAppearance takes over.
+const REMOTE_CHROME_CSS = '.workspace-frame { --workspace-rail-width: 0px; grid-template-columns: minmax(0, 1fr) !important; } .workspace-activity-rail { display: none !important; }';
+async function applyRemoteAppearance(record, contents = record.remote?.webContents) {
+ const appearance = record.appearance;
+ if (!appearance || !contents || contents.isDestroyed()) return;
+ const payload = {...appearance, serviceId: record.scope?.serviceId || '', identityId: record.scope?.identityId || ''};
+ const script = `(() => {
+ const value = ${JSON.stringify(payload)}, key = 'muteki.desktop.appearanceOverride';
+ const serialized = JSON.stringify(value);
+ if (localStorage.getItem(key) !== serialized) {
+ localStorage.setItem(key, serialized);
+ window.dispatchEvent(new StorageEvent('storage', {key, newValue: serialized, storageArea: localStorage}));
+ }
+ const root = document.documentElement;
+ root.dataset.theme = value.resolvedTheme;
+ root.classList.toggle('dark', value.resolvedTheme === 'dark'); root.classList.toggle('light', value.resolvedTheme === 'light');
+ const result = {build: root.dataset.mutekiUiBuild || '', appearanceContract: root.dataset.mutekiAppearanceContract || ''};
+ if (result.appearanceContract !== '1' || root.dataset.desktopAppearanceApplied === serialized) return result;
+ return new Promise((resolve, reject) => {
+ const observer = new MutationObserver(() => {
+ if (root.dataset.desktopAppearanceApplied === serialized) { observer.disconnect(); clearTimeout(timeout); resolve(result); }
+ });
+ const timeout = setTimeout(() => { observer.disconnect(); reject(new Error('远程界面未确认外观已应用,请更新或重新加载 Web 界面。')); }, 5000);
+ observer.observe(root, {attributes: true, attributeFilter: ['data-desktop-appearance-applied']});
+ });
+ })()`;
+ const info = await contents.executeJavaScript(script);
+ if (record.remote?.webContents === contents && !contents.isDestroyed()) {
+ emit(record, {remoteUiBuild: info.build || 'unknown', remoteAppearanceContract: info.appearanceContract});
+ }
+}
+async function applyRemoteChrome(record, contents) {
+ if (contents.isDestroyed()) return;
+ await contents.insertCSS(REMOTE_CHROME_CSS);
+ await applyRemoteAppearance(record, contents);
+}
+function parseAppearance(input) {
+ const selection = input?.selection;
+ const valid = input && ['system', 'light', 'dark'].includes(input.preference) && ['light', 'dark'].includes(input.resolvedTheme) && selection && (
+ (selection.kind === 'preset' && ['azure', 'violet', 'teal', 'ember'].includes(selection.id))
+ || (selection.kind === 'custom' && Number.isFinite(selection.hue) && selection.hue >= 0 && selection.hue < 360));
+ if (!valid) throw new DesktopError('desktop.appearance_invalid', '外观设置无效。');
+ return {preference: input.preference, resolvedTheme: input.resolvedTheme, selection: selection.kind === 'custom' ? {kind: 'custom', hue: selection.hue} : {kind: 'preset', id: selection.id}};
+}
+
function remoteBounds(record) {
if (!record.remote || record.window.isDestroyed()) return;
- const [width, height] = record.window.getContentSize();
- record.remote.setBounds({ x: 56, y: 44, width: Math.max(0, width - 56), height: Math.max(0, height - 44) });
+ const [width, height] = record.window.getContentSize(), { left, top, right, bottom } = SURFACE_INSET;
+ record.remote.setBounds({ x: left, y: top, width: Math.max(0, width - left - right), height: Math.max(0, height - top - bottom) });
}
async function navigate(record, value, mode = 'push', historyIndex) {
- const route = safeRoute(value) === '/' ? '/chat' : safeRoute(value);
+ const target = desktopNavigationTarget(value);
+ const route = target.route === '/' ? '/chat' : target.route;
+ const anchorTarget = target.hash ? {hash: target.hash, id: (record.anchorSequence = (record.anchorSequence || 0) + 1)} : undefined;
if (!['push', 'replace', 'traverse'].includes(mode) || (mode === 'traverse' && (!Number.isInteger(historyIndex) || record.navigation?.entries[historyIndex] !== route))) throw new DesktopError('desktop.navigation_invalid', '导航历史请求无效。');
record.routeRequested = true;
- if (route === record.state.route && !record.state.configuring && !record.navigationPending) {
+ const remoteRoute = !/^\/(chat|settings)(\/|\?|$)/.test(route) && route !== '/';
+ if (route === record.state.route && !record.state.configuring && !record.navigationPending
+ && (!remoteRoute || (record.remote && !record.remote.webContents.isDestroyed()))) {
if (mode === 'traverse' && historyIndex !== record.navigation?.index) { commitRoute(record, route, mode, historyIndex); persist(record); }
+ if (!remoteRoute) emit(record, {anchorTarget});
+ else if (target.hash) await record.remote.webContents.loadURL(record.scope.origin + route + target.hash);
return;
}
const generation = record.navigationGeneration = (record.navigationGeneration || 0) + 1;
record.navigationPending = false;
disposePreview(record);
+ emit(record, {anchorTarget: undefined});
if (/^\/(chat|settings)(\/|\?|$)/.test(route) || route === '/') {
disposeRemote(record); commitRoute(record, route, mode, historyIndex);
+ emit(record, {anchorTarget});
persist(record); return;
}
if (!record.scope) throw new DesktopError('desktop.not_connected', '请先连接工作台。');
disposeRemote(record);
let remote;
try {
- const origin = record.scope.origin, partition = partitionFor(origin);
+ const origin = record.scope.origin, partition = partitionFor(origin, record.scope.environmentId);
const remoteSession = session.fromPartition(partition);
+ if (record.scope.environmentId && managedService?.ready && record.scope.auth.token) {
+ await remoteSession.cookies.set({ url: origin + '/api', name: managedService.ready.cookie_name, value: record.scope.auth.token, httpOnly: true, sameSite: 'strict', path: '/api' });
+ }
const permitsClipboard = (contents, permission, requestingOrigin, details) =>
permission === 'clipboard-sanitized-write' && details?.isMainFrame !== false &&
parseWebUrl(requestingOrigin)?.origin === origin && parseWebUrl(contents?.getURL())?.origin === origin &&
@@ -233,11 +421,18 @@ async function navigate(record, value, mode = 'push', historyIndex) {
remoteSession.setPermissionRequestHandler((contents, permission, callback, details) =>
callback(permitsClipboard(contents, permission, details?.requestingUrl, details)));
remote = new WebContentsView({ webPreferences: webPreferences(partition) });
+ remote.setBorderRadius(SURFACE_RADIUS);
+ remote.setVisible(false);
+ remote.webContents.setUserAgent(remote.webContents.getUserAgent().replace(/\sMutekiDesktop\/[^\s]+/g, '') + ` MutekiDesktop/${app.getVersion()}`);
+ remote.webContents.on('dom-ready', () => { if (record.remote === remote) void applyRemoteChrome(record, remote.webContents).catch(error => { if (record.remote === remote) emit(record, {message: `远程界面外观同步失败:${error.message}`}); }); });
record.remote = remote; record.window.contentView.addChildView(remote); remoteBounds(record);
record.navigationPending = true;
guardRemote(record, remote.webContents, record.scope.origin);
- await remote.webContents.loadURL(record.scope.origin + route);
+ await remote.webContents.loadURL(record.scope.origin + route + target.hash);
if (record.remote !== remote || record.scope.closed || generation !== record.navigationGeneration) throw new DesktopError('desktop.navigation_cancelled', '该导航已由较新的请求替代。');
+ await applyRemoteChrome(record, remote.webContents);
+ if (record.remote !== remote || generation !== record.navigationGeneration) return;
+ remote.setVisible(true);
const destination = new URL(remote.webContents.getURL());
const loadedRoute = safeRoute(destination.pathname + destination.search + destination.hash);
commitRoute(record, route, mode, historyIndex);
@@ -253,7 +448,7 @@ function guardRemote(record, contents, origin, preview = false) {
const guard = event => {
const url = parseWebUrl(event.url);
if (!url || (!preview && url.origin !== origin)) { event.preventDefault(); if (url) void openExternal(record, url.href).catch(error => emit(record, { message: error.message })); }
- else if (!preview && /^\/(chat|settings)(\/|$)/.test(url.pathname)) { event.preventDefault(); void navigate(record, url.pathname + url.search).catch(error => emit(record, { message: error.message })); }
+ else if (!preview && /^\/(chat|settings)(\/|$)/.test(url.pathname)) { event.preventDefault(); void navigate(record, url.pathname + url.search + url.hash).catch(error => emit(record, { message: error.message })); }
};
contents.on('will-navigate', guard); contents.on('will-redirect', event => { if (event.isMainFrame) guard(event); });
if (!preview) {
@@ -261,9 +456,14 @@ function guardRemote(record, contents, origin, preview = false) {
if (isMainFrame === false || record.remote?.webContents !== contents || record.navigationPending || record.scope?.closed) return;
const url = parseWebUrl(value);
if (!url || url.origin !== origin) return;
- const route = safeRoute(url.pathname + url.search + url.hash);
+ let route;
+ try { route = safeRoute(url.pathname + url.search + url.hash); }
+ catch (error) {
+ emit(record, { message: error.message, error: transportError(error) });
+ return;
+ }
if (/^\/(chat|settings)(\/|\?|$)/.test(route)) {
- void navigate(record, route).catch(error => emit(record, { message: error.message }));
+ void navigate(record, route + url.hash).catch(error => emit(record, { message: error.message }));
} else if (route !== record.state.route) {
commitRoute(record, route, 'push'); persist(record);
}
@@ -279,6 +479,29 @@ function guardRemote(record, contents, origin, preview = false) {
return { action: 'deny' };
});
}
+const PREVIEW_LOG_LIMIT = 1000;
+const previewLogs = new Map();
+const watchedPreviewSessions = new WeakSet();
+function createPreviewLogs() { return { console: [], network: [], dropped: { console: 0, network: 0 }, started: new Map() }; }
+function recordPreviewLog(logs, kind, entry) {
+ const rows = logs[kind];
+ rows.push({ at: new Date().toISOString(), ...entry });
+ if (rows.length > PREVIEW_LOG_LIMIT) { rows.splice(0, rows.length - PREVIEW_LOG_LIMIT); logs.dropped[kind] = (logs.dropped[kind] || 0) + 1; }
+}
+function watchPreviewNetwork(previewSession) {
+ if (watchedPreviewSessions.has(previewSession)) return;
+ watchedPreviewSessions.add(previewSession);
+ const finish = (details, extra) => {
+ const logs = previewLogs.get(details.webContentsId);
+ if (!logs) return;
+ const started = logs.started.get(details.id); logs.started.delete(details.id);
+ recordPreviewLog(logs, 'network', { url: details.url, method: details.method, type: details.resourceType,
+ duration_ms: started ? Math.round(details.timestamp - started) : undefined, ...extra });
+ };
+ previewSession.webRequest.onSendHeaders(details => { previewLogs.get(details.webContentsId)?.started.set(details.id, details.timestamp); });
+ previewSession.webRequest.onCompleted(details => finish(details, { status: details.statusCode, from_cache: details.fromCache, failed: details.statusCode >= 400 }));
+ previewSession.webRequest.onErrorOccurred(details => finish(details, { error: details.error, failed: true }));
+}
async function openPreview(record, input) {
if (!record.scope || record.scope.closed || !input || typeof input !== 'object') throw new DesktopError('desktop.preview_invalid', '预览请求无效或工作台连接已改变。');
const url = parseWebUrl(input.url);
@@ -288,20 +511,47 @@ async function openPreview(record, input) {
if (typeof input.surfaceId !== 'string' || !input.surfaceId || typeof input.threadId !== 'string' || !activeThread(record, input.threadId)) throw new DesktopError('desktop.preview_scope_invalid', '预览必须属于当前聊天视图。');
const bounds = { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) };
const previous = record.preview;
- if (previous && previous.scope === record.scope && previous.surfaceId === input.surfaceId && previous.threadId === input.threadId && previous.url === url.href) {
+ const persistent = input.persistent === true;
+ if (previous?.webContents && !previous.webContents.isDestroyed() && previous.scope === record.scope && previous.surfaceId === input.surfaceId && previous.threadId === input.threadId && previous.persistent === persistent) {
previous.setBounds(bounds); previous.setVisible(true); previous.hidden = false;
- if (input.reload) previous.webContents.reload();
+ // Layout updates can still carry the old address while an in-page
+ // navigation is being reported to React. Keep the live view and history;
+ // only a changed renderer request for a different page starts navigation.
+ const navigate = previous.requestedUrl !== url.href && previous.url !== url.href
+ && previous.webContents.getURL() !== url.href;
+ previous.requestedUrl = url.href;
+ if (navigate) {
+ void previous.webContents.loadURL(url.href).catch(error => {
+ if (error.code !== 'ERR_ABORTED' && record.preview === previous && !record.window.isDestroyed()) {
+ record.window.webContents.send('desktop:preview-state', { id: previous.id, threadId: previous.threadId,
+ url: previous.webContents.getURL() || url.href, title: previous.webContents.getTitle(),
+ status: 'error', message: `网页加载失败:${error.code || error.message}` });
+ }
+ });
+ } else if (input.reload) previous.webContents.reload();
return { id: previous.id };
}
disposePreview(record);
- const partition = `muteki-preview-${record.window.id}-${randomUUID()}`;
+ // A persistent profile is shared by every preview of one service origin, so
+ // logins survive restarts; the default stays a throwaway in-memory session.
+ const partition = persistent
+ ? `persist:muteki-preview-${createHash('sha256').update(record.scope.environmentId || record.scope.origin).digest('hex').slice(0, 16)}`
+ : `muteki-preview-${record.window.id}-${randomUUID()}`;
const previewSession = session.fromPartition(partition);
previewSession.setPermissionRequestHandler((_c, _p, callback) => callback(false));
previewSession.setPermissionCheckHandler(() => false);
+ watchPreviewNetwork(previewSession);
const preview = new WebContentsView({ webPreferences: webPreferences(partition) });
// Explicit session beats defaultSession; previews never inherit the trusted
// renderer's storage, preload, custom transport, or native bridge.
- Object.assign(preview, { id: randomUUID(), surfaceId: input.surfaceId, threadId: input.threadId, scope: record.scope, url: url.href, hidden: false });
+ Object.assign(preview, { id: randomUUID(), surfaceId: input.surfaceId, threadId: input.threadId, scope: record.scope, url: url.href, requestedUrl: url.href, hidden: false, persistent, logs: createPreviewLogs() });
+ // view.webContents is already gone when 'destroyed' fires; keep the id.
+ const previewContentsId = preview.webContents.id;
+ previewLogs.set(previewContentsId, preview.logs);
+ preview.webContents.once('destroyed', () => previewLogs.delete(previewContentsId));
+ preview.webContents.on('console-message', details => recordPreviewLog(preview.logs, 'console', {
+ level: details.level, message: details.message, source: details.sourceId || undefined, line: details.lineNumber || undefined, url: preview.webContents.getURL(),
+ }));
record.preview = preview; record.window.contentView.addChildView(preview);
preview.setBounds(bounds);
const update = (status, message = '') => {
@@ -318,6 +568,17 @@ async function openPreview(record, input) {
void preview.webContents.loadURL(url.href).catch(() => update('error', '预览加载失败,请重试或在系统浏览器打开。'));
return { id: preview.id };
}
+async function emulateColorScheme(contents, scheme) {
+ if (!['light', 'dark', 'system'].includes(scheme)) throw new DesktopError('desktop.preview_scheme_invalid', '外观只能是 light、dark 或 system。');
+ const debug = contents.debugger;
+ try {
+ if (!debug.isAttached()) debug.attach('1.3');
+ await debug.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: scheme === 'system' ? '' : scheme }] });
+ } catch (error) {
+ throw new DesktopError('desktop.preview_scheme_failed', `无法切换页面外观:${error?.message || error}`);
+ }
+ return { scheme };
+}
function persistenceBarrier(record, purpose) {
return new Promise(resolve => {
const token = randomUUID();
@@ -337,21 +598,31 @@ function restoreBounds() {
async function createWindow(origin = '', route = '/chat') {
let record;
try {
- const window = new BrowserWindow({ title: 'Muteki', ...restoreBounds(), minWidth: 360, minHeight: 320, show: false,
+ const window = new BrowserWindow({ title: environment.name, ...restoreBounds(), minWidth: 360, minHeight: 320, show: false,
titleBarStyle: 'hidden', ...(process.platform === 'darwin' ? { trafficLightPosition: { x: 16, y: 16 } } : {}), backgroundColor: '#f3f3f3',
- webPreferences: { ...webPreferences(undefined), preload: path.join(__dirname, 'preload.cjs') } });
- record = { window, generation: 0, connecting: false, terminals: new Map(), selectedPaths: new Map(), workspaceGrants: new Map(), barriers: new Map(), state: {
+ webPreferences: { ...webPreferences(undefined), backgroundThrottling: false, preload: path.join(__dirname, 'preload.cjs') } });
+ record = { window, generation: 0, rendererErrors: [], connecting: false, terminals: new Map(), selectedPaths: new Map(), workspaceGrants: new Map(), barriers: new Map(), state: {
origin: origin || preferences.origin || '', transportOrigin: '', status: 'idle', configuring: true,
- message: preferences.error?.message || '', error: preferences.error, route: safeRoute(route), platform: process.platform, capabilities: CAPABILITIES,
+ connectionMode: origin ? (managedService?.ready?.origin === origin ? 'local' : 'external') : preferences.mode,
+ message: preferences.error?.message || '', error: preferences.error, route: safeRoute(route), platform: process.platform, desktopVersion: app.getVersion(), capabilities: CAPABILITIES,
+ environment: { id: environment.id, channel: environment.channel, name: environment.name }, managedService: managedService?.status,
} };
Object.assign(record.state, resetNavigation(record, record.state.route));
- if (route !== '/chat') record.pendingRoute = safeRoute(route);
+ if (route !== '/chat') record.pendingRoute = route;
windows.set(window.id, record);
+ window.webContents.on('console-message', details => {
+ if (details.level !== 'error') return;
+ record.rendererErrors.push({message: details.message, source: details.sourceId, line: details.lineNumber});
+ if (environment.channel !== 'stable' || updateJournal) {
+ try { fs.appendFileSync(path.join(environment.paths.logs, 'renderer-errors.jsonl'), JSON.stringify(record.rendererErrors.at(-1)) + '\n', {mode: 0o600}); }
+ catch (error) { console.error('Cannot persist renderer diagnostics:', error.message); }
+ }
+ });
installKeyboard(record, window.webContents);
window.webContents.on('did-start-navigation', details => { if (details.isMainFrame) nativeSpeech.cancel(record); });
window.webContents.on('will-navigate', event => {
event.preventDefault();
- if (local(event.url)) { const target = new URL(event.url); void navigate(record, target.pathname + target.search).catch(error => emit(record, { message: error.message })); }
+ if (local(event.url)) { const target = new URL(event.url); void navigate(record, target.pathname + target.search + target.hash).catch(error => emit(record, { message: error.message })); }
else { const url = parseWebUrl(event.url); if (url) void openExternal(record, url.href).catch(error => emit(record, { message: error.message })); }
});
window.webContents.on('will-frame-navigate', event => {
@@ -361,21 +632,31 @@ async function createWindow(origin = '', route = '/chat') {
if (url.protocol !== 'muteki-desktop:' || !document || document.record !== record || document.scope !== record.scope || url.pathname !== '/document.html') event.preventDefault();
} catch { event.preventDefault(); }
});
- window.webContents.setWindowOpenHandler(({ url }) => { if (local(url)) { const target = new URL(url); void createWindow(record.state.origin, target.pathname + target.search).catch(error => emit(record, { message: error.message })); }
+ window.webContents.setWindowOpenHandler(({ url }) => { if (local(url)) { const target = new URL(url); void createWindow(record.state.origin, target.pathname + target.search + target.hash).catch(error => emit(record, { message: error.message })); }
else if (parseWebUrl(url)) void openExternal(record, url).catch(error => emit(record, { message: error.message })); return { action: 'deny' }; });
window.webContents.on('render-process-gone', () => { disposeConnection(record); emit(record, { status: 'error', configuring: true, message: '聊天界面进程已停止,请重新连接。' }); });
window.on('resize', () => { remoteBounds(record); if (record.preview) { record.preview.setVisible(false); record.preview.hidden = true; } });
+ window.on('show', () => emit(record));
+ window.on('hide', () => emit(record));
+ window.on('blur', () => emit(record));
+ window.on('focus', () => {
+ emit(record);
+ if (!window.webContents.isDestroyed()) window.webContents.send('desktop:focus');
+ void renewManagedSessions().catch(error => emit(record, {message: error.message}));
+ });
window.on('close', event => {
if (record.allowClose) return;
event.preventDefault();
if (record.pendingClose) return;
const token = randomUUID(); record.pendingClose = token;
window.webContents.send('desktop:before-close', { token });
- record.closeTimer = setTimeout(() => { record.pendingClose = undefined; quitting = false; emit(record, { message: text('草稿保存尚未确认,窗口仍保持打开。请重试关闭。', 'Draft saving was not confirmed. The window remains open; retry closing.') }); }, 5000);
+ record.closeTimer = setTimeout(() => { record.pendingClose = undefined; quitting = false; quitApproved = false; emit(record, { message: text('草稿保存尚未确认,窗口仍保持打开。请重试关闭。', 'Draft saving was not confirmed. The window remains open; retry closing.') }); }, 5000);
});
window.on('closed', () => { clearTimeout(record.closeTimer); for (const barrier of record.barriers.values()) { clearTimeout(barrier.timer); barrier.resolve({ persisted: false, error: '窗口已关闭。' }); } record.barriers.clear(); record.generation++; record.connectionAbort?.abort(); disposeConnection(record); windows.delete(window.id); if (quitting && windows.size === 0) app.quit(); });
await window.loadURL(SHELL_URL); window.show();
- if (origin || preferences.origin) await connect(record, origin || preferences.origin);
+ if (origin) await connect(record, origin);
+ else if (preferences.mode === 'local') await connectLocal(record);
+ else if (preferences.origin) await connect(record, preferences.origin);
return record;
} catch (error) {
if (record && !record.window.isDestroyed() && !record.window.isVisible()) { record.allowClose = true; record.window.destroy(); }
@@ -390,7 +671,7 @@ function identity(record, scope, data) {
if (changed) {
// Stop old transport immediately, preserving the old scope's draft cache
// identity until the renderer has confirmed its local save barrier.
- closeScope(scope); disposeNotifications(record, 'desktop.notification_scope_closed'); disposePreview(record); disposeRemote(record); record.workspaceGrants.clear();
+ closeScope(scope); handoffNotificationEntries(record); disposeNotifications(record, 'desktop.notification_scope_closed'); disposePreview(record); disposeRemote(record); record.workspaceGrants.clear();
record.connectionAbort?.abort();
for (const socket of record.terminals.values()) socket.close(); record.terminals.clear();
const attempt = ++record.generation;
@@ -402,7 +683,7 @@ function identity(record, scope, data) {
}
disposeConnection(record);
const host = `service-${record.window.id}-${attempt}`;
- const next = { origin: scope.origin, host, version: attempt, requests: new Set(), closed: false, serviceId: data.service_id, identityId: data.identity_id };
+ const next = { origin: scope.origin, host, version: attempt, requests: new Set(), closed: false, serviceId: data.service_id, identityId: data.identity_id, auth: authSessions.forService(scope.origin, data.service_id) };
record.scope = next; scopes.set(host, { record, scope: next });
record.navigationGeneration = (record.navigationGeneration || 0) + 1;
emit(record, { transportOrigin: `muteki-desktop://${host}`, connectionVersion: attempt, serviceId: next.serviceId, identityId: next.identityId,
@@ -412,10 +693,14 @@ function identity(record, scope, data) {
}
const firstIdentity = !scope.serviceId;
scope.serviceId = data.service_id; scope.identityId = data.identity_id;
- const key = `${scope.origin}|${scope.serviceId}|${scope.identityId}`;
+ const key = `${scope.environmentId ? 'managed:' + scope.environmentId : scope.origin}|${scope.serviceId}|${scope.identityId}`;
const route = record.pendingRoute || (!changed && preferences.routes?.[key]) || '/chat'; record.pendingRoute = undefined;
- emit(record, { serviceId: scope.serviceId, identityId: scope.identityId, ...(firstIdentity && !record.routeRequested ? resetNavigation(record, safeRoute(route)) : {}) });
+ const restoreRoute = firstIdentity && !record.routeRequested;
+ emit(record, { serviceId: scope.serviceId, identityId: scope.identityId, ...(restoreRoute ? resetNavigation(record, safeRoute(route)) : {}) });
persist(record);
+ if (restoreRoute && (new URL(route, SHELL_URL).hash || (!/^\/(chat|settings)(\/|\?|$)/.test(route) && route !== '/'))) {
+ void navigate(record, route, 'replace').catch(error => emit(record, { message: error.message, error: transportError(error) }));
+ }
}
function notificationScope(record, input) {
const scope = record.scope;
@@ -425,20 +710,49 @@ function notificationScope(record, input) {
}
return scope;
}
-function notificationStatus(record, input) {
+function notificationChoice(record, scope) {
+ const key = notificationConsentKey(scope);
+ if (!key) return undefined;
+ const allowed = preferences.notificationConsents?.[key];
+ if (typeof allowed !== 'boolean') return undefined;
+ record.notificationPermission = { scope, key, allowed };
+ return allowed;
+}
+async function systemNotificationPermission() {
+ if (process.platform !== 'darwin') return { systemPermission: 'unknown' };
+ try {
+ const native = require(app.isPackaged ? path.join(process.resourcesPath, 'native', 'macos-speech.node') : path.join(__dirname, '..', 'native', 'build', 'macos-speech.node'));
+ const settings = await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new DesktopError('desktop.notification_status_timeout', 'macOS notification settings did not respond within 5s.')), 5000);
+ try { native.notificationSettings(raw => { clearTimeout(timer); try { const result = JSON.parse(raw); if (result.error) { const error = new DesktopError(result.error.code, result.error.message, { cause: Object.assign(new Error(result.error.message), result.error) }); error.nativeDetails = result.error; reject(error); } else resolve(result); } catch (error) { reject(error); } }); }
+ catch (error) { clearTimeout(timer); reject(error); }
+ });
+ const permissions = { notDetermined: 'default', denied: 'denied', authorized: 'granted', provisional: 'provisional', ephemeral: 'ephemeral', unknown: 'unknown' };
+ if (!(settings.authorizationStatus in permissions)) throw new DesktopError('desktop.notification_status_invalid', `Invalid macOS authorization status: ${JSON.stringify(settings)}`);
+ return { systemPermission: permissions[settings.authorizationStatus], systemNotificationSettings: settings };
+ } catch (error) {
+ return { systemPermission: 'unknown', systemPermissionError: { ...transportError(error), detail: [error.stack || String(error), error.nativeDetails ? JSON.stringify(error.nativeDetails) : ''].filter(Boolean).join('\n') } };
+ }
+}
+async function notificationStatus(record, input) {
const scope = notificationScope(record, input);
- const supported = NativeNotification.isSupported();
- const choice = record.notificationPermission?.scope === scope ? record.notificationPermission.allowed : undefined;
+ // Electron macOS isSupported() initializes its presenter and requests OS
+ // authorization. A read-only status query must not call it. macOS supports
+ // UserNotifications; actual authorization is read below from the OS.
+ const supported = process.platform === 'darwin' ? true : NativeNotification.isSupported();
+ const system = await systemNotificationPermission();
+ if (notificationScope(record, input) !== scope) throw new DesktopError('desktop.notification_scope_changed', 'Notification workspace changed during system settings query.');
+ const choice = notificationChoice(record, scope);
return { connectionVersion: scope.version, serviceId: scope.serviceId, identityId: scope.identityId,
permission: !supported ? 'unsupported' : choice === true ? 'granted' : choice === false ? 'denied' : 'default',
workspacePermission: choice === true ? 'granted' : choice === false ? 'denied' : 'default',
- host: 'desktop-client', systemPermission: 'unknown',
+ host: 'desktop-client', ...system,
...(record.notificationDiagnostic?.scope === scope ? { delivery: record.notificationDiagnostic.value } : {}),
code: !supported ? 'desktop.notifications_unsupported' : choice === true ? 'desktop.notifications_workspace_allowed' : choice === false ? 'desktop.notifications_workspace_denied' : 'desktop.notifications_not_requested' };
}
async function requestNotifications(record, input) {
- const scope = notificationScope(record, input), status = notificationStatus(record, input);
- if (status.permission !== 'default') return status;
+ const scope = notificationScope(record, input), status = await notificationStatus(record, input);
+ if (status.permission === 'unsupported') return status;
if (record.notificationPrompt) {
if (record.notificationPrompt.scope === scope) return record.notificationPrompt.promise;
throw new DesktopError('desktop.notification_request_pending', text('上一个工作台的通知授权窗口尚未关闭,请先完成该选择。', 'Finish the previous workspace notification dialog first.'), { retryable: true });
@@ -450,10 +764,26 @@ async function requestNotifications(record, input) {
const { response } = await dialog.showMessageBox(record.window, { type: 'question',
message: text('允许此工作台发送桌面通知?', 'Allow desktop notifications from this workspace?'),
detail: `${scope.origin}\n${text('系统通知权限由操作系统单独管理。', 'The operating system manages its notification permission separately.')}`,
- buttons: [text('暂不允许', 'Not now'), text('允许', 'Allow')], defaultId: 0, cancelId: 0 });
+ buttons: [text('取消', 'Cancel'), text('不允许', 'Do not allow'), text('允许', 'Allow')], defaultId: 0, cancelId: 0 });
if (notificationScope(record, input) !== scope) throw new DesktopError('desktop.notification_scope_changed', 'Notification workspace changed.');
- if (response !== 0 && response !== 1) throw new DesktopError('desktop.notification_reply_invalid', 'Notification permission dialog returned an invalid choice.');
- record.notificationPermission = { scope, allowed: response === 1 };
+ if (response === 0) return notificationStatus(record, input);
+ if (response !== 1 && response !== 2) throw new DesktopError('desktop.notification_reply_invalid', 'Notification permission dialog returned an invalid choice.');
+ const key = notificationConsentKey(scope), allowed = response === 2;
+ const nextPreferences = { ...preferences, notificationConsents: { ...preferences.notificationConsents, [key]: allowed } };
+ try {
+ writePreferences(preferencesFile, nextPreferences.origin || scope.origin, nextPreferences);
+ } catch (cause) {
+ throw new DesktopError('desktop.notification_preferences_failed',
+ `${text('通知选择未能保存,授权保持原状态。', 'The notification choice could not be saved; permission is unchanged.')} ${cause.message || String(cause)}`,
+ { retryable: true, cause });
+ }
+ preferences = nextPreferences;
+ record.notificationPermission = { scope, key, allowed };
+ if (!allowed) for (const owner of windows.values()) {
+ if (owner.scope && !owner.scope.closed && notificationConsentKey(owner.scope) === key) {
+ disposeNotifications(owner, 'desktop.notifications_workspace_denied');
+ }
+ }
return notificationStatus(record, input);
} catch (error) {
if (error instanceof DesktopError) throw error;
@@ -464,14 +794,14 @@ async function requestNotifications(record, input) {
}
function notificationsAllowed(record) {
return Boolean(record.scope && !record.scope.closed && record.scope.serviceId && record.scope.identityId
- && record.notificationPermission?.scope === record.scope && record.notificationPermission.allowed === true);
+ && notificationChoice(record, record.scope) === true);
}
function notificationEvent(entry, status, code, message) {
const { record, scope } = entry;
entry.history.push({ status, at: new Date().toISOString(), ...(code ? { code } : {}), ...(message ? { message } : {}) });
const value = { connectionVersion: scope.version, serviceId: scope.serviceId, identityId: scope.identityId,
id: entry.id, threadId: entry.threadId, eventId: entry.eventId, dedupeKey: entry.dedupeKey,
- seq: record.notificationSequence = (record.notificationSequence || 0) + 1, status, shown: entry.shown,
+ seq: record.notificationSequence = (record.notificationSequence || 0) + 1, status, shown: entry.shown, ...(entry.system || {}), soundRequested: entry.soundRequested === true, shownReceiptMeaning: process.platform === 'darwin' ? 'system-accepted-scheduling' : 'native-show-event',
...(code ? { code } : {}), ...(message ? { message } : {}), history: entry.history.map(item => ({ ...item })) };
entry.last = value;
if (record.scope === scope) record.notificationDiagnostic = { scope, value };
@@ -497,34 +827,78 @@ function disposeNotifications(record, code) {
releaseNotification(entry, true);
}
}
-function sendNotification(record, input) {
+function playNotificationSound(record, input) {
const scope = notificationScope(record, input);
+ if (!isNotificationOwner(record)) throw new DesktopError('desktop.notification_owner_changed', 'This window no longer owns workspace notifications.');
+ if (!notificationsAllowed(record)) throw new DesktopError('desktop.notifications_workspace_denied', 'This workspace has not allowed native notification sounds.');
+ if (!input || ['threadId', 'eventId', 'dedupeKey'].some(key => typeof input[key] !== 'string' || !input[key])) throw new DesktopError('desktop.notification_sound_invalid', 'The sound request is missing its thread or event identity.');
+ const workspace = notificationConsentKey(scope), key = JSON.stringify([workspace, input.dedupeKey]);
+ const visible = [...windows.values()].some(owner => owner.scope && !owner.scope.closed && !owner.window.isDestroyed()
+ && notificationConsentKey(owner.scope) === workspace && !owner.state.configuring && !owner.remote && owner.window.isVisible() && !owner.window.isMinimized() && notificationWindowFocused(owner) && activeThread(owner, input.threadId));
+ const status = visible ? 'suppressed' : requestedNotificationSounds.has(key) ? 'already-requested' : 'sound-requested';
+ if (status === 'sound-requested') {
+ try { shell.beep(); }
+ catch (cause) { throw new DesktopError('desktop.notification_sound_failed', cause.stack || cause.message || String(cause), { cause }); }
+ requestedNotificationSounds.add(key);
+ }
+ const receipt = { connectionVersion: scope.version, serviceId: scope.serviceId, identityId: scope.identityId,
+ threadId: input.threadId, eventId: input.eventId, dedupeKey: input.dedupeKey, status, host: 'desktop-client' };
+ console.info('muteki.notification.sound', JSON.stringify(receipt));
+ return receipt;
+}
+async function sendNotification(record, input) {
+ const scope = notificationScope(record, input);
+ if (!isNotificationOwner(record)) throw new DesktopError('desktop.notification_owner_changed', 'This window no longer owns workspace notifications.');
if (!notificationsAllowed(record)) throw new DesktopError('desktop.notifications_workspace_denied', text('此工作台尚未获得通知授权。', 'This workspace has not been allowed to send notifications.'));
if (!input || ['threadId', 'eventId', 'dedupeKey', 'title', 'body'].some(key => typeof input[key] !== 'string') || !input.threadId || !input.eventId || !input.dedupeKey) {
throw new DesktopError('desktop.notification_input_invalid', 'The notification is missing its thread or event identity.');
}
+ if (input.wantSound !== undefined && typeof input.wantSound !== 'boolean') throw new DesktopError('desktop.notification_sound_invalid', 'wantSound must be a boolean.');
+ const system = await systemNotificationPermission();
+ // OS querying is asynchronous: revalidate every authority before acting.
+ if (notificationScope(record, input) !== scope) throw new DesktopError('desktop.notification_scope_changed', 'Notification workspace changed during system settings query.');
+ if (!isNotificationOwner(record)) throw new DesktopError('desktop.notification_owner_changed', 'This window no longer owns workspace notifications.');
+ if (!notificationsAllowed(record)) throw new DesktopError('desktop.notifications_workspace_denied', 'This workspace has not allowed notifications.');
+ const workspace = notificationConsentKey(scope);
+ if ([...windows.values()].some(owner => owner.scope && !owner.scope.closed && notificationConsentKey(owner.scope) === workspace
+ && !owner.state.configuring && !owner.remote && owner.window.isVisible() && !owner.window.isMinimized() && notificationWindowFocused(owner) && activeThread(owner, input.threadId))) {
+ const suppressed = { id: randomUUID(), record, scope, threadId: input.threadId, eventId: input.eventId, dedupeKey: input.dedupeKey, history: [], shown: false, active: false };
+ notificationEvent(suppressed, 'closed', 'desktop.notification_thread_visible', 'This thread is already visible in a workspace window.');
+ return suppressed.last;
+ }
+ if (system.systemPermission === 'denied') {
+ const suppressed = { id: randomUUID(), record, scope, threadId: input.threadId, eventId: input.eventId,
+ dedupeKey: input.dedupeKey, history: [], shown: false, active: false, system };
+ notificationEvent(suppressed, 'closed', 'desktop.notifications_system_denied', 'The operating system has denied notification authorization; no native notification was constructed.');
+ return suppressed.last;
+ }
const route = safeRoute(`/chat/${encodeURIComponent(input.threadId)}`);
if (!NativeNotification.isSupported()) throw new DesktopError('desktop.notifications_unsupported', 'Native notifications are unsupported on this system.');
+ const deliveredKey = JSON.stringify([workspace, input.dedupeKey]);
+ const delivered = deliveredNotificationEvents.get(deliveredKey);
+ if (delivered) return { ...delivered, connectionVersion: scope.version, serviceId: scope.serviceId, identityId: scope.identityId,
+ seq: record.notificationSequence = (record.notificationSequence || 0) + 1, requestedEventId: input.eventId, code: 'desktop.notification_already_delivered' };
record.notifications ||= new Map();
const previous = record.notifications.get(input.threadId);
- if (previous?.scope === scope && previous.eventId === input.eventId && previous.dedupeKey === input.dedupeKey) return previous.last;
+ if (previous?.scope === scope && previous.dedupeKey === input.dedupeKey) return { ...previous.last, requestedEventId: input.eventId };
if (previous) {
notificationEvent(previous, 'closed', 'desktop.notification_replaced', 'Replaced by a new notification from this thread.');
releaseNotification(previous, true);
}
const entry = { id: randomUUID(), record, scope, threadId: input.threadId, eventId: input.eventId,
- dedupeKey: input.dedupeKey, history: [], shown: false, active: true };
+ dedupeKey: input.dedupeKey, system, soundRequested: input.wantSound === true, history: [], shown: false, active: true };
record.notifications.set(entry.threadId, entry);
notificationEvent(entry, 'submitted');
- const sameScope = () => record.scope === scope && !scope.closed && !record.window.isDestroyed()
- && input.serviceId === scope.serviceId && input.identityId === scope.identityId;
+ const sameScope = () => entry.record.scope === entry.scope && !entry.scope.closed && !entry.record.window.isDestroyed()
+ && input.serviceId === entry.scope.serviceId && input.identityId === entry.scope.identityId;
const current = () => entry.active && sameScope();
try {
- entry.note = new NativeNotification({ title: input.title, body: input.body });
+ entry.note = new NativeNotification({ title: input.title, body: input.body, silent: input.wantSound !== true });
entry.note.on('show', () => {
if (!current()) return;
clearTimeout(entry.timer); entry.shown = true;
notificationEvent(entry, 'shown');
+ deliveredNotificationEvents.set(deliveredKey, entry.last);
});
entry.note.on('failed', (_event, error) => {
if (!entry.active) return;
@@ -540,10 +914,11 @@ function sendNotification(record, input) {
if (!current()) return;
// A real click proves delivery even if this platform omitted its show event.
clearTimeout(entry.timer); entry.shown = true;
- if (record.window.isMinimized()) record.window.restore();
- record.window.show(); record.window.focus();
+ if (entry.record.window.isMinimized()) entry.record.window.restore();
+ entry.record.window.show(); entry.record.window.focus();
notificationEvent(entry, 'clicked');
- void navigate(record, route).then(() => {
+ deliveredNotificationEvents.set(deliveredKey, entry.last);
+ void navigate(entry.record, route).then(() => {
if (!current()) return;
releaseNotification(entry, true);
}).catch(error => {
@@ -564,6 +939,48 @@ function sendNotification(record, input) {
}
return entry.last;
}
+// GUI-launched apps on macOS do not inherit the login shell PATH, so the
+// usual CLI install locations are probed explicitly.
+const EDITOR_CLIS = [
+ ['code', 'VS Code', ['/usr/local/bin/code', '/opt/homebrew/bin/code', '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code']],
+ ['cursor', 'Cursor', ['/usr/local/bin/cursor', '/opt/homebrew/bin/cursor', '/Applications/Cursor.app/Contents/Resources/app/bin/cursor']],
+ ['windsurf', 'Windsurf', ['/usr/local/bin/windsurf', '/opt/homebrew/bin/windsurf', '/Applications/Windsurf.app/Contents/Resources/app/bin/windsurf']],
+ ['zed', 'Zed', ['/usr/local/bin/zed', '/opt/homebrew/bin/zed', '/Applications/Zed.app/Contents/MacOS/cli']],
+];
+const EDITOR_CHOICES = new Set(['auto', 'system', ...EDITOR_CLIS.map(([name]) => name)]);
+function findEditorCli(preferred = 'auto') {
+ // Windows editor shims are .cmd scripts that cannot be spawned without a shell.
+ if (process.platform === 'win32') return null;
+ const dirs = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
+ for (const [name, label, extra] of EDITOR_CLIS) {
+ if (preferred !== 'auto' && preferred !== name) continue;
+ for (const candidate of [...dirs.map(dir => path.join(dir, name)), ...extra]) {
+ try { fs.accessSync(candidate, fs.constants.X_OK); return { name, label, file: candidate }; } catch { /* next candidate */ }
+ }
+ }
+ return null;
+}
+async function openInEditor(target, line, preferred = 'auto') {
+ const cli = preferred === 'system' ? null : findEditorCli(preferred);
+ if (!cli && preferred !== 'auto' && preferred !== 'system') {
+ const label = EDITOR_CLIS.find(([name]) => name === preferred)?.[1] || preferred;
+ throw new DesktopError('desktop.editor_not_found', text(`未找到 ${label} 命令行,请安装后重试,或在设置中改用其他编辑器。`, `${label} command line was not found. Install it or choose another editor in Settings.`));
+ }
+ if (cli) {
+ const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE;
+ // Zed takes path:line directly; the VS Code family needs --goto.
+ const args = !line ? [target] : cli.name === 'zed' ? [`${target}:${line}`] : ['--goto', `${target}:${line}`];
+ await new Promise((resolve, reject) => {
+ const child = spawn(cli.file, args, { detached: true, stdio: 'ignore', env });
+ child.once('error', error => reject(new DesktopError('desktop.editor_launch_failed', `${cli.label} 启动失败:${error.message}`)));
+ child.once('spawn', () => { child.unref(); resolve(); });
+ });
+ return { opener: cli.name, label: cli.label, path: target };
+ }
+ const error = await shell.openPath(target);
+ if (error) throw new DesktopError('desktop.workspace_file_open_failed', error);
+ return { opener: 'system', label: text('系统默认应用', 'system default application'), path: target };
+}
function installIPC() {
const handle = (name, fn) => ipcMain.handle(name, async (event, ...args) => {
const record = windows.get(BrowserWindow.fromWebContents(event.sender)?.id);
@@ -572,10 +989,13 @@ function installIPC() {
catch (error) { return { ok: false, error: transportError(error) }; }
});
handle('desktop:state', record => ({ ...record.state })); handle('desktop:connect', connect);
+ handle('desktop:connect-local', connectLocal);
handle('desktop:configure', record => { disposePreview(record); record.remote?.setVisible(false); emit(record, { configuring: true }); });
handle('desktop:resume', record => { if (record.scope && (!record.scope.closed || record.state.draftRecovery)) { record.remote?.setVisible(true); emit(record, { configuring: false }); } });
handle('desktop:navigate', navigate);
+ handle('desktop:anchor-consumed', (record, id) => { if (record.state.anchorTarget?.id === id) emit(record, {anchorTarget: undefined}); });
handle('desktop:action', (record, name) => { if (!['back', 'forward', 'reload', 'sidebar', 'search', 'theme', 'new-chat', 'settings', 'shortcuts'].includes(name)) throw new DesktopError('desktop.command_invalid', '此命令不可用。'); return executeCommand(name, record); });
+ handle('desktop:appearance', (record, input) => { record.appearance = parseAppearance(input); return applyRemoteAppearance(record); });
handle('desktop:locale', (_record, lang) => { if (!['zh', 'en'].includes(lang)) throw new DesktopError('desktop.locale_invalid', '语言无效。'); language = lang; installMenu(); });
handle('desktop:window', (record, name) => { if (name === 'minimize') record.window.minimize(); else if (name === 'maximize') record.window.isMaximized() ? record.window.unmaximize() : record.window.maximize(); else if (name === 'close') record.window.close(); else throw new DesktopError('desktop.window_action_invalid', '窗口操作无效。'); });
handle('desktop:new-window', (record, route) => createWindow(record.state.origin, safeRoute(route)).then(child => ({ windowId: child.window.id })));
@@ -604,15 +1024,38 @@ function installIPC() {
else if (input.action === 'forward' && contents.navigationHistory.canGoForward()) contents.navigationHistory.goForward();
else if (input.action === 'reload') contents.reload();
else if (input.action === 'stop') contents.stop();
+ else if (input.action === 'reload-hard') contents.reloadIgnoringCache();
+ else if (input.action === 'devtools') { const wasOpen = contents.isDevToolsOpened(); if (wasOpen) contents.closeDevTools(); else contents.openDevTools({ mode: 'detach' }); return { open: !wasOpen }; }
+ else if (input.action === 'zoom') {
+ const factor = Number(input.factor);
+ if (!Number.isFinite(factor) || factor < 0.25 || factor > 3) throw new DesktopError('desktop.preview_zoom_invalid', '页面缩放须在 25% 到 300% 之间。');
+ contents.setZoomFactor(factor); return { factor };
+ }
+ else if (input.action === 'color-scheme') return emulateColorScheme(contents, input.scheme);
+ else if (input.action === 'screenshot') return contents.capturePage().then(image => {
+ if (image.isEmpty()) throw new DesktopError('desktop.preview_capture_empty', '截图为空:预览可能尚未绘制。', { retryable: true });
+ const size = image.getSize();
+ return { mime: 'image/png', data: image.toPNG().toString('base64'), width: size.width, height: size.height, url: contents.getURL(), title: contents.getTitle() };
+ });
+ else if (input.action === 'clear-data') {
+ if (!preview.persistent) throw new DesktopError('desktop.preview_not_persistent', '当前预览未保留登录状态,没有需要清除的数据。');
+ const target = contents.session;
+ return Promise.all([target.clearStorageData(), target.clearCache(), target.clearAuthCache()]).then(() => { contents.reload(); return { cleared: true }; });
+ }
+ else if (input.action === 'pick') return pickElement(preview);
+ else if (input.action === 'pick-cancel') return cancelPick(preview);
else throw new DesktopError('desktop.preview_action_unavailable', '此预览操作不可用。');
});
+ handle('desktop:browser-control', createPreviewControl({ activeThread }));
handle('desktop:close-ack', async (record, input) => {
const barrier = record.barriers.get(input?.token);
if (barrier) { clearTimeout(barrier.timer); record.barriers.delete(input.token); barrier.resolve({ persisted: input.persisted === true, error: input.error }); return; }
if (!input || input.token !== record.pendingClose) throw new DesktopError('desktop.close_ack_stale', '关闭请求已改变。');
clearTimeout(record.closeTimer); record.pendingClose = undefined;
- if (!input.persisted) { const { response } = await dialog.showMessageBox(record.window, { type: 'warning', message: text('草稿保存失败。仍然关闭窗口?', 'Draft saving failed. Close the window anyway?'), detail: String(input.error || ''), buttons: [text('保持打开', 'Keep open'), text('关闭', 'Close')], defaultId: 0, cancelId: 0 }); if (response !== 1) { quitting = false; return; } }
- persist(record); record.allowClose = true; record.window.close();
+ if (!input.persisted) { const { response } = await dialog.showMessageBox(record.window, { type: 'warning', message: text('草稿保存失败。仍然关闭窗口?', 'Draft saving failed. Close the window anyway?'), detail: String(input.error || ''), buttons: [text('保持打开', 'Keep open'), text('关闭', 'Close')], defaultId: 0, cancelId: 0 }); if (response !== 1) { quitting = false; quitApproved = false; return; } }
+ persist(record);
+ if (process.platform === 'darwin' && !quitting && (isNotificationOwner(record) || windows.size === 1)) { record.window.hide(); return; }
+ record.allowClose = true; record.window.close();
});
handle('desktop:attachment-cache', (record, input) => {
if (!(input?.data instanceof ArrayBuffer) || input.data.byteLength > 256 * 1024 * 1024) throw new DesktopError('desktop.attachment_cache_limit', '附件超过桌面缓存范围。');
@@ -660,6 +1103,31 @@ function installIPC() {
if (input.action === 'reveal') shell.showItemInFolder(file);
else { const error = await shell.openPath(file); if (error) throw new DesktopError('desktop.workspace_file_open_failed', error); }
});
+ handle('desktop:workspace-editor', async (record, input) => {
+ const grant = record.workspaceGrants.get(input?.grantId), scope = record.scope;
+ if (!grant || grant.scope !== scope || scope.closed || !activeThread(record, grant.threadId) || ['threadId', 'workspaceId', 'serviceId', 'identityId'].some(key => input[key] !== grant[key])) throw new DesktopError('desktop.workspace_scope_changed', '本机目录映射或会话已改变,请重新选择。');
+ const relative = input.relativePath ?? '';
+ if (typeof relative !== 'string' || path.isAbsolute(relative) || (input.line !== undefined && !(Number.isInteger(input.line) && input.line > 0))) throw new DesktopError('desktop.workspace_file_invalid', '请选择工作区内的相对文件路径。');
+ const editor = input.editor ?? 'auto';
+ if (typeof editor !== 'string' || !EDITOR_CHOICES.has(editor)) throw new DesktopError('desktop.editor_invalid', '不支持的编辑器选项。');
+ const root = await fs.promises.realpath(grant.clientRoot), target = relative ? await fs.promises.realpath(path.resolve(root, relative)) : root;
+ if (root !== grant.clientRoot || (target !== root && !target.startsWith(root + path.sep))) throw new DesktopError('desktop.workspace_file_outside', '文件不属于已选择的本机工作区。');
+ if (scope !== record.scope || scope.closed || !activeThread(record, grant.threadId)) throw new DesktopError('desktop.connection_changed', '服务连接或会话已改变。');
+ return openInEditor(target, relative && input.line ? input.line : undefined, editor);
+ });
+ handle('desktop:menu-accelerators', (_record, input) => {
+ const next = { ...DEFAULT_MENU_ACCELERATORS };
+ for (const key of Object.keys(DEFAULT_MENU_ACCELERATORS)) {
+ const value = input?.[key];
+ if (value === undefined || value === null) continue;
+ // An empty accelerator leaves the shortcut to the renderer (bindings Electron cannot express, such as `?`).
+ if (typeof value !== 'string' || (value !== '' && !ACCELERATOR_PATTERN.test(value))) throw new DesktopError('desktop.accelerator_invalid', `无效的菜单快捷键:${String(value).slice(0, 40)}`);
+ next[key] = value;
+ }
+ menuAccelerators = next;
+ installMenu();
+ return menuAccelerators;
+ });
handle('desktop:microphone', async record => {
if (process.platform !== 'darwin') return { granted: false, code: 'desktop.microphone_os_settings', settings: 'system' };
const scope = record.scope;
@@ -674,6 +1142,7 @@ function installIPC() {
handle('desktop:notification-status', notificationStatus);
handle('desktop:notification-request', requestNotifications);
handle('desktop:notification-send', sendNotification);
+ handle('desktop:notification-sound', playNotificationSound);
handle('desktop:permission-settings', async (_record, name) => {
if (!['microphone', 'speechRecognition', 'notifications'].includes(name)) throw new DesktopError('desktop.permission_invalid', '权限设置类型无效。');
if (process.platform === 'darwin') await shell.openExternal(name === 'microphone' ? 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone' : name === 'speechRecognition' ? 'x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition' : 'x-apple.systempreferences:com.apple.Notifications-Settings.extension');
@@ -698,16 +1167,48 @@ function installIPC() {
handle('desktop:terminal-send', (record, id, data) => { const socket = record.terminals.get(id); if (!socket || socket.scope !== record.scope || socket.scope.closed || !activeThread(record, socket.threadId) || socket.readyState !== WebSocket.OPEN || typeof data !== 'string') throw new DesktopError('desktop.terminal_not_open', '终端尚未连接到当前会话。', { retryable: true }); socket.send(data); });
handle('desktop:terminal-close', (record, id) => { record.terminals.get(id)?.close(); record.terminals.delete(id); });
}
+const DEFAULT_MENU_ACCELERATORS = { 'new-chat': 'CmdOrCtrl+Shift+N', search: 'CmdOrCtrl+K', sidebar: 'CmdOrCtrl+B' };
+let menuAccelerators = { ...DEFAULT_MENU_ACCELERATORS };
+const ACCELERATOR_PATTERN = /^(?:(?:CmdOrCtrl|Ctrl|Alt|Shift)\+){0,4}(?:[A-Z0-9]|F[1-9]|F1[0-2]|Up|Down|Left|Right|Enter|Escape|Space|Tab|Backspace|Delete|[\[\],./\\;'`=-])$/;
function installMenu() {
const role = (name, zh, en) => ({ role: name, label: text(zh, en) });
- const item = (zh, en, name, accelerator) => ({ label: text(zh, en), accelerator, click: () => void executeCommand(name).catch(error => { const record = focused(); if (record) emit(record, { message: error.message }); }) });
+ const item = (zh, en, name, accelerator) => ({ label: text(zh, en), accelerator: accelerator || undefined, click: () => void executeCommand(name).catch(error => { const record = focused(); if (record) emit(record, { message: error.message }); }) });
Menu.setApplicationMenu(Menu.buildFromTemplate([
- ...(process.platform === 'darwin' ? [{ label: 'Muteki', submenu: [role('about', '关于 Muteki', 'About Muteki'), { type: 'separator' }, item('设置…', 'Settings…', 'settings', 'CmdOrCtrl+,'), { label: text('服务连接…', 'Service connection…'), click: () => { const record = focused(); if (record) emit(record, { configuring: true }); } }, { type: 'separator' }, role('hide', '隐藏 Muteki', 'Hide Muteki'), role('hideOthers', '隐藏其他应用', 'Hide others'), role('unhide', '显示全部', 'Show all'), { type: 'separator' }, role('quit', '退出 Muteki', 'Quit Muteki')] }] : []),
- { label: text('对话', 'Chat'), submenu: [item('新建对话', 'New chat', 'new-chat', 'CmdOrCtrl+Shift+N'), item('搜索对话', 'Search conversations', 'search', 'CmdOrCtrl+K'), { label: text('新建窗口', 'New window'), click: () => void createWindow(focused()?.state.origin).catch(error => dialog.showErrorBox('Muteki', error.message)) }, { type: 'separator' }, role('close', '关闭窗口', 'Close window')] },
+ ...(process.platform === 'darwin' ? [{ label: environment.name, submenu: [role('about', '关于 Muteki', 'About Muteki'), { type: 'separator' }, item('设置…', 'Settings…', 'settings', 'CmdOrCtrl+,'), { label: text('服务连接…', 'Service connection…'), click: () => { const record = focused(); if (record) emit(record, { configuring: true }); } }, {label: text('从本机候选版本更新…', 'Update from a local candidate…'), enabled: app.isPackaged && environment.channel === 'stable', click: () => void installCandidate().catch(error => dialog.showErrorBox(environment.name, error.message))}, { type: 'separator' }, role('hide', '隐藏 Muteki', 'Hide Muteki'), role('hideOthers', '隐藏其他应用', 'Hide others'), role('unhide', '显示全部', 'Show all'), { type: 'separator' }, role('quit', '退出 Muteki', 'Quit Muteki')] }] : []),
+ { label: text('对话', 'Chat'), submenu: [item('新建对话', 'New chat', 'new-chat', menuAccelerators['new-chat']), item('搜索对话', 'Search conversations', 'search', menuAccelerators.search), { label: text('新建窗口', 'New window'), click: () => void createWindow(focused()?.state.origin).catch(error => dialog.showErrorBox('Muteki', error.message)) }, { type: 'separator' }, role('close', '关闭窗口', 'Close window')] },
{ label: text('编辑', 'Edit'), submenu: [role('undo', '撤销', 'Undo'), role('redo', '重做', 'Redo'), { type: 'separator' }, role('cut', '剪切', 'Cut'), role('copy', '拷贝', 'Copy'), role('paste', '粘贴', 'Paste'), role('selectAll', '全选', 'Select all')] },
- { label: text('视图', 'View'), submenu: [item('后退', 'Back', 'back', 'Alt+Left'), item('前进', 'Forward', 'forward', 'Alt+Right'), item('刷新', 'Reload', 'reload', 'CmdOrCtrl+R'), item('切换会话侧栏', 'Toggle chat sidebar', 'sidebar', 'CmdOrCtrl+B'), item('快捷键', 'Keyboard shortcuts', 'shortcuts'), { type: 'separator' }, role('resetZoom', '实际大小', 'Actual size'), role('zoomIn', '放大', 'Zoom in'), role('zoomOut', '缩小', 'Zoom out'), role('togglefullscreen', '切换全屏', 'Toggle fullscreen')] }, role('windowMenu', '窗口', 'Window'),
+ { label: text('视图', 'View'), submenu: [item('后退', 'Back', 'back', 'Alt+Left'), item('前进', 'Forward', 'forward', 'Alt+Right'), item('刷新', 'Reload', 'reload', 'CmdOrCtrl+R'), item('切换会话侧栏', 'Toggle chat sidebar', 'sidebar', menuAccelerators.sidebar), item('快捷键', 'Keyboard shortcuts', 'shortcuts'), { type: 'separator' }, role('resetZoom', '实际大小', 'Actual size'), role('zoomIn', '放大', 'Zoom in'), role('zoomOut', '缩小', 'Zoom out'), role('togglefullscreen', '切换全屏', 'Toggle fullscreen')] }, role('windowMenu', '窗口', 'Window'),
]));
}
+let preparingUpdate = false;
+async function installCandidate() {
+ if (preparingUpdate) return;
+ preparingUpdate = true;
+ try {
+ const picked = await dialog.showOpenDialog({title: text('选择已构建的 Muteki 候选应用', 'Select a built Muteki candidate'), properties: ['openFile'], filters: [{name: 'Muteki', extensions: ['app']}]});
+ if (picked.canceled || picked.filePaths.length !== 1) return;
+ const { DesktopUpdates } = require('./updates.cjs');
+ desktopUpdates = new DesktopUpdates(app, environment);
+ const prepared = await desktopUpdates.prepare(picked.filePaths[0]);
+ app.setAsDefaultProtocolClient('muteki');
+ const {response} = await dialog.showMessageBox({type: 'question', message: text(`候选版本 ${prepared.version} 已通过启动验收`, `Candidate ${prepared.version} passed startup acceptance`), detail: text('应用更新会等待当前任务结束、保存草稿并重启。日常数据将先备份,开发数据不会导入。', 'The update saves drafts and restarts after current tasks finish. Daily data is backed up; development data is not imported.'), buttons: [text('稍后', 'Later'), text('应用更新', 'Apply update')], defaultId: 0, cancelId: 0});
+ if (response !== 1) return;
+ const records = [...windows.values()];
+ try {
+ for (const record of records) {
+ record.window.hide();
+ const saved = await persistenceBarrier(record, 'update');
+ if (!saved.persisted) throw new Error(saved.error || '草稿未保存,更新已暂停。');
+ }
+ if (managedService?.ready) await managedService.drain();
+ for (const record of records) record.allowClose = true;
+ applyUpdate = true; quitApproved = true; app.quit();
+ } catch (error) {
+ for (const record of records) if (!record.window.isDestroyed()) record.window.show();
+ throw error;
+ }
+ } finally { preparingUpdate = false; }
+}
async function deepLink(value) {
let url;
try { url = new URL(value); } catch { throw new DesktopError('desktop.deep_link_invalid', '聊天链接无效。'); }
@@ -724,9 +1225,59 @@ if (!app.requestSingleInstanceLock()) app.quit();
else {
app.on('second-instance', (_event, argv) => { const link = argv.find(value => value.startsWith('muteki://')); if (link) void deepLink(link).catch(error => dialog.showErrorBox('Muteki', error.message)); else { const record = focused(); if (record) { record.window.restore(); record.window.show(); record.window.focus(); } } });
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
- app.on('before-quit', event => { if (windows.size) { event.preventDefault(); quitting = true; for (const record of windows.values()) record.window.close(); } });
- app.on('activate', () => { if (!windows.size) void createWindow().catch(error => dialog.showErrorBox('Muteki 无法打开窗口', error.message)); });
+ app.on('did-become-active', refreshNotificationOwners);
+ app.on('did-resign-active', refreshNotificationOwners);
+ let servicesStopped = false, quitChecking = false, finishingQuit = false, stopDevControl;
+ app.on('before-quit', event => {
+ if (managedService?.ready && !quitApproved) {
+ event.preventDefault();
+ if (quitChecking) return;
+ quitChecking = true;
+ void managedService.activity().then(async activity => {
+ if (activity.active > 0) {
+ const { response } = await dialog.showMessageBox({ type: 'question', message: text('仍有任务正在运行', 'Tasks are still running'), detail: text(`退出将停止 ${activity.active} 个活动任务。关闭窗口可让任务继续在后台运行。`, `Quitting will stop ${activity.active} active tasks. Close the window to keep them running in the background.`), buttons: [text('继续运行', 'Keep running'), text('停止任务并退出', 'Stop tasks and quit')], defaultId: 0, cancelId: 0 });
+ if (response !== 1) return;
+ }
+ quitApproved = true; app.quit();
+ }).catch(error => dialog.showErrorBox(environment.name, error.message)).finally(() => { quitChecking = false; });
+ } else if (windows.size) { event.preventDefault(); quitting = true; for (const record of windows.values()) record.window.close(); }
+ else if (managedService && !servicesStopped) {
+ event.preventDefault();
+ if (finishingQuit) return;
+ finishingQuit = true;
+ void managedService.stop().then(async result => {
+ if (applyUpdate) {
+ if (!result.graceful) throw new Error('工作台未能正常停止,本次更新已取消。请重新启动后检查运行状态。');
+ await desktopUpdates.launch(); applyUpdate = false;
+ }
+ servicesStopped = true; finishingQuit = false; app.quit();
+ }).catch(error => { finishingQuit = false; applyUpdate = false; servicesStopped = true; dialog.showErrorBox(environment.name, error.message); app.quit(); });
+ }
+ else if (applyUpdate) { event.preventDefault(); if (finishingQuit) return; finishingQuit = true; void desktopUpdates.launch().then(() => { applyUpdate = false; finishingQuit = false; app.quit(); }).catch(error => { finishingQuit = false; applyUpdate = false; dialog.showErrorBox(environment.name, error.message); }); }
+ else stopDevControl?.();
+ });
+ app.on('activate', () => {
+ const existing = [...windows.values()].find(record => !record.window.isDestroyed());
+ if (existing) { existing.window.show(); existing.window.focus(); }
+ else void createWindow().catch(error => dialog.showErrorBox('Muteki 无法打开窗口', error.message));
+ });
app.whenReady().then(async () => {
+ if (interruptedUpdate && !repairingUpdate) {
+ if (helperRunning(interruptedUpdate.journal)) {
+ dialog.showErrorBox(environment.name, '版本更新仍在进行,请等待更新完成后再打开工作台。');
+ } else {
+ const {DesktopUpdates} = require('./updates.cjs');
+ desktopUpdates = new DesktopUpdates(app, environment);
+ desktopUpdates.prepared = {file: interruptedUpdate.file, transaction: path.dirname(interruptedUpdate.file)};
+ await desktopUpdates.launch(true);
+ }
+ app.quit(); return;
+ }
+ authSessions = new ServiceAuthSessions(path.join(app.getPath('userData'), 'auth-sessions.json'), safeStorage, (owner, source) => {
+ for (const record of windows.values()) {
+ if (record.scope?.auth === owner && record.scope !== source) emit(record, { authSessionVersion: owner.revision });
+ }
+ });
const root = path.resolve(__dirname, '../renderer-dist');
const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.ico': 'image/x-icon' };
protocol.handle('muteki-desktop', async request => {
@@ -736,7 +1287,12 @@ else {
return new Response(document.html, { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com https://esm.sh https://cdn.jsdelivr.net https://unpkg.com; style-src 'unsafe-inline' https://cdnjs.cloudflare.com https://esm.sh https://cdn.jsdelivr.net https://unpkg.com https://fonts.googleapis.com https://fonts.bunny.net; font-src https://fonts.gstatic.com https://fonts.bunny.net; img-src data: blob: https://cdnjs.cloudflare.com https://esm.sh https://cdn.jsdelivr.net https://unpkg.com; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors muteki-desktop://app" } });
}
- if (url.host !== 'app') { const entry = scopes.get(url.host); return forwardService(request, entry?.scope, (scope, data) => identity(entry.record, scope, data)); }
+ if (url.host !== 'app') {
+ const entry = scopes.get(url.host);
+ try { if (entry?.scope.environmentId) await renewManagedSessions(); }
+ catch (error) { return errorResponse(transportError(error)); }
+ return forwardService(request, entry?.scope, (scope, data) => identity(entry.record, scope, data));
+ }
try {
const pathname = decodeURIComponent(url.pathname);
const asset = pathname === '/' || /^\/(chat|settings)(\/|$)/.test(pathname) ? '/index.html' : pathname;
@@ -764,9 +1320,68 @@ else {
title: text('保存 Muteki 文件', 'Save Muteki file'),
defaultPath: path.join(app.getPath('downloads'), path.basename(item.getFilename())),
}));
- if (app.isPackaged && process.env.MUTEKI_DESKTOP_REGISTER_PROTOCOL !== '0') app.setAsDefaultProtocolClient('muteki');
+ if (app.isPackaged && environment.channel === 'stable' && process.env.MUTEKI_DESKTOP_REGISTER_PROTOCOL !== '0') app.setAsDefaultProtocolClient('muteki');
installIPC(); installMenu();
- if (pendingLinks.length) { for (const link of pendingLinks) await deepLink(link); }
+ const renewal = setInterval(() => { void renewManagedSessions().catch(error => {
+ for (const record of windows.values()) if (record.scope?.environmentId === environment.id) emit(record, {message: error.message});
+ }); }, 60000);
+ renewal.unref(); app.once('will-quit', () => clearInterval(renewal));
+ if (process.argv.includes('--muteki-control')) {
+ const { startDevControl } = require('./dev-control.cjs');
+ stopDevControl = await startDevControl(environment, () => [...windows.values()].flatMap(record => {
+ const managed = !record.scope || record.scope.environmentId === environment.id;
+ const rawKeyWindowFocused = record.window.isFocused();
+ const windowState = { visible: record.window.isVisible(), focused: notificationWindowFocused(record),
+ rawKeyWindowFocused, macAppActive: process.platform === 'darwin' ? app.isActive() : null,
+ minimized: record.window.isMinimized() };
+ return [{contents: record.window.webContents, windowId: record.window.id, kind: 'shell', managed, windowState, revision: `${record.state.route}:${record.generation}`, errors: record.rendererErrors},
+ ...(record.remote ? [{contents: record.remote.webContents, windowId: record.window.id, kind: 'workspace', managed}] : []),
+ ...(record.preview ? [{contents: record.preview.webContents, windowId: record.window.id, kind: 'browser', managed}] : [])].filter(item => !item.contents.isDestroyed());
+ }), () => managedService?.status || {state: 'stopped'}, async () => {
+ const activity = await managedService?.activity();
+ if (activity?.active) throw Object.assign(new Error('Dev still has active tasks; stop them before closing this instance'), {code: 'dev.tasks_active'});
+ setTimeout(() => app.quit(), 100);
+ });
+ }
+ if (updateJournal) {
+ const runtime = await managedRuntime().start();
+ const auth = await fetch(`${runtime.origin}/api/auth/me`, {headers: {Authorization: `Bearer ${runtime.token}`}, signal: AbortSignal.timeout(10000)});
+ if (!auth.ok) throw new Error('Updated workspace failed its private session check');
+ patchUpdate({phase: 'validated'});
+ if (repairingUpdate) patchUpdate({phase: 'writes-open'});
+ const deadline = Date.now() + 30000;
+ while (JSON.parse(fs.readFileSync(updateJournal, 'utf8')).phase !== 'writes-open') {
+ if (Date.now() > deadline) throw new Error('Update coordinator did not admit writes');
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ await managedRuntime().refreshSession('activate'); environment.maintenance = false;
+ await createWindow(preferences.mode === 'external' ? preferences.origin : runtime.origin);
+ patchUpdate({phase: 'complete', completedAt: new Date().toISOString()});
+ } else if (selfCheck) {
+ const record = await createWindow();
+ await connectLocal(record);
+ const deadline = Date.now() + 20000;
+ while (!record.scope?.identityId) {
+ if (Date.now() > deadline || record.state.status === 'error') throw new Error('Candidate renderer did not complete its authenticated startup');
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ while (!await record.window.webContents.executeJavaScript('Boolean(document.querySelector(\'[role="textbox"][contenteditable="true"]\'))')) {
+ if (Date.now() > deadline) throw new Error('Candidate conversation interface did not render');
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ if (record.rendererErrors.length) throw new Error(`Candidate renderer reported errors; inspect ${path.join(environment.paths.logs, 'renderer-errors.jsonl')}`);
+ atomicJson(path.join(environment.root, 'candidate-result.json'), {status: 'passed', environmentId: environment.id, generation: environment.generation, version: app.getVersion(), serviceId: record.scope.serviceId});
+ app.quit();
+ } else if (pendingLinks.length) { for (const link of pendingLinks) await deepLink(link); }
else await createWindow(process.env.MUTEKI_DESKTOP_URL || '');
- }).catch(error => { dialog.showErrorBox('Muteki 无法启动', error.message || '请构建桌面界面后重试。'); for (const record of windows.values()) { record.allowClose = true; record.window.destroy(); } });
+ }).catch(error => {
+ if (selfCheck) atomicJson(path.join(environment.root, 'candidate-result.json'), {status: 'failed', error: error.message});
+ else if (updateJournal) {
+ patchUpdate({phase: repairingUpdate ? 'repair-required' : 'failed', error: error.message});
+ if (repairingUpdate) dialog.showErrorBox(environment.name, `当前版本恢复启动失败,数据未回退。\n${error.message}\n${updateJournal}`);
+ }
+ else dialog.showErrorBox('Muteki 无法启动', error.message || '请构建桌面界面后重试。');
+ for (const record of windows.values()) { record.allowClose = true; record.window.destroy(); }
+ if (selfCheck || updateJournal) app.quit();
+ });
}
diff --git a/apps/desktop/src/managed-service.cjs b/apps/desktop/src/managed-service.cjs
new file mode 100644
index 0000000..013fed3
--- /dev/null
+++ b/apps/desktop/src/managed-service.cjs
@@ -0,0 +1,169 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const { spawn } = require('node:child_process');
+const { once } = require('node:events');
+const { EventEmitter } = require('node:events');
+const { randomUUID } = require('node:crypto');
+const { atomicJson, serviceEnvironment } = require('./environment.cjs');
+
+class ManagedService extends EventEmitter {
+ constructor(context, runtime) {
+ super(); this.context = context; this.runtime = runtime; this.children = new Set(); this.ready = null; this.pending = null; this.stopping = false;
+ this.status = { state: 'stopped', environmentId: context.id, channel: context.channel, logs: context.paths.logs }; this.stopPending = null;
+ }
+ changed(patch) { Object.assign(this.status, patch); this.emit('state', { ...this.status }); }
+ async child(name, executable, args, bootstrap) {
+ if (!fs.existsSync(executable)) throw new Error(`Missing ${name} runtime: ${executable}`);
+ const log = fs.openSync(path.join(this.context.paths.logs, `${name}.log`), 'a', 0o600);
+ const env = serviceEnvironment(this.context);
+ env.PATH = [path.dirname(this.runtime.node), path.dirname(this.runtime.python), env.PATH].join(path.delimiter);
+ let child;
+ try { child = spawn(executable, args, { cwd: this.context.root, env, detached: process.platform !== 'win32', windowsHide: true, stdio: ['pipe', log, log, 'pipe'] }); }
+ finally { fs.closeSync(log); }
+ this.children.add(child);
+ if (name === 'backend') this.backendChild = child;
+ child.once('error', () => { if (!child.pid) this.children.delete(child); });
+ child.once('exit', (code, signal) => {
+ this.children.delete(child);
+ if (!this.stopping) {
+ this.ready = null;
+ this.changed({ state: 'failed', error: `${name} exited (${signal || code}). See ${name}.log`, origin: undefined });
+ void this.stop(false).catch(error => this.changed({ error: error.message }));
+ }
+ });
+ const ready = await new Promise((resolve, reject) => {
+ let buffer = '';
+ const timer = setTimeout(() => finish(new Error(`${name} did not become ready; see ${name}.log`)), 120000);
+ const failed = error => finish(error);
+ const exited = (code, signal) => finish(new Error(`${name} stopped before readiness (${signal || code}); see ${name}.log`));
+ const data = chunk => {
+ buffer += chunk.toString('utf8');
+ if (buffer.length > 65536) return finish(new Error(`${name} sent an invalid readiness message`));
+ if (!buffer.includes('\n')) return;
+ try { finish(null, JSON.parse(buffer.slice(0, buffer.indexOf('\n')))); }
+ catch (error) { finish(error); }
+ };
+ const finish = (error, value) => {
+ clearTimeout(timer); child.off('error', failed); child.off('exit', exited); child.stdio[3].off('data', data);
+ if (error) reject(error); else resolve(value);
+ };
+ child.once('error', failed); child.once('exit', exited); child.stdio[3].on('data', data);
+ child.stdin.on('error', failed);
+ child.stdin.write(JSON.stringify(bootstrap) + '\n');
+ });
+ if (ready.environment_id !== this.context.id || ready.generation !== this.context.generation) throw new Error(`${name} readiness identity mismatch`);
+ return ready;
+ }
+ start() {
+ if (this.ready) return Promise.resolve(this.ready);
+ if (this.pending) return this.pending;
+ this.pending = this.launch().finally(() => { this.pending = null; });
+ return this.pending;
+ }
+ async refreshSession(command = 'session') {
+ if (this.refreshPending) return this.refreshPending;
+ if (!this.ready || !this.backendChild || this.backendChild.exitCode !== null) throw new Error('Local workspace is not running');
+ this.refreshPending = new Promise((resolve, reject) => {
+ const child = this.backendChild, request_id = randomUUID();
+ let buffer = '';
+ const finish = (error, value) => {
+ clearTimeout(timer); child.stdio[3].off('data', data); child.off('exit', exited);
+ if (error) reject(error); else resolve(value);
+ };
+ const exited = () => finish(new Error('Local workspace stopped during session handshake'));
+ const data = chunk => {
+ buffer += chunk.toString();
+ if (buffer.length > 65536) return finish(new Error('Invalid private session response size'));
+ if (!buffer.includes('\n')) return;
+ try {
+ const value = JSON.parse(buffer.slice(0, buffer.indexOf('\n')));
+ if (value.request_id !== request_id || typeof value.token !== 'string' || !Number.isFinite(value.expires_at)) throw new Error('Invalid private session response');
+ if (!this.ready) throw new Error('Workspace changed during session handshake');
+ Object.assign(this.ready, {token: value.token, expires_at: value.expires_at});
+ if (command === 'activate') { this.ready.maintenance = false; this.changed({state: 'ready'}); }
+ finish(null, this.ready);
+ } catch (error) { finish(error); }
+ };
+ const timer = setTimeout(() => finish(new Error('Private session handshake timed out')), command === 'activate' ? 120000 : 10000);
+ child.stdio[3].on('data', data); child.once('exit', exited);
+ child.stdin.write(JSON.stringify({command, request_id}) + '\n');
+ }).finally(() => { this.refreshPending = null; });
+ return this.refreshPending;
+ }
+ async launch() {
+ if (this.stopPending) await this.stopPending;
+ this.context.generation = randomUUID();
+ this.stopping = false;
+ this.changed({ state: 'starting', error: undefined });
+ try {
+ const bootstrap = { environment_id: this.context.id, generation: this.context.generation, release: this.runtime.release };
+ const backend = await this.child('backend', this.runtime.python, ['-I', '-B', path.join(this.runtime.code, 'apps/web/desktop_runtime.py')], bootstrap);
+ const preferences = path.join(this.context.root, 'runtime-preferences.json');
+ const previous = fs.existsSync(preferences) ? JSON.parse(fs.readFileSync(preferences, 'utf8')) : {};
+ if (previous.uiPort !== undefined && (!Number.isInteger(previous.uiPort) || previous.uiPort < 1024 || previous.uiPort > 65535)) throw new Error('Invalid saved workspace port');
+ if (!fs.existsSync(this.runtime.next)) throw new Error('Production Web UI is missing. Build the Web UI before starting the development App.');
+ // Standalone Node cannot resolve Electron's virtual app.asar filesystem.
+ const ui = await this.child('ui', this.runtime.node, [path.join(this.runtime.code, 'apps/desktop/src/next-runtime.cjs')], {
+ ...bootstrap, next: this.runtime.next, backend: `http://127.0.0.1:${backend.api_port}`, port: previous.uiPort || 0,
+ });
+ const origin = `http://127.0.0.1:${ui.port}`;
+ const health = await fetch(`${origin}/api/health`, { signal: AbortSignal.timeout(15000), redirect: 'error' }).then(response => response.json());
+ if ((!health.ready && !(this.context.maintenance && health.desktop_runtime?.maintenance)) || health.desktop_runtime?.generation !== this.context.generation || health.desktop_runtime?.environment_id !== this.context.id) throw new Error('Workspace health identity mismatch');
+ atomicJson(preferences, { uiPort: ui.port });
+ this.ready = { ...backend, origin };
+ const descriptor = { ...bootstrap, origin, pid: process.pid, backendPid: backend.pid, serviceId: backend.service_id };
+ atomicJson(path.join(this.context.root, 'runtime.json'), descriptor);
+ this.changed({ state: this.context.maintenance ? 'maintenance' : 'ready', origin, portChanged: Boolean(previous.uiPort && previous.uiPort !== ui.port), error: undefined });
+ return this.ready;
+ } catch (error) {
+ await this.stop(false);
+ this.changed({ state: 'failed', error: error.message });
+ throw error;
+ }
+ }
+ stop(report = true) {
+ if (this.stopPending) return this.stopPending;
+ this.stopPending = this.shutdown(report).finally(() => { this.stopPending = null; });
+ return this.stopPending;
+ }
+ async activity() {
+ if (!this.ready) return { active: 0 };
+ await this.refreshSession();
+ const response = await fetch(`${this.ready.origin}/api/desktop/activity`, { headers: { Authorization: `Bearer ${this.ready.token}` }, signal: AbortSignal.timeout(10000) });
+ if (!response.ok) throw new Error(`Cannot verify running tasks (HTTP ${response.status}); the workspace is still running`);
+ return response.json();
+ }
+ async drain(cancel = false) {
+ await this.refreshSession();
+ const response = await fetch(`${this.ready.origin}/api/desktop/drain`, { method: 'POST', headers: { Authorization: `Bearer ${this.ready.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({cancel}), signal: AbortSignal.timeout(10000) });
+ const result = await response.json();
+ if (!response.ok) throw new Error(result.detail || result.error?.message || 'Workspace could not enter maintenance');
+ return result;
+ }
+ async shutdown(report) {
+ this.stopping = true; this.ready = null;
+ let graceful = true;
+ const children = [...this.children].filter(child => child.pid && child.exitCode === null && child.signalCode === null);
+ await Promise.all(children.map(async child => {
+ const exited = once(child, 'exit').catch(() => {});
+ child.stdin.end();
+ const timer = setTimeout(() => {
+ try {
+ if (process.platform === 'win32') child.kill();
+ else process.kill(-child.pid, 'SIGTERM');
+ } catch (error) { if (error.code !== 'ESRCH') this.changed({ error: error.message }); }
+ }, 5000);
+ const force = setTimeout(() => {
+ graceful = false;
+ try { if (process.platform === 'win32') child.kill('SIGKILL'); else process.kill(-child.pid, 'SIGKILL'); }
+ catch (error) { if (error.code !== 'ESRCH') this.changed({ error: error.message }); }
+ }, 25000);
+ await exited; clearTimeout(timer); clearTimeout(force);
+ }));
+ const descriptor = path.join(this.context.root, 'runtime.json');
+ if (fs.existsSync(descriptor) && JSON.parse(fs.readFileSync(descriptor, 'utf8')).generation === this.context.generation) fs.unlinkSync(descriptor);
+ if (report) this.changed({ state: 'stopped', origin: undefined });
+ return {graceful};
+ }
+}
+module.exports = { ManagedService };
diff --git a/apps/desktop/src/next-runtime.cjs b/apps/desktop/src/next-runtime.cjs
new file mode 100644
index 0000000..17fff7d
--- /dev/null
+++ b/apps/desktop/src/next-runtime.cjs
@@ -0,0 +1,60 @@
+// Production Next handler with an owned socket and a private readiness pipe.
+const fs = require('node:fs');
+const path = require('node:path');
+const http = require('node:http');
+const { once } = require('node:events');
+const readline = require('node:readline');
+
+async function main() {
+ const lines = readline.createInterface({ input: process.stdin });
+ const [line] = await once(lines, 'line');
+ const input = JSON.parse(line);
+ const dir = path.dirname(input.next);
+ const config = JSON.parse(fs.readFileSync(path.join(dir, '.next/required-server-files.json'), 'utf8')).config;
+ config.distDir = '.next';
+ // Packaged source is immutable. ISR and image caches must not write into it.
+ config.experimental = { ...config.experimental, isrFlushToDisk: false };
+ config.images = { ...config.images, unoptimized: true };
+ process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config);
+ process.env.MUTEKI_BACKEND = input.backend;
+ const { getRequestHandlers } = require(path.join(dir, 'node_modules/next/dist/server/lib/start-server'));
+ let handlers;
+ let port;
+ const trustedHost = request => [`127.0.0.1:${port}`, `localhost:${port}`].includes(request.headers.host);
+ const server = http.createServer((request, response) => {
+ if (!trustedHost(request)) { response.writeHead(403); response.end('Invalid workspace host'); return; }
+ if (!handlers) { response.writeHead(503); response.end('Preparing workspace'); return; }
+ void handlers.requestHandler(request, response);
+ });
+ const bind = port => new Promise((resolve, reject) => {
+ const failed = error => { server.off('listening', ready); reject(error); };
+ const ready = () => { server.off('error', failed); resolve(); };
+ server.once('error', failed); server.once('listening', ready);
+ server.listen(port, '127.0.0.1');
+ });
+ try { await bind(input.port || 0); }
+ catch (error) { if (error.code !== 'EADDRINUSE') throw error; await bind(0); }
+ port = server.address().port;
+ handlers = await getRequestHandlers({ dir, port, hostname: '127.0.0.1', isDev: false, server, minimalMode: false, quiet: true });
+ server.on('upgrade', (request, socket, head) => {
+ if (!trustedHost(request) || (request.headers.origin && ![`http://127.0.0.1:${port}`, `http://localhost:${port}`].includes(request.headers.origin))) { socket.destroy(); return; }
+ // Next route handlers don't proxy WebSocket upgrades. Keep them on the
+ // same owned backend and preserve the browser's credentials and origin.
+ if (request.url.startsWith('/api/')) {
+ const upstream = http.request(new URL(request.url, input.backend), { headers: { ...request.headers, host: new URL(input.backend).host, ...(request.headers.origin ? { origin: input.backend } : {}) } });
+ upstream.on('upgrade', (reply, peer, buffered) => {
+ socket.write(`HTTP/1.1 101 Switching Protocols\r\n${Object.entries(reply.headers).map(([key, value]) => `${key}: ${value}`).join('\r\n')}\r\n\r\n`);
+ if (buffered.length) socket.write(buffered);
+ if (head.length) peer.write(head);
+ peer.pipe(socket); socket.pipe(peer);
+ socket.on('error', () => peer.destroy()); peer.on('error', () => socket.destroy());
+ });
+ upstream.on('response', reply => { socket.end(`HTTP/1.1 ${reply.statusCode} Rejected\r\nConnection: close\r\n\r\n`); reply.resume(); });
+ upstream.on('error', () => socket.destroy()); socket.on('close', () => upstream.destroy()); upstream.end();
+ } else void handlers.upgradeHandler(request, socket, head);
+ });
+ fs.writeSync(3, JSON.stringify({ environment_id: input.environment_id, generation: input.generation, port }) + '\n');
+ const stop = () => { server.close(); server.closeAllConnections(); process.exit(0); };
+ lines.on('close', stop); process.on('SIGTERM', stop); process.on('SIGINT', stop);
+}
+main().catch(error => { console.error(error); process.exit(1); });
diff --git a/apps/desktop/src/policy.cjs b/apps/desktop/src/policy.cjs
index ab8f95e..c52a654 100644
--- a/apps/desktop/src/policy.cjs
+++ b/apps/desktop/src/policy.cjs
@@ -44,8 +44,9 @@ function allowedNavigation(value, origin, { preview = false } = {}) {
}
}
-function partitionFor(origin) {
- return `persist:muteki-${createHash('sha256').update(normalizeOrigin(origin)).digest('hex')}`;
+function partitionFor(origin, environmentId) {
+ const identity = environmentId ? `environment:${environmentId}` : normalizeOrigin(origin);
+ return `persist:muteki-${createHash('sha256').update(identity).digest('hex')}`;
}
function webPreferences(partition) {
@@ -68,12 +69,19 @@ function normalizeDesktopRoute(value) {
if (typeof value !== 'string') throw invalid();
let url;
try { url = new URL(value, SHELL_URL); } catch { throw invalid(); }
- if (url.protocol !== 'muteki-desktop:' || url.host !== 'app' || url.username || url.password || url.hash
- || !/^\/(chat(?:\/[^/]+)?|settings(?:\/[a-z-]+)?|ctf(?:\/.*)?|pentest(?:\/.*)?|competitions(?:\/.*)?|task(?:\/.*)?|usage)?$/.test(url.pathname)) throw invalid();
+ if (url.protocol !== 'muteki-desktop:' || url.host !== 'app' || url.username || url.password
+ || !/^\/(chat(?:\/[^/]+)?|settings(?:\/[a-z-]+)?|ctf(?:\/.*)?|pentest(?:\/.*)?|competitions(?:\/.*)?|run\/[^/]+|task(?:\/.*)?|usage)?$/.test(url.pathname)) throw invalid();
if (url.pathname.startsWith('/chat/')) {
try { decodeURIComponent(url.pathname.split('/')[2]); } catch { throw invalid(); }
}
return url.pathname + url.search;
}
-module.exports = { DEFAULT_ORIGIN, SHELL_URL, parseWebUrl, normalizeOrigin, normalizeDesktopRoute, sameOrigin, allowedNavigation, partitionFor, webPreferences };
+/** A hash is a transient location target, never part of shell history or saved routes. */
+function desktopNavigationTarget(value) {
+ const route = normalizeDesktopRoute(value);
+ const hash = new URL(value, SHELL_URL).hash;
+ return { route, hash };
+}
+
+module.exports = { DEFAULT_ORIGIN, SHELL_URL, parseWebUrl, normalizeOrigin, normalizeDesktopRoute, desktopNavigationTarget, sameOrigin, allowedNavigation, partitionFor, webPreferences };
diff --git a/apps/desktop/src/preferences.cjs b/apps/desktop/src/preferences.cjs
index fa3b1f9..8a337ed 100644
--- a/apps/desktop/src/preferences.cjs
+++ b/apps/desktop/src/preferences.cjs
@@ -3,12 +3,31 @@ const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { normalizeOrigin } = require('./policy.cjs');
+function notificationConsentKey(scope) {
+ if (!scope || typeof scope.serviceId !== 'string' || !scope.serviceId
+ || typeof scope.identityId !== 'string' || !scope.identityId) return '';
+ const workspace = scope.environmentId ? `managed:${scope.environmentId}` : normalizeOrigin(scope.origin);
+ return JSON.stringify([workspace, scope.serviceId, scope.identityId]);
+}
+
+function notificationConsents(value) {
+ if (value === undefined) return undefined;
+ if (!value || typeof value !== 'object' || Array.isArray(value)
+ || Object.values(value).some(choice => typeof choice !== 'boolean')) {
+ throw new Error('Notification consent preferences must contain boolean choices.');
+ }
+ return { ...value };
+}
+
function readPreferences(file) {
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
const origin = normalizeOrigin(data.origin);
+ const consents = notificationConsents(data.notificationConsents);
return { origin, routes: data.routes && typeof data.routes === 'object' ? data.routes : {},
- ...(data.bounds && typeof data.bounds === 'object' ? { bounds: data.bounds } : {}) };
+ ...(data.mode === 'local' || data.mode === 'external' ? { mode: data.mode } : {}),
+ ...(data.bounds && typeof data.bounds === 'object' ? { bounds: data.bounds } : {}),
+ ...(consents !== undefined ? { notificationConsents: consents } : {}) };
} catch (error) {
if (error.code === 'ENOENT') return { origin: '', routes: {} };
return { origin: '', routes: {}, error: { code: 'desktop.preferences_invalid',
@@ -17,7 +36,9 @@ function readPreferences(file) {
}
function writePreferences(file, origin, extras = {}) {
- const data = { origin: normalizeOrigin(origin), routes: extras.routes || {}, bounds: extras.bounds };
+ const consents = notificationConsents(extras.notificationConsents);
+ const data = { ...(consents !== undefined ? { notificationConsents: consents } : {}), origin: normalizeOrigin(origin), routes: extras.routes || {}, bounds: extras.bounds,
+ ...(extras.mode === 'local' || extras.mode === 'external' ? { mode: extras.mode } : {}) };
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.${randomUUID()}.tmp`;
try {
@@ -29,4 +50,4 @@ function writePreferences(file, origin, extras = {}) {
} finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); }
}
-module.exports = { readPreferences, writePreferences };
+module.exports = { readPreferences, writePreferences, notificationConsentKey };
diff --git a/apps/desktop/src/preload.cjs b/apps/desktop/src/preload.cjs
index ab2f11a..33abc3d 100644
--- a/apps/desktop/src/preload.cjs
+++ b/apps/desktop/src/preload.cjs
@@ -12,26 +12,31 @@ function subscribe(channel, callback) {
}
contextBridge.exposeInMainWorld('mutekiDesktop', {
getState: () => invoke('desktop:state'), connect: origin => invoke('desktop:connect', origin),
+ connectLocal: () => invoke('desktop:connect-local'),
configure: () => invoke('desktop:configure'), resume: () => invoke('desktop:resume'),
+ consumeAnchor: id => invoke('desktop:anchor-consumed', id),
navigate: (href, mode = 'push') => invoke('desktop:navigate', href, mode), action: name => invoke('desktop:action', name),
- setLocale: lang => invoke('desktop:locale', lang), windowAction: name => invoke('desktop:window', name),
+ setLocale: lang => invoke('desktop:locale', lang), syncAppearance: input => invoke('desktop:appearance', input), windowAction: name => invoke('desktop:window', name),
newWindow: route => invoke('desktop:new-window', route), openExternal: url => invoke('desktop:external', url),
openPreview: input => invoke('desktop:preview', input), closePreview: input => invoke('desktop:preview-close', input),
- previewAction: input => invoke('desktop:preview-action', input),
+ previewAction: input => invoke('desktop:preview-action', input), browserControl: input => invoke('desktop:browser-control', input),
createVisualization: input => invoke('desktop:visualization-create', input), releaseVisualization: id => invoke('desktop:visualization-release', id),
cacheAttachment: input => invoke('desktop:attachment-cache', input), restoreAttachment: input => invoke('desktop:attachment-restore', input),
removeAttachment: input => invoke('desktop:attachment-remove', input),
attachmentCacheUsage: () => invoke('desktop:attachment-cache-usage'),
selectPath: input => invoke('desktop:select-path', input), openPath: input => invoke('desktop:open-path', input),
selectWorkspaceRoot: input => invoke('desktop:workspace-root', input), openWorkspaceFile: input => invoke('desktop:workspace-file', input),
+ openWorkspaceInEditor: input => invoke('desktop:workspace-editor', input),
+ setMenuAccelerators: input => invoke('desktop:menu-accelerators', input),
requestMicrophone: () => invoke('desktop:microphone'), openPermissionSettings: name => invoke('desktop:permission-settings', name),
startSpeech: input => invoke('desktop:speech-start', input), finishSpeech: input => invoke('desktop:speech-finish', input), cancelSpeech: input => invoke('desktop:speech-cancel', input),
onSpeech: callback => subscribe('desktop:speech', callback),
notificationStatus: input => invoke('desktop:notification-status', input), requestNotifications: input => invoke('desktop:notification-request', input),
- sendNotification: input => invoke('desktop:notification-send', input), onNotification: callback => subscribe('desktop:notification-state', callback),
+ sendNotification: input => invoke('desktop:notification-send', input), playNotificationSound: input => invoke('desktop:notification-sound', input), onNotification: callback => subscribe('desktop:notification-state', callback),
acknowledgeClose: input => invoke('desktop:close-ack', input),
terminalOpen: input => invoke('desktop:terminal-open', input), terminalSend: (id, data) => invoke('desktop:terminal-send', id, data), terminalClose: id => invoke('desktop:terminal-close', id),
onState: callback => subscribe('desktop:state-changed', callback), onCommand: callback => subscribe('desktop:command', callback),
+ onFocus: callback => subscribe('desktop:focus', callback),
onBeforeClose: callback => subscribe('desktop:before-close', callback), onTerminal: callback => subscribe('desktop:terminal', callback),
onPreview: callback => subscribe('desktop:preview-state', callback),
});
diff --git a/apps/desktop/src/preview-control.cjs b/apps/desktop/src/preview-control.cjs
new file mode 100644
index 0000000..4222dd5
--- /dev/null
+++ b/apps/desktop/src/preview-control.cjs
@@ -0,0 +1,473 @@
+// Provider-driven control of a thread's native preview (chat_browser_* tools).
+// Page functions below run inside the isolated preview page through
+// executeJavaScript, so each must be self-contained.
+const { DesktopError } = require('./transport.cjs');
+const { parseWebUrl } = require('./policy.cjs');
+
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+async function until(predicate, timeoutMs, step = 100) {
+ const end = Date.now() + timeoutMs;
+ for (;;) {
+ const value = predicate();
+ if (value || Date.now() >= end) return value;
+ await sleep(step);
+ }
+}
+
+function pageRead({ mode, selector }) {
+ const root = selector ? document.querySelector(selector) : (document.body || document.documentElement);
+ if (!root) return { missing: true };
+ const base = { url: location.href, title: document.title };
+ if (mode === 'html') return { ...base, content: root.outerHTML };
+ if (mode === 'text') return { ...base, content: root.innerText ?? root.textContent ?? '' };
+ const interactive = 'a[href],button,input:not([type=hidden]),textarea,select,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=option],[role=switch],[role=textbox],[role=combobox],[role=searchbox],[contenteditable=""],[contenteditable=true],[onclick],[tabindex]:not([tabindex="-1"])';
+ const visible = el => {
+ const rect = el.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) return false;
+ const style = getComputedStyle(el);
+ return style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) !== 0;
+ };
+ let seq = Number(document.documentElement.dataset.mutekiRefSeq || 0);
+ const elements = [];
+ for (const el of root.querySelectorAll(interactive)) {
+ if (!visible(el)) continue;
+ if (!el.dataset.mutekiRef) el.dataset.mutekiRef = String(++seq);
+ const rect = el.getBoundingClientRect();
+ const type = el.getAttribute('type') || undefined;
+ const secret = type === 'password';
+ const hasValue = 'value' in el && typeof el.value === 'string';
+ elements.push({
+ ref: Number(el.dataset.mutekiRef), tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || undefined, type,
+ name: el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('title') || el.getAttribute('alt') || el.getAttribute('name') || undefined,
+ text: (el.innerText || '').replace(/\s+/g, ' ').trim() || undefined,
+ value: hasValue && !secret ? el.value : undefined, value_length: hasValue && secret ? el.value.length : undefined,
+ href: typeof el.href === 'string' && el.href ? el.href : undefined,
+ checked: type === 'checkbox' || type === 'radio' ? Boolean(el.checked) : undefined,
+ disabled: el.disabled === true || el.getAttribute('aria-disabled') === 'true' || undefined,
+ in_viewport: rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth,
+ });
+ }
+ document.documentElement.dataset.mutekiRefSeq = String(seq);
+ return { ...base, elements };
+}
+
+function pageTarget({ ref, selector, text }) {
+ const interactive = 'a[href],button,input:not([type=hidden]),textarea,select,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=option],[role=switch],[role=textbox],[role=combobox],[role=searchbox],[contenteditable=""],[contenteditable=true],[onclick],[tabindex]:not([tabindex="-1"])';
+ const visible = el => {
+ const rect = el.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) return false;
+ const style = getComputedStyle(el);
+ return style.visibility !== 'hidden' && style.display !== 'none';
+ };
+ const label = el => (el.innerText || el.value || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim();
+ let el = null;
+ if (ref) el = document.querySelector(`[data-muteki-ref="${Number(ref)}"]`);
+ else if (selector) el = document.querySelector(selector);
+ else if (text) {
+ const want = text.replace(/\s+/g, ' ').trim().toLowerCase();
+ const pool = Array.from(document.querySelectorAll(interactive)).filter(visible);
+ el = pool.find(item => label(item).toLowerCase() === want) || pool.find(item => label(item).toLowerCase().includes(want)) || null;
+ if (!el) {
+ const exact = Array.from(document.body.querySelectorAll('*')).filter(item => label(item).toLowerCase() === want && visible(item));
+ el = exact.at(-1) || null;
+ }
+ }
+ if (!el) return { missing: true };
+ el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
+ const rect = el.getBoundingClientRect();
+ const x = rect.left + rect.width / 2, y = rect.top + rect.height / 2;
+ const hit = document.elementFromPoint(x, y);
+ return { x, y, tag: el.tagName.toLowerCase(), text: label(el) || undefined,
+ covered_by: hit && hit !== el && !el.contains(hit) ? hit.tagName.toLowerCase() : undefined };
+}
+
+function pageFocused({ caretToEnd }) {
+ const el = document.activeElement;
+ if (!el || el === document.body || el === document.documentElement) return null;
+ const tag = el.tagName.toLowerCase();
+ const editable = el.isContentEditable || tag === 'textarea' || tag === 'select'
+ || (tag === 'input' && !['button', 'submit', 'reset', 'checkbox', 'radio', 'file', 'image', 'range', 'color'].includes(el.type));
+ if (caretToEnd && typeof el.value === 'string' && typeof el.setSelectionRange === 'function') {
+ try { el.setSelectionRange(el.value.length, el.value.length); } catch { /* input types without selection */ }
+ }
+ return { tag, type: el.getAttribute('type') || undefined, name: el.getAttribute('name') || el.getAttribute('aria-label') || el.getAttribute('placeholder') || undefined,
+ editable, value_length: typeof el.value === 'string' ? el.value.length : (el.isContentEditable ? (el.innerText || '').length : undefined) };
+}
+
+// User-driven element picker. Resolves with the clicked element's description,
+// or null when the user presses Escape or the picker is cancelled.
+function pagePickElement({ htmlLimit }) {
+ const KEY = '__mutekiElementPicker';
+ if (window[KEY]) window[KEY].cancel();
+ return new Promise(resolve => {
+ const box = document.createElement('div');
+ const tag = document.createElement('div');
+ const style = document.createElement('style');
+ const z = '2147483647';
+ box.setAttribute('data-muteki-picker', '');
+ tag.setAttribute('data-muteki-picker', '');
+ box.style.cssText = `position:fixed;pointer-events:none;z-index:${z};border:2px solid #3b82f6;background:rgba(59,130,246,.14);border-radius:3px;transition:all 60ms ease-out;display:none;box-sizing:border-box`;
+ tag.style.cssText = `position:fixed;pointer-events:none;z-index:${z};font:600 11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:#fff;background:#1d4ed8;padding:2px 6px;border-radius:4px;white-space:nowrap;max-width:60vw;overflow:hidden;text-overflow:ellipsis;display:none`;
+ style.textContent = '*{cursor:crosshair!important}';
+ document.documentElement.append(style, box, tag);
+ let target = null;
+ const short = el => {
+ let out = el.tagName.toLowerCase();
+ if (el.id) out += `#${el.id}`;
+ const classes = [...el.classList].slice(0, 2);
+ if (classes.length) out += `.${classes.join('.')}`;
+ return out;
+ };
+ const esc = value => (window.CSS && CSS.escape ? CSS.escape(value) : value.replace(/[^\w-]/g, '\\$&'));
+ const selectorFor = el => {
+ if (el.id && document.querySelectorAll(`#${esc(el.id)}`).length === 1) return `#${esc(el.id)}`;
+ const parts = [];
+ let node = el;
+ while (node && node.nodeType === 1 && node !== document.documentElement) {
+ let part = node.tagName.toLowerCase();
+ if (node.id && document.querySelectorAll(`#${esc(node.id)}`).length === 1) { parts.unshift(`#${esc(node.id)}`); break; }
+ const testId = node.getAttribute('data-testid');
+ if (testId) part += `[data-testid="${testId.replace(/"/g, '\\"')}"]`;
+ const parent = node.parentElement;
+ if (parent) {
+ const same = [...parent.children].filter(child => child.tagName === node.tagName);
+ if (same.length > 1) part += `:nth-of-type(${same.indexOf(node) + 1})`;
+ }
+ parts.unshift(part);
+ const candidate = parts.join(' > ');
+ try { if (document.querySelectorAll(candidate).length === 1) return candidate; } catch { /* keep climbing */ }
+ node = parent;
+ }
+ return parts.join(' > ');
+ };
+ const describe = el => {
+ const rect = el.getBoundingClientRect();
+ const attrs = {};
+ for (const name of ['role', 'aria-label', 'name', 'type', 'href', 'src', 'alt', 'placeholder', 'title', 'data-testid', 'value']) {
+ const value = name === 'value' && 'value' in el ? el.value : el.getAttribute(name);
+ if (value != null && value !== '') attrs[name] = String(value);
+ }
+ const computed = getComputedStyle(el);
+ const styles = {};
+ for (const name of ['display', 'position', 'color', 'background-color', 'font-size', 'font-weight', 'font-family', 'padding', 'margin', 'border-radius']) styles[name] = computed.getPropertyValue(name);
+ const html = el.outerHTML || '';
+ const text = (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
+ return {
+ url: location.href, title: document.title, tag: el.tagName.toLowerCase(), selector: selectorFor(el),
+ id: el.id || null, classes: [...el.classList], text, attributes: attrs, styles,
+ html: html.length > htmlLimit ? html.slice(0, htmlLimit) : html, html_length: html.length, html_truncated: html.length > htmlLimit,
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
+ viewport: { width: innerWidth, height: innerHeight }, device_pixel_ratio: devicePixelRatio,
+ };
+ };
+ const show = el => {
+ const rect = el.getBoundingClientRect();
+ Object.assign(box.style, { display: 'block', left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, height: `${rect.height}px` });
+ tag.textContent = `${short(el)} ${Math.round(rect.width)}×${Math.round(rect.height)}`;
+ const above = rect.top > 22;
+ Object.assign(tag.style, { display: 'block', left: `${Math.max(0, rect.left)}px`, top: `${above ? rect.top - 21 : Math.min(innerHeight - 20, rect.bottom + 3)}px` });
+ };
+ const pick = event => {
+ const el = document.elementFromPoint(event.clientX, event.clientY);
+ if (!el || el.hasAttribute('data-muteki-picker')) return;
+ target = el;
+ show(el);
+ };
+ const swallow = event => { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); };
+ const finish = value => {
+ window.removeEventListener('mousemove', pick, true);
+ for (const type of ['mousedown', 'mouseup', 'pointerdown', 'pointerup', 'dblclick', 'contextmenu']) window.removeEventListener(type, swallow, true);
+ window.removeEventListener('click', choose, true);
+ window.removeEventListener('keydown', key, true);
+ box.remove(); tag.remove(); style.remove();
+ delete window[KEY];
+ // Two frames so the highlight is gone from the next captured frame; rAF
+ // never fires while the view is hidden/occluded, so a timer backs it up.
+ let settled = false;
+ const settle = () => { if (!settled) { settled = true; resolve(value); } };
+ requestAnimationFrame(() => requestAnimationFrame(settle));
+ setTimeout(settle, 150);
+ };
+ const choose = event => {
+ swallow(event);
+ const el = target || document.elementFromPoint(event.clientX, event.clientY);
+ finish(el ? describe(el) : null);
+ };
+ const key = event => {
+ if (event.key === 'Escape') { swallow(event); finish(null); }
+ else if (event.key === 'ArrowUp' && target?.parentElement && target.parentElement !== document.documentElement) { swallow(event); target = target.parentElement; show(target); }
+ else if (event.key === 'Enter' && target) { swallow(event); finish(describe(target)); }
+ };
+ window.addEventListener('mousemove', pick, true);
+ for (const type of ['mousedown', 'mouseup', 'pointerdown', 'pointerup', 'dblclick', 'contextmenu']) window.addEventListener(type, swallow, true);
+ window.addEventListener('click', choose, true);
+ window.addEventListener('keydown', key, true);
+ window[KEY] = { cancel: () => finish(null) };
+ });
+}
+
+function pageCancelPick() {
+ window.__mutekiElementPicker?.cancel();
+ return true;
+}
+
+function pageScroll({ direction, amount, to }) {
+ const root = document.scrollingElement || document.documentElement;
+ if (to === 'top') window.scrollTo({ top: 0, behavior: 'instant' });
+ else if (to === 'bottom') window.scrollTo({ top: root.scrollHeight, behavior: 'instant' });
+ else if (direction) {
+ const step = amount || (direction === 'left' || direction === 'right' ? innerWidth : innerHeight) * 0.85;
+ window.scrollBy({ left: direction === 'left' ? -step : direction === 'right' ? step : 0, top: direction === 'up' ? -step : direction === 'down' ? step : 0, behavior: 'instant' });
+ }
+ return { scroll_x: Math.round(scrollX), scroll_y: Math.round(scrollY), page_width: root.scrollWidth, page_height: root.scrollHeight, viewport: { width: innerWidth, height: innerHeight } };
+}
+
+function pagePresent({ selector, text }) {
+ if (selector) {
+ const el = document.querySelector(selector);
+ if (!el) return false;
+ const rect = el.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+ }
+ const want = text.replace(/\s+/g, ' ').trim().toLowerCase();
+ return (document.body?.innerText || '').replace(/\s+/g, ' ').toLowerCase().includes(want);
+}
+
+function page(fn, arg) { return `(${fn.toString()})(${JSON.stringify(arg)})`; }
+
+// The expression is evaluated as written by the agent; serialisation keeps DOM
+// nodes and cycles readable instead of failing JSON.stringify.
+function evaluateScript(expression) {
+ return `(async () => {
+ const value = await (${expression}
+ );
+ const seen = new WeakSet();
+ const json = JSON.stringify(value === undefined ? null : value, (key, item) => {
+ if (typeof item === 'bigint') return item.toString();
+ if (typeof item === 'function') return '[function ' + (item.name || 'anonymous') + ']';
+ if (item instanceof Element) return '<' + item.tagName.toLowerCase() + (item.id ? '#' + item.id : '') + '>';
+ if (item && typeof item === 'object') { if (seen.has(item)) return '[circular]'; seen.add(item); }
+ return item;
+ });
+ return { type: value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value, json: json ?? 'null' };
+ })()`;
+}
+
+const KEY_CODES = { ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', ' ': 'Space', Esc: 'Escape' };
+const EVALUATE_MAX_CHARS = 200000;
+
+const PICK_HTML_LIMIT = 6000;
+const PICK_PADDING = 6;
+
+/** Runs the element picker on a user-opened preview and crops a screenshot of the pick. */
+async function pickElement(preview) {
+ const contents = preview.webContents;
+ contents.focus();
+ let picked;
+ try { picked = await contents.executeJavaScript(page(pagePickElement, { htmlLimit: PICK_HTML_LIMIT }), true); }
+ catch (error) { throw new DesktopError('desktop.preview_pick_failed', `元素选择器无法在此页面运行:${error?.message || error}`); }
+ if (!picked) return null;
+ const bounds = preview.getBounds();
+ const x = Math.max(0, Math.floor(picked.rect.x - PICK_PADDING)), y = Math.max(0, Math.floor(picked.rect.y - PICK_PADDING));
+ const right = Math.min(bounds.width, Math.ceil(picked.rect.x + picked.rect.width + PICK_PADDING));
+ const bottom = Math.min(bounds.height, Math.ceil(picked.rect.y + picked.rect.height + PICK_PADDING));
+ let screenshot = null;
+ let screenshotError = null;
+ if (right - x >= 2 && bottom - y >= 2) {
+ try {
+ const image = await contents.capturePage({ x, y, width: right - x, height: bottom - y });
+ if (image.isEmpty()) screenshotError = '截图为空(预览可能被遮挡或未显示)';
+ else {
+ const size = image.getSize();
+ screenshot = { mime: 'image/png', data: image.toPNG().toString('base64'), width: size.width, height: size.height };
+ }
+ } catch (error) {
+ // 截图只是附加信息;元素描述已拿到,带上失败原因交给界面展示。
+ screenshotError = `截图失败:${error?.message || error}`;
+ }
+ }
+ return { ...picked, screenshot, screenshot_error: screenshotError, screenshot_clipped: picked.rect.x < 0 || picked.rect.y < 0 || picked.rect.x + picked.rect.width > bounds.width || picked.rect.y + picked.rect.height > bounds.height };
+}
+
+async function cancelPick(preview) {
+ try { await preview.webContents.executeJavaScript(page(pageCancelPick, null), true); }
+ catch { /* page navigated away; nothing left to cancel */ }
+}
+
+function createPreviewControl({ activeThread }) {
+ const current = (record, threadId) => {
+ const preview = record.preview;
+ return preview?.webContents && !preview.webContents.isDestroyed() && preview.scope === record.scope && !preview.scope.closed && preview.threadId === threadId ? preview : undefined;
+ };
+ const liveContents = preview => {
+ const contents = preview.webContents;
+ if (!contents || contents.isDestroyed()) throw new DesktopError('desktop.browser_preview_closed', '浏览器页面已关闭或替换,请重新打开页面后再操作。', { retryable: true });
+ return contents;
+ };
+ const state = preview => {
+ const contents = liveContents(preview);
+ return { url: contents.getURL() || preview.url, title: contents.getTitle(), loading: contents.isLoading(),
+ can_go_back: contents.navigationHistory.canGoBack(), can_go_forward: contents.navigationHistory.canGoForward() };
+ };
+ const settle = (preview, timeoutMs) => until(() => !liveContents(preview).isLoading(), timeoutMs);
+ const run = async (preview, fn, arg) => {
+ try { return await preview.webContents.executeJavaScript(page(fn, arg), true); }
+ catch (error) { throw new DesktopError('desktop.browser_script_failed', `页面脚本执行失败:${error?.message || error}`); }
+ };
+ async function locate(record, threadId, timeoutMs, url) {
+ const requested = url ? parseWebUrl(url)?.href : undefined;
+ const preview = await until(() => {
+ const found = current(record, threadId);
+ return found && !found.hidden && (!requested || found.requestedUrl === requested) ? found : undefined;
+ }, timeoutMs);
+ if (!preview) throw new DesktopError('desktop.browser_preview_missing', '右侧浏览器当前不可见:面板可能已关闭、被对话框遮挡,或窗口尺寸刚改变。', { retryable: true });
+ return preview;
+ }
+ async function click(preview, target, timeoutMs) {
+ let point = target.point, found = {};
+ if (!point) {
+ found = await run(preview, pageTarget, { ref: target.ref, selector: target.selector, text: target.text });
+ if (!found || found.missing) throw new DesktopError('desktop.browser_target_missing', '页面上找不到该元素;请重新读取 elements 获取最新 ref。');
+ point = { x: found.x, y: found.y };
+ }
+ const bounds = preview.getBounds(), x = Math.round(point.x), y = Math.round(point.y);
+ if (x < 0 || y < 0 || x >= bounds.width || y >= bounds.height) throw new DesktopError('desktop.browser_point_outside', `点击位置 (${x}, ${y}) 不在 ${bounds.width}x${bounds.height} 视口内。`);
+ const contents = preview.webContents;
+ contents.focus();
+ contents.sendInputEvent({ type: 'mouseMove', x, y });
+ contents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
+ contents.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 });
+ await sleep(400); await settle(preview, timeoutMs);
+ return { x, y, tag: found.tag, text: found.text, covered_by: found.covered_by };
+ }
+
+ return async function control(record, input) {
+ if (!input || typeof input !== 'object' || typeof input.threadId !== 'string') throw new DesktopError('desktop.browser_invalid', '浏览器操作请求无效。');
+ if (!record.scope || record.scope.closed || !activeThread(record, input.threadId)) throw new DesktopError('desktop.browser_thread_inactive', '此窗口当前没有显示该对话。', { retryable: true });
+ const timeoutMs = Math.min(Math.max(Number(input.timeoutMs) || 0, 500), 30000), started = Date.now();
+ const left = () => Math.max(200, timeoutMs - (Date.now() - started));
+ const preview = await locate(record, input.threadId, input.action === 'wait' ? timeoutMs : Math.min(timeoutMs, 5000), input.action === 'wait' ? input.url : undefined);
+ const contents = preview.webContents;
+ switch (input.action) {
+ case 'wait': {
+ await settle(preview, left());
+ if (!input.selector && !input.text) return state(preview);
+ const want = { selector: input.selector || null, text: input.text || null };
+ let met = false;
+ while (!met && !contents.isDestroyed()) {
+ const present = await run(preview, pagePresent, want).catch(() => false);
+ met = input.gone ? !present : present;
+ if (met || left() <= 200) break;
+ await sleep(200);
+ }
+ if (!met) throw new DesktopError('desktop.browser_wait_timeout', `等待${input.gone ? '消失' : '出现'}超时:${input.selector || input.text}`, { retryable: true });
+ return { ...state(preview), waited_for: input.selector || input.text, gone: Boolean(input.gone) };
+ }
+ case 'press': {
+ const target = input.ref || input.selector ? await click(preview, { ref: input.ref, selector: input.selector }, Math.min(left(), 3000)) : undefined;
+ const keyCode = KEY_CODES[input.key] || input.key;
+ const modifiers = Array.isArray(input.modifiers) ? input.modifiers : [];
+ const printable = input.key.length === 1 && !modifiers.some(item => item === 'control' || item === 'meta');
+ contents.focus();
+ for (let i = 0; i < (input.repeat || 1); i += 1) {
+ contents.sendInputEvent({ type: 'keyDown', keyCode, modifiers });
+ if (printable || keyCode === 'Enter') contents.sendInputEvent({ type: 'char', keyCode: keyCode === 'Enter' ? '\r' : input.key, modifiers });
+ contents.sendInputEvent({ type: 'keyUp', keyCode, modifiers });
+ }
+ await sleep(250); await settle(preview, left());
+ return { ...state(preview), pressed: input.key, modifiers, repeat: input.repeat || 1, focused: await run(preview, pageFocused, { caretToEnd: false }),
+ target: target ? { tag: target.tag, text: target.text } : undefined };
+ }
+ case 'scroll': {
+ let target;
+ if (input.ref || input.selector) {
+ target = await run(preview, pageTarget, { ref: input.ref, selector: input.selector });
+ if (!target || target.missing) throw new DesktopError('desktop.browser_target_missing', '页面上找不到该元素;请重新读取 elements 获取最新 ref。');
+ }
+ await sleep(150);
+ const position = await run(preview, pageScroll, { direction: input.direction, amount: input.amount, to: input.to });
+ return { ...state(preview), ...position, target: target ? { tag: target.tag, text: target.text } : undefined };
+ }
+ case 'evaluate': {
+ let result;
+ try { result = await contents.executeJavaScript(evaluateScript(input.expression), true); }
+ catch (error) { throw new DesktopError('desktop.browser_evaluate_failed', `表达式执行失败:${error?.message || error}`); }
+ if (result.json.length > EVALUATE_MAX_CHARS) throw new DesktopError('desktop.browser_evaluate_too_large', `结果有 ${result.json.length} 个字符,超过 ${EVALUATE_MAX_CHARS};请缩小表达式返回的数据。`);
+ return { ...state(preview), result_type: result.type, result: JSON.parse(result.json) };
+ }
+ case 'logs': {
+ const logs = preview.logs;
+ if (!logs) throw new DesktopError('desktop.browser_logs_unavailable', '此预览没有记录日志,请重新打开网页。');
+ let rows = logs[input.kind] || [];
+ if (input.kind === 'console' && input.level) rows = rows.filter(row => row.level === input.level);
+ if (input.kind === 'network' && input.failed_only) rows = rows.filter(row => row.failed);
+ const offset = Math.max(0, Number(input.offset) || 0), limit = Math.max(1, Number(input.limit) || 100);
+ const end = Math.min(rows.length, offset + limit);
+ return { ...state(preview), kind: input.kind, total: rows.length, dropped: logs.dropped[input.kind] || 0, offset,
+ next_offset: end < rows.length ? end : null, entries: rows.slice(offset, end) };
+ }
+ case 'navigate': {
+ const url = parseWebUrl(input.url);
+ if (!url) throw new DesktopError('desktop.browser_url_invalid', '只能打开 http:// 或 https:// 地址。');
+ const load = contents.loadURL(url.href).then(() => null, error => error);
+ const failure = await Promise.race([load, sleep(left()).then(() => null)]);
+ // ERR_ABORTED: superseded by a redirect or client-side navigation, not a failed load.
+ if (failure && failure.code !== 'ERR_ABORTED') throw new DesktopError('desktop.browser_load_failed', `网页加载失败:${failure.code || failure.message}`, { retryable: true });
+ return state(preview);
+ }
+ case 'history': {
+ const history = contents.navigationHistory;
+ if (input.direction === 'back' && history.canGoBack()) history.goBack();
+ else if (input.direction === 'forward' && history.canGoForward()) history.goForward();
+ else if (input.direction === 'reload') contents.reload();
+ else throw new DesktopError('desktop.browser_no_history', input.direction === 'back' ? '没有可后退的页面。' : '没有可前进的页面。');
+ await sleep(300); await settle(preview, left());
+ return state(preview);
+ }
+ case 'read': {
+ await settle(preview, left());
+ const result = await run(preview, pageRead, { mode: input.mode, selector: input.selector || null });
+ if (!result || result.missing) throw new DesktopError('desktop.browser_target_missing', `找不到选择器 ${input.selector} 对应的元素。`);
+ const offset = Math.max(0, Number(input.offset) || 0), limit = Math.max(1, Number(input.limit) || 1);
+ const rows = input.mode === 'elements' ? result.elements : result.content;
+ const total = rows.length, end = Math.min(total, offset + limit);
+ return { ...state(preview), mode: input.mode, offset, [input.mode === 'elements' ? 'total_elements' : 'total_chars']: total,
+ next_offset: end < total ? end : null, [input.mode === 'elements' ? 'elements' : 'content']: rows.slice(offset, end) };
+ }
+ case 'click': {
+ const clicked = await click(preview, input, left());
+ return { ...state(preview), clicked };
+ }
+ case 'type': {
+ const target = input.ref || input.selector ? await click(preview, { ref: input.ref, selector: input.selector }, Math.min(left(), 3000)) : undefined;
+ const focused = await run(preview, pageFocused, { caretToEnd: !input.clear });
+ if (!focused?.editable) throw new DesktopError('desktop.browser_not_editable', '当前焦点不是可输入的元素;请提供 ref 或 selector。');
+ contents.focus();
+ if (input.clear) { contents.selectAll(); if (!input.text) contents.delete(); }
+ if (input.text) await contents.insertText(input.text);
+ if (input.submit) {
+ contents.sendInputEvent({ type: 'keyDown', keyCode: 'Enter' });
+ contents.sendInputEvent({ type: 'char', keyCode: '\r' });
+ contents.sendInputEvent({ type: 'keyUp', keyCode: 'Enter' });
+ }
+ await sleep(300); await settle(preview, left());
+ const after = input.submit ? null : await run(preview, pageFocused, { caretToEnd: false });
+ return { ...state(preview), typed_chars: input.text.length, cleared: Boolean(input.clear), submitted: Boolean(input.submit),
+ target: target ? { tag: target.tag, text: target.text } : undefined, focused: after || focused };
+ }
+ case 'screenshot': {
+ await settle(preview, left());
+ const image = await contents.capturePage();
+ if (image.isEmpty()) throw new DesktopError('desktop.browser_capture_empty', '截图为空:右侧浏览器可能尚未绘制。', { retryable: true });
+ const bounds = preview.getBounds();
+ // CSS-pixel size keeps screenshot coordinates identical to click x/y.
+ const scaled = image.getSize().width === bounds.width ? image : image.resize({ width: bounds.width, height: bounds.height, quality: 'best' });
+ return { ...state(preview), width: bounds.width, height: bounds.height, data: scaled.toPNG().toString('base64') };
+ }
+ default:
+ throw new DesktopError('desktop.browser_action_invalid', '此浏览器操作不可用。');
+ }
+ };
+}
+
+module.exports = { createPreviewControl, pickElement, cancelPick, pageRead, pageTarget };
diff --git a/apps/desktop/src/transport.cjs b/apps/desktop/src/transport.cjs
index 064eaa3..7fd7950 100644
--- a/apps/desktop/src/transport.cjs
+++ b/apps/desktop/src/transport.cjs
@@ -11,22 +11,36 @@ class DesktopError extends Error {
// an arbitrary network destination, redirect its credentials, or proxy a URL.
const API_PATHS = [
/^\/api\/(health|readiness|workspace-kinds|engines|agent-runtimes)(\/|$)/,
- /^\/api\/auth\/(login|me|ticket)$/,
+ /^\/api\/auth\/(login|me|ticket|status|logout)$/,
/^\/api\/(threads|projects|workspaces|import|sidebar-preferences|receipts|usage)(\/|$)/,
/^\/api\/directories\/select$/,
/^\/api\/settings\/(credentials|credential-accounts|model-endpoints|workers|worker-model|worker-models|worker-image|profiles|runtime|llm|extensions|agent-extensions|conversation|notifications|capabilities|operations)(\/|$)/,
/^\/api\/(conversation|agent-runtime|capabilities|extensions|platform-extensions|operations)(\/|$)/,
/^\/api\/(agent-extensions|chat-plugins|conversation-shares|commands|queries|effects)(\/|$)/,
- /^\/api\/settings\/(agent-engines|credential-models|identity|system-login|system-update)(\/|$)/,
+ /^\/api\/settings\/(credential-models|identity|system-login|system-update|access)(\/|$)/,
/^\/api\/events\/(read|wait)$/,
];
+const METHOD_RULES = [
+ { path: '/api/runs', match: 'exact', methods: ['GET'] },
+ { path: '/api/capability-management', match: 'prefix', methods: ['GET', 'POST', 'PUT'] },
+ { path: '/api/domain-modules', match: 'prefix', methods: ['GET', 'POST'] },
+ { path: '/api/settings/ui', match: 'exact', methods: ['GET', 'PUT'] },
+];
+function pathMatches(rule, pathname) {
+ return pathname === rule.path || (rule.match === 'prefix' && pathname.startsWith(rule.path + '/'));
+}
const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
const REQUEST_HEADERS = new Set(['accept', 'authorization', 'content-type', 'last-event-id', 'idempotency-key']);
const RESPONSE_DROP = new Set(['connection', 'keep-alive', 'transfer-encoding', 'set-cookie', 'content-encoding', 'content-length']);
function allowedApiPath(pathname) {
return !/%2f|%5c/i.test(pathname) && !pathname.includes('\\')
- && API_PATHS.some(pattern => pattern.test(pathname));
+ && (API_PATHS.some(pattern => pattern.test(pathname)) || METHOD_RULES.some(rule => pathMatches(rule, pathname)));
+}
+function allowedApiRequest(pathname, method) {
+ if (!allowedApiPath(pathname) || !METHODS.has(method) || method === 'OPTIONS') return false;
+ const rule = METHOD_RULES.find(rule => pathMatches(rule, pathname));
+ return rule ? rule.methods.includes(method) : true;
}
function transportError(error) {
return { code: error.code || 'desktop.operation_failed', message: error.message,
@@ -50,7 +64,12 @@ async function probeService(value, signal) {
if (!health || typeof health !== 'object' || (!['ok', 'ready'].includes(health.status) && !(health.status === 'degraded' && health.ready === true))) {
throw new DesktopError('desktop.service_not_ready', '工作台尚未就绪,请等待后重试。', { retryable: true, source: 'service' });
}
- return { origin, health };
+ const authResponse = await fetch(`${origin}/api/auth/status`, { signal, redirect: 'error' });
+ const auth = authResponse.ok ? await authResponse.json() : null;
+ if (auth?.session_protocol !== 2 || typeof auth.service_id !== 'string' || !auth.service_id) {
+ throw new DesktopError('desktop.auth_protocol', '服务登录协议不兼容,请更新服务后重试。', { source: 'service' });
+ }
+ return { origin, health, auth };
}
async function forwardService(request, scope, onIdentity) {
@@ -58,16 +77,23 @@ async function forwardService(request, scope, onIdentity) {
if (!scope || incoming.host !== scope.host || scope.closed) {
return errorResponse(new DesktopError('desktop.connection_changed', '服务连接已改变,请在当前工作台重试。'), 409);
}
- if (!METHODS.has(request.method) || !allowedApiPath(incoming.pathname)) {
+ const preflight = request.method === 'OPTIONS';
+ const requestedMethod = preflight ? request.headers.get('Access-Control-Request-Method') : request.method;
+ if (!allowedApiPath(incoming.pathname) || (requestedMethod && !allowedApiRequest(incoming.pathname, requestedMethod)) || (!preflight && !requestedMethod)) {
return errorResponse(new DesktopError('desktop.operation_unavailable', '此服务操作不属于桌面聊天接口。'), 403);
}
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: {
'Access-Control-Allow-Origin': 'muteki-desktop://app',
- 'Access-Control-Allow-Methods': [...METHODS].join(', '),
+ 'Access-Control-Allow-Methods': [...METHODS].filter(method => allowedApiRequest(incoming.pathname, method)).join(', '),
'Access-Control-Allow-Headers': [...REQUEST_HEADERS].join(', '),
} });
const headers = new Headers();
for (const [key, value] of request.headers) if (REQUEST_HEADERS.has(key.toLowerCase())) headers.set(key, value);
+ const authSnapshot = scope.auth?.snapshot();
+ if (scope.auth) {
+ headers.delete('authorization');
+ if (authSnapshot.token) headers.set('Authorization', `Bearer ${authSnapshot.token}`);
+ }
headers.set('Accept-Encoding', 'identity');
const target = new URL(incoming.pathname + incoming.search, scope.origin);
const abort = new AbortController(); scope.requests.add(abort);
@@ -75,22 +101,48 @@ async function forwardService(request, scope, onIdentity) {
request.signal.addEventListener('abort', cancelled, { once: true });
if (request.signal.aborted) cancelled();
try {
- const body = ['GET', 'HEAD'].includes(request.method) ? undefined : request.body;
+ let body = ['GET', 'HEAD'].includes(request.method) ? undefined : request.body;
+ let remember = true;
+ if (scope.auth && incoming.pathname === '/api/auth/login' && request.method === 'POST') {
+ const text = await request.text();
+ if (text.length > 8192) throw new DesktopError('desktop.auth_input', '登录请求过大。');
+ const input = JSON.parse(text);
+ remember = input.remember !== false;
+ body = JSON.stringify({ password: input.password, remember, client: 'desktop' });
+ }
const upstream = await fetch(target, { method: request.method, headers, body,
...(body ? { duplex: 'half' } : {}), signal: abort.signal, redirect: 'manual' });
if (scope.closed) throw new DesktopError('desktop.connection_changed', '服务连接已改变,请在当前工作台重试。');
if (upstream.status >= 300 && upstream.status < 400) {
throw new DesktopError('desktop.api_redirect', '工作台 API 返回重定向;桌面没有向重定向目标投递登录身份。', { source: 'service' });
}
+ let authData;
if (upstream.ok && ['/api/auth/me', '/api/auth/login'].includes(incoming.pathname)) {
- const data = await upstream.clone().json();
- if (typeof data.service_id === 'string' && typeof data.identity_id === 'string') onIdentity(scope, data);
+ authData = await upstream.clone().json();
+ if (typeof authData.service_id === 'string' && typeof authData.identity_id === 'string') onIdentity(scope, authData);
+ if (scope.auth && !scope.closed) {
+ if (authData.service_id !== scope.auth.serviceId) throw new DesktopError('desktop.service_identity_changed', '服务身份已改变,请重新连接当前工作台。');
+ if (incoming.pathname === '/api/auth/login') {
+ if (authSnapshot.revision !== scope.auth.revision) throw new DesktopError('desktop.auth_changed', '登录已由另一个窗口更新,请重新校验。');
+ scope.auth.accept(authData, remember, scope);
+ }
+ delete authData.token;
+ authData.persistence_warning = scope.auth.persistenceWarning;
+ }
+ }
+ if (scope.auth && (upstream.status === 401 || (upstream.ok && incoming.pathname === '/api/auth/logout')
+ || (upstream.ok && incoming.pathname === '/api/settings/access' && request.method === 'PUT'))) {
+ scope.auth.clear(authSnapshot, scope);
}
if (scope.closed) throw new DesktopError('desktop.connection_changed', '服务连接已经改变,请在当前工作台重新确认身份。');
const responseHeaders = new Headers();
for (const [key, value] of upstream.headers) if (!RESPONSE_DROP.has(key.toLowerCase())) responseHeaders.set(key, value);
responseHeaders.set('Access-Control-Allow-Origin', 'muteki-desktop://app');
responseHeaders.set('X-Content-Type-Options', 'nosniff');
+ if (scope.auth && authData) {
+ scope.requests.delete(abort); request.signal.removeEventListener('abort', cancelled);
+ return Response.json(authData, { status: upstream.status, headers: responseHeaders });
+ }
// Preserve streaming and complete response bodies. Scope cancellation owns
// the underlying request until the stream closes, not just until headers.
const reader = upstream.body?.getReader();
@@ -121,4 +173,4 @@ function closeScope(scope) {
for (const controller of scope.requests) controller.abort(new DesktopError('desktop.connection_changed', '服务连接已改变。'));
scope.requests.clear();
}
-module.exports = { DesktopError, transportError, errorResponse, allowedApiPath, probeService, forwardService, closeScope };
+module.exports = { DesktopError, transportError, errorResponse, allowedApiPath, allowedApiRequest, probeService, forwardService, closeScope };
diff --git a/apps/desktop/src/update-files.cjs b/apps/desktop/src/update-files.cjs
new file mode 100644
index 0000000..8da7be0
--- /dev/null
+++ b/apps/desktop/src/update-files.cjs
@@ -0,0 +1,70 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const { createHash } = require('node:crypto');
+const { execFileSync } = require('node:child_process');
+
+function bundleHash(root) {
+ const hash = createHash('sha256');
+ const walk = directory => {
+ for (const name of fs.readdirSync(directory).sort()) {
+ const file = path.join(directory, name), relative = path.relative(root, file), stat = fs.lstatSync(file);
+ hash.update(relative + '\0' + (stat.mode & 0o777).toString(8) + '\0');
+ if (stat.isSymbolicLink()) {
+ const target = fs.readlinkSync(file);
+ if (!fs.realpathSync(file).startsWith(fs.realpathSync(root) + path.sep)) throw new Error(`Bundle symlink escapes its root: ${relative}`);
+ hash.update('link\0' + target + '\0');
+ } else if (stat.isDirectory()) walk(file);
+ else if (stat.isFile()) {
+ const content = createHash('sha256'), fd = fs.openSync(file, 'r'), buffer = Buffer.alloc(1024 * 1024);
+ try { for (;;) { const length = fs.readSync(fd, buffer); if (!length) break; content.update(buffer.subarray(0, length)); } }
+ finally { fs.closeSync(fd); }
+ hash.update(content.digest());
+ } else throw new Error(`Unsupported bundle entry: ${relative}`);
+ }
+ };
+ walk(root); return hash.digest('hex');
+}
+function bundleIdentity(root) {
+ if (process.platform !== 'darwin' || !root.endsWith('.app')) throw new Error('Local candidate installation currently requires a macOS .app bundle');
+ execFileSync('/usr/bin/codesign', ['--verify', '--deep', '--strict', root], {stdio: ['ignore', 'pipe', 'pipe']});
+ const plist = key => execFileSync('/usr/libexec/PlistBuddy', ['-c', `Print :${key}`, path.join(root, 'Contents/Info.plist')], {encoding: 'utf8'}).trim();
+ const { spawnSync } = require('node:child_process');
+ const signed = spawnSync('/usr/bin/codesign', ['-dv', '--verbose=4', root], {encoding: 'utf8'});
+ if (signed.status !== 0) throw new Error(signed.stderr);
+ const team = signed.stderr.match(/^TeamIdentifier=(.+)$/m)?.[1];
+ if (!team || team === 'not set') throw new Error('Candidate must be signed with a stable signing identity');
+ const executable = plist('CFBundleExecutable');
+ if (path.basename(executable) !== executable) throw new Error('Invalid bundle executable');
+ return { id: plist('CFBundleIdentifier'), version: plist('CFBundleShortVersionString'), executable, team };
+}
+function copyBundle(source, destination) {
+ execFileSync('/usr/bin/ditto', ['--rsrc', '--extattr', source, destination], {stdio: ['ignore', 'pipe', 'pipe']});
+}
+function atomicJson(file, value) {
+ const temporary = file + '.tmp';
+ fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', {mode: 0o600});
+ const fd = fs.openSync(temporary, 'r'); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
+ fs.renameSync(temporary, file);
+ const directory = fs.openSync(path.dirname(file), 'r');
+ try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
+}
+function helperRunning(journal) {
+ if (!Number.isInteger(journal.helperPid)) return false;
+ try { process.kill(journal.helperPid, 0); return true; }
+ catch (error) { if (error.code === 'ESRCH') return false; throw error; }
+}
+function unfinishedUpdate(root) {
+ const directory = path.join(root, 'updates');
+ if (!fs.existsSync(directory)) return null;
+ const pending = fs.readdirSync(directory).flatMap(name => {
+ const file = path.join(directory, name, 'transaction.json');
+ if (!fs.existsSync(file)) return [];
+ const journal = JSON.parse(fs.readFileSync(file, 'utf8'));
+ if (['prepared', 'complete', 'rolled-back'].includes(journal.phase)) return [];
+ if (journal.environmentRoot !== root || journal.id !== name) throw new Error('Invalid pending update identity');
+ return [{file, journal}];
+ });
+ if (pending.length > 1) throw new Error('Multiple unfinished updates require recovery before this workspace can start');
+ return pending[0] || null;
+}
+module.exports = { bundleHash, bundleIdentity, copyBundle, atomicJson, helperRunning, unfinishedUpdate };
diff --git a/apps/desktop/src/update-runner.cjs b/apps/desktop/src/update-runner.cjs
new file mode 100644
index 0000000..f8818bd
--- /dev/null
+++ b/apps/desktop/src/update-runner.cjs
@@ -0,0 +1,93 @@
+// Runs from a private copy outside the replaced .app and waits for its owner.
+const fs = require('node:fs');
+const path = require('node:path');
+const { spawn } = require('node:child_process');
+const { once } = require('node:events');
+const { bundleHash, bundleIdentity, copyBundle, atomicJson, helperRunning } = require('./update-files.cjs');
+const dataDirectories = ['state', 'sessions', 'desktop', 'home', 'config'];
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+async function run(file, recover = false) {
+ let journal = JSON.parse(fs.readFileSync(file, 'utf8'));
+ if (helperRunning(journal) && journal.helperPid !== process.pid) throw new Error('Another update coordinator is still running');
+ const write = patch => { journal = {...journal, ...patch}; atomicJson(file, journal); };
+ const transaction = path.dirname(file), root = journal.environmentRoot;
+ const previous = journal.current + `.previous-${journal.id}.app`;
+ const incoming = journal.current + `.incoming-${journal.id}.app`;
+ const backup = path.join(transaction, 'data-backup');
+ let switched = fs.existsSync(previous), backedUp = Boolean(journal.backupComplete), next;
+ const env = {...process.env};
+ for (const key of Object.keys(env)) if (key.startsWith('MUTEKI_') || ['NODE_OPTIONS', 'NODE_PATH', 'ELECTRON_RUN_AS_NODE', 'PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV'].includes(key)) delete env[key];
+ write({helperPid: process.pid});
+ try {
+ if (recover) throw new Error('Recovered an interrupted update before workspace writes were admitted');
+ if (!['prepared', 'waiting-for-owner'].includes(journal.phase)) throw new Error('Update transaction is not prepared');
+ const identity = bundleIdentity(journal.candidate);
+ if (identity.id !== journal.identity.id || identity.team !== journal.identity.team || bundleHash(journal.candidate) !== journal.sha256) throw new Error('Candidate changed after acceptance');
+ if (bundleHash(journal.current) !== journal.previousSha256) throw new Error('Installed application changed during update preparation');
+ fs.mkdirSync(backup, {mode: 0o700});
+ write({phase: 'backing-up'});
+ for (const directory of dataDirectories) {
+ const source = path.join(root, directory);
+ if (fs.existsSync(source)) fs.cpSync(source, path.join(backup, directory), {recursive: true, verbatimSymlinks: true});
+ }
+ backedUp = true;
+ write({phase: 'backed-up', backupComplete: true});
+ copyBundle(journal.candidate, incoming);
+ if (bundleHash(incoming) !== journal.sha256) throw new Error('Installed copy does not match the accepted candidate');
+ write({phase: 'switching', previous});
+ fs.renameSync(journal.current, previous);
+ try { fs.renameSync(incoming, journal.current); switched = true; }
+ catch (error) { fs.renameSync(previous, journal.current); throw error; }
+ write({phase: 'validating', previous});
+ next = spawn(path.join(journal.current, 'Contents/MacOS', identity.executable), [`--muteki-environment-root=${root}`, `--muteki-update-journal=${file}`], {env, detached: true, stdio: 'ignore'});
+ await once(next, 'spawn'); next.unref();
+ const deadline = Date.now() + 150000;
+ while (Date.now() < deadline) {
+ journal = JSON.parse(fs.readFileSync(file, 'utf8'));
+ if (journal.phase === 'validated') {
+ // Recovery and external side effects may begin after this durable point.
+ write({phase: 'writes-open', activatedAt: new Date().toISOString()});
+ } else if (journal.phase === 'complete') return;
+ else if (journal.phase === 'failed') throw new Error(journal.error || 'New application failed validation');
+ if (next.exitCode !== null || next.signalCode !== null) throw new Error('New application stopped during validation');
+ await sleep(250);
+ }
+ throw new Error('New application did not complete startup in time');
+ } catch (error) {
+ const latest = JSON.parse(fs.readFileSync(file, 'utf8'));
+ if (latest.activatedAt) {
+ atomicJson(file, {...latest, phase: 'repair-required', error: error.message});
+ return; // Never restore an old snapshot after writes were admitted.
+ }
+ if (next && next.exitCode === null && next.signalCode === null) {
+ const exited = once(next, 'exit'); next.kill('SIGTERM');
+ await Promise.race([exited, sleep(10000)]);
+ if (next.exitCode === null && next.signalCode === null) { next.kill('SIGKILL'); await exited; }
+ }
+ // The private ownership pipes close with the failed app. Wait until its
+ // backend releases its writer lock before touching any database files.
+ if (switched) {
+ const runtimeRoot = path.join(journal.candidate, 'Contents/Resources/runtime');
+ const executable = path.join(runtimeRoot, JSON.parse(fs.readFileSync(path.join(runtimeRoot, 'manifest.json'), 'utf8')).python);
+ const check = spawn(executable, ['-I', '-B', '-c', 'import fcntl,sys; f=open(sys.argv[1],"a+b"); fcntl.flock(f,fcntl.LOCK_EX)', path.join(root, 'backend.lock')], {stdio: 'ignore'});
+ const timer = setTimeout(() => check.kill('SIGKILL'), 30000);
+ const [code] = await once(check, 'exit'); clearTimeout(timer);
+ if (code !== 0) { write({phase: 'recovery-required', error: `${error.message}; backend writer lock remains held`}); return; }
+ if (fs.existsSync(journal.current)) fs.rmSync(journal.current, {recursive: true});
+ fs.renameSync(previous, journal.current);
+ }
+ if (backedUp) for (const directory of dataDirectories) {
+ const live = path.join(root, directory), saved = path.join(backup, directory);
+ if (fs.existsSync(live)) fs.rmSync(live, {recursive: true});
+ if (fs.existsSync(saved)) fs.cpSync(saved, live, {recursive: true, verbatimSymlinks: true});
+ }
+ write({phase: 'rolled-back', error: error.message});
+ spawn(path.join(journal.current, 'Contents/MacOS', journal.identity.executable), [`--muteki-environment-root=${root}`], {env, detached: true, stdio: 'ignore'}).unref();
+ }
+}
+if (require.main === module) {
+ process.stdin.resume();
+ process.stdin.once('end', () => run(process.argv[2], process.argv.includes('--recover')).catch(error => { console.error(error); process.exitCode = 1; }));
+}
+module.exports = {run};
diff --git a/apps/desktop/src/updates.cjs b/apps/desktop/src/updates.cjs
new file mode 100644
index 0000000..0a0ecea
--- /dev/null
+++ b/apps/desktop/src/updates.cjs
@@ -0,0 +1,60 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const { randomUUID } = require('node:crypto');
+const { spawn } = require('node:child_process');
+const { once } = require('node:events');
+const { bundleHash, bundleIdentity, copyBundle, atomicJson } = require('./update-files.cjs');
+
+class DesktopUpdates {
+ constructor(app, environment) {
+ this.app = app; this.environment = environment; this.prepared = null;
+ this.current = path.resolve(app.getPath('exe'), '../../..');
+ }
+ async prepare(source) {
+ if (!this.app.isPackaged || process.platform !== 'darwin' || this.environment.channel !== 'stable') throw new Error('请在已安装的 macOS 日常版中应用候选更新。');
+ source = fs.realpathSync(source);
+ if (source === this.current) throw new Error('请选择新构建的候选应用。');
+ const identity = bundleIdentity(this.current), candidateIdentity = bundleIdentity(source);
+ if (identity.id !== candidateIdentity.id || identity.team !== candidateIdentity.team) throw new Error('候选版本的应用身份或签名团队与日常版不同。');
+ const id = randomUUID(), transaction = path.join(this.environment.root, 'updates', id);
+ fs.mkdirSync(transaction, {recursive: true, mode: 0o700});
+ const candidate = path.join(transaction, 'candidate.app');
+ copyBundle(source, candidate);
+ bundleIdentity(candidate);
+ const sha256 = bundleHash(candidate);
+ if (sha256 !== bundleHash(source)) throw new Error('候选应用在准备过程中发生变化,请重新构建。');
+ const profile = path.join(transaction, 'acceptance');
+ const log = fs.openSync(path.join(transaction, 'acceptance.log'), 'a', 0o600);
+ const env = {...process.env, MUTEKI_DESKTOP_USER_DATA: path.join(profile, 'desktop'), MUTEKI_DESKTOP_REGISTER_PROTOCOL: '0'};
+ for (const key of ['MUTEKI_DESKTOP_URL', 'MUTEKI_ENVIRONMENT_ROOT', 'MUTEKI_DESKTOP_WORKSPACE', 'NODE_OPTIONS', 'NODE_PATH', 'ELECTRON_RUN_AS_NODE', 'PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV']) delete env[key];
+ const child = spawn(path.join(candidate, 'Contents/MacOS', candidateIdentity.executable), ['--muteki-channel=candidate', `--muteki-environment-root=${profile}`, '--muteki-self-check'], {env, stdio: ['ignore', log, log]});
+ fs.closeSync(log);
+ const timer = setTimeout(() => child.kill('SIGTERM'), 150000);
+ let code;
+ try { [code] = await once(child, 'exit'); } finally { clearTimeout(timer); }
+ const resultFile = path.join(profile, 'candidate-result.json');
+ if (code !== 0 || !fs.existsSync(resultFile) || JSON.parse(fs.readFileSync(resultFile, 'utf8')).status !== 'passed') throw new Error(`候选版本验收失败,请查看 ${path.join(transaction, 'acceptance.log')}`);
+ if (bundleHash(candidate) !== sha256) throw new Error('候选版本运行时修改了应用包,不能安装。');
+ const file = path.join(transaction, 'transaction.json');
+ const runtime = JSON.parse(fs.readFileSync(path.join(process.resourcesPath, 'runtime/manifest.json'), 'utf8'));
+ fs.copyFileSync(path.join(process.resourcesPath, 'runtime', runtime.node), path.join(transaction, 'node'));
+ fs.chmodSync(path.join(transaction, 'node'), 0o700);
+ for (const name of ['update-runner.cjs', 'update-files.cjs']) fs.copyFileSync(path.join(__dirname, name), path.join(transaction, name));
+ atomicJson(file, {version: 1, id, phase: 'prepared', current: this.current, candidate, identity, candidateIdentity, sha256, previousSha256: bundleHash(this.current), environmentRoot: this.environment.root});
+ this.prepared = {file, transaction, version: candidateIdentity.version};
+ return this.prepared;
+ }
+ async launch(recover = false) {
+ if (!this.prepared) throw new Error('No accepted candidate is prepared');
+ const {file, transaction} = this.prepared;
+ const log = fs.openSync(path.join(transaction, 'install.log'), 'a', 0o600);
+ const env = {...process.env}; delete env.NODE_OPTIONS; delete env.NODE_PATH;
+ const child = spawn(path.join(transaction, 'node'), [path.join(transaction, 'update-runner.cjs'), file, ...(recover ? ['--recover'] : [])], {env, detached: true, stdio: ['pipe', log, log]});
+ fs.closeSync(log); await once(child, 'spawn'); child.unref();
+ const journal = JSON.parse(fs.readFileSync(file, 'utf8'));
+ atomicJson(file, {...journal, helperPid: child.pid, ...(!recover ? {phase: 'waiting-for-owner'} : {})});
+ // The app owns this pipe until it exits. The helper cannot replace a live bundle.
+ this.helper = child;
+ }
+}
+module.exports = { DesktopUpdates };
diff --git a/apps/desktop/test/chrome.test.cjs b/apps/desktop/test/chrome.test.cjs
index 2bcfda5..91fb3fc 100644
--- a/apps/desktop/test/chrome.test.cjs
+++ b/apps/desktop/test/chrome.test.cjs
@@ -3,8 +3,8 @@ const assert = require('node:assert/strict');
const { normalizeDesktopRoute } = require('../src/policy.cjs');
const { forwardService, closeScope } = require('../src/transport.cjs');
-test('chat and settings routes preserve their own path and query', () => {
- for (const route of ['/chat/example?panel=files', '/settings/appearance', '/settings/agent-extensions', '/settings/notifications']) assert.equal(normalizeDesktopRoute(route), route);
+test('chat, run and settings routes preserve their own path and query', () => {
+ for (const route of ['/chat/example?panel=files', '/run/run-123?panel=evidence', '/settings/appearance', '/settings/agent-extensions', '/settings/notifications']) assert.equal(normalizeDesktopRoute(route), route);
assert.equal(normalizeDesktopRoute('muteki-desktop://app/chat/example'), '/chat/example');
});
@@ -23,3 +23,67 @@ test('native service transport preserves complete streamed response bodies', asy
assert.equal(scope.requests.size, 0);
} finally { closeScope(scope); global.fetch = originalFetch; }
});
+
+test('native login owns bearer sessions and strips them from renderer responses', async () => {
+ const originalFetch = global.fetch;
+ let stored = '', forwarded = '';
+ const auth = {
+ serviceId: 'qa-service', revision: 0, persistenceWarning: '',
+ snapshot() { return { token: stored, revision: this.revision }; },
+ accept(data) { stored = data.token; this.revision += 1; },
+ clear(snapshot) { if (snapshot.revision === this.revision) stored = ''; },
+ };
+ const scope = { origin: 'http://127.0.0.1:18193', host: 'service-private', requests: new Set(), closed: false, auth };
+ global.fetch = async (_target, init) => {
+ forwarded = init.headers.get('Authorization');
+ return Response.json({ ok: true, auth_required: true, service_id: 'qa-service', identity_id: 'operator',
+ session_protocol: 2, session_owner: 'desktop', token: 'synthetic-private-session', expires_at: 9999999999 });
+ };
+ try {
+ const response = await forwardService(new Request('muteki-desktop://service-private/api/auth/login', {
+ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer renderer-token' },
+ body: JSON.stringify({ password: 'synthetic-password', client: 'web' }),
+ }), scope, () => {});
+ const data = await response.json();
+ assert.equal(data.session_owner, 'desktop'); assert.equal('token' in data, false);
+ assert.equal(stored, 'synthetic-private-session'); assert.equal(forwarded, null);
+ assert.equal(scope.requests.size, 0);
+ } finally { closeScope(scope); global.fetch = originalFetch; }
+});
+
+test('a late unauthorized response cannot clear a newer native session', async () => {
+ const originalFetch = global.fetch;
+ let token = 'old-session', revision = 1;
+ const auth = { snapshot: () => ({ token, revision }), clear(snapshot) { if (snapshot.revision === revision && snapshot.token === token) token = ''; } };
+ const scope = { origin: 'http://127.0.0.1:18193', host: 'service-race', requests: new Set(), closed: false, auth };
+ global.fetch = async () => { token = 'new-session'; revision += 1; return Response.json({ error: 'unauthorized' }, { status: 401 }); };
+ try {
+ const response = await forwardService(new Request('muteki-desktop://service-race/api/threads'), scope, () => {});
+ await response.text(); assert.equal(token, 'new-session');
+ } finally { closeScope(scope); global.fetch = originalFetch; }
+});
+
+test('native remembered sessions are encrypted, installation-bound and removable', () => {
+ const fs = require('node:fs'), os = require('node:os'), path = require('node:path'), crypto = require('node:crypto');
+ const { ServiceAuthSessions } = require('../src/auth.cjs');
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'muteki-native-auth-'));
+ const file = path.join(root, 'sessions.json'), key = crypto.randomBytes(32);
+ // An encrypting fixture exercises the store's boundary, not Electron's OS keychain.
+ const storage = { isEncryptionAvailable: () => true, getSelectedStorageBackend: () => 'gnome_libsecret',
+ encryptString(value) { const iv = crypto.randomBytes(12), cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const body = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); return Buffer.concat([iv, cipher.getAuthTag(), body]); },
+ decryptString(value) { const cipher = crypto.createDecipheriv('aes-256-gcm', key, value.subarray(0, 12)); cipher.setAuthTag(value.subarray(12, 28)); return Buffer.concat([cipher.update(value.subarray(28)), cipher.final()]).toString('utf8'); } };
+ try {
+ const sessions = new ServiceAuthSessions(file, storage), owner = sessions.forService('http://127.0.0.1:18193', 'installation-a');
+ const data = { session_protocol: 2, session_owner: 'desktop', service_id: 'installation-a', auth_required: true, token: 'synthetic-private-token', expires_at: Date.now() / 1000 + 3600 };
+ owner.accept(data, true);
+ assert.equal(fs.readFileSync(file, 'utf8').includes(data.token), false);
+ assert.equal(fs.statSync(file).mode & 0o777, 0o600);
+ const restored = new ServiceAuthSessions(file, storage);
+ assert.equal(restored.forService(owner.origin, owner.serviceId).snapshot().token, data.token);
+ assert.equal(restored.forService(owner.origin, 'installation-b').snapshot().token, '');
+ const old = owner.snapshot(); owner.accept({ ...data, token: 'new-private-token' }, false);
+ owner.clear(old); assert.equal(owner.snapshot().token, 'new-private-token');
+ assert.equal(new ServiceAuthSessions(file, storage).forService(owner.origin, owner.serviceId).snapshot().token, '');
+ owner.clear(owner.snapshot()); assert.equal(owner.snapshot().token, '');
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
+});
diff --git a/apps/desktop/test/fixture.cjs b/apps/desktop/test/fixture.cjs
index f79b8d7..64e4675 100644
--- a/apps/desktop/test/fixture.cjs
+++ b/apps/desktop/test/fixture.cjs
@@ -13,8 +13,9 @@ function createFixture() {
}
const bodies = {
'/api/health': { status: 'ready', ready: true },
- '/api/auth/me': { authenticated: true, auth_required: false, service_id: 'desktop-smoke', identity_id: 'operator', in_container: false },
- '/api/auth/ticket': { ticket: '' },
+ '/api/auth/status': { session_protocol: 2, auth_required: false, service_id: 'desktop-smoke', identity_id: 'operator' },
+ '/api/auth/me': { session_protocol: 2, authenticated: true, auth_required: false, service_id: 'desktop-smoke', identity_id: 'operator', in_container: false },
+ '/api/auth/ticket': { ticket: 'desktop-smoke-ticket' },
'/api/threads': { threads: [] }, '/api/projects': { projects: [] },
'/api/agent-runtimes': { runtimes: [] }, '/api/sidebar-preferences': { pinned_ids: [], project_order: [] },
'/api/settings/credentials': { credentials: [] },
diff --git a/apps/desktop/test/policy.test.cjs b/apps/desktop/test/policy.test.cjs
index 47beddc..5d56cb3 100644
--- a/apps/desktop/test/policy.test.cjs
+++ b/apps/desktop/test/policy.test.cjs
@@ -60,3 +60,68 @@ test('validates encoded chat routes before they reach the renderer', () => {
assert.throws(() => normalizeDesktopRoute(route), error => error.code === 'desktop.route_invalid');
}
});
+
+test('shared settings declarations are covered by desktop path and method policy', () => {
+ const registry = require('../../web/ui/components/settings/registry.json');
+ const { allowedApiPath, allowedApiRequest } = require('../src/transport.cjs');
+ assert.equal(registry.defaultPage, 'appearance');
+ for (const page of Object.values(registry.pages)) for (const operation of page.requiredApis) {
+ const paths = operation.match === 'prefix' ? [operation.path, operation.path + '/fixture'] : [operation.path];
+ for (const target of paths) {
+ assert.equal(allowedApiPath(target), true, target);
+ for (const method of operation.methods) assert.equal(allowedApiRequest(target, method), true, `${page.id}: ${method} ${target}`);
+ }
+ }
+ assert.equal(allowedApiRequest('/api/runs', 'GET'), true);
+ for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) assert.equal(allowedApiRequest('/api/runs', method), false);
+ for (const target of ['/api/runs/run-example', '/api/runs/run-example/control', '/api/runs-extra', '/api/domain-modules-extra', '/api/capability-management-extra']) {
+ for (const method of ['GET', 'POST', 'DELETE']) assert.equal(allowedApiRequest(target, method), false, `${method} ${target}`);
+ }
+});
+
+test('anchor targets are transient while path and query remain durable', () => {
+ const { desktopNavigationTarget } = require('../src/policy.cjs');
+ for (const route of ['/settings/agents?engine=codex#setting-usage-cursor-account', '/usage#limits', '/ctf#progress']) {
+ const { pathname, search, hash } = new URL(route, 'muteki-desktop://app');
+ assert.deepEqual(desktopNavigationTarget(route), {route: pathname + search, hash});
+ assert.equal(normalizeDesktopRoute(route), pathname + search);
+ }
+ assert.throws(() => desktopNavigationTarget('https://other.example/settings/agents#setting-agents-models'));
+});
+
+test('preflight does not authorize a runs mutation', async () => {
+ const { forwardService } = require('../src/transport.cjs');
+ const scope = {origin: 'http://127.0.0.1:18193', host: 'service-policy', requests: new Set(), closed: false};
+ const preflight = method => forwardService(new Request('muteki-desktop://service-policy/api/runs', {
+ method: 'OPTIONS', headers: {'Access-Control-Request-Method': method},
+ }), scope, () => {});
+ const read = await preflight('GET');
+ assert.equal(read.status, 204);
+ assert.equal(read.headers.get('Access-Control-Allow-Methods'), 'GET');
+ assert.equal((await preflight('DELETE')).status, 403);
+});
+
+test('desktop environment identity persists and runtime inheritance is explicit', () => {
+ const { desktopEnvironment, serviceEnvironment } = require('../src/environment.cjs');
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'muteki-environment-'));
+ try {
+ const options = {appData: root, root: path.join(root, 'stable'), packaged: true, channel: 'stable'};
+ const first = desktopEnvironment(options), second = desktopEnvironment(options);
+ assert.equal(first.id, second.id);
+ assert.notEqual(first.generation, second.generation);
+ assert.throws(() => desktopEnvironment({...options, channel: 'candidate'}));
+ const env = serviceEnvironment(first, {PYTHONPATH: '/checkout', NODE_OPTIONS: '--inspect', MUTEKI_STATE_ROOT: '/wrong', OPENAI_API_KEY: 'fixture', LANG: 'en_US.UTF-8'});
+ for (const name of ['PYTHONPATH', 'NODE_OPTIONS', 'OPENAI_API_KEY']) assert.equal(env[name], undefined);
+ assert.equal(env.MUTEKI_STATE_ROOT, first.paths.state);
+ assert.equal(env.MUTEKI_HOST_DISCOVERY, '0');
+ assert.equal(env.HOME, first.paths.home);
+ assert.equal(env.LANG, 'en_US.UTF-8');
+ } finally { fs.rmSync(root, {recursive: true, force: true}); }
+});
+
+test('managed partitions follow environment identity across port changes', () => {
+ const { partitionFor } = require('../src/policy.cjs');
+ assert.equal(partitionFor('http://127.0.0.1:41001', 'stable-id'), partitionFor('http://127.0.0.1:42001', 'stable-id'));
+ assert.notEqual(partitionFor('http://127.0.0.1:41001', 'stable-id'), partitionFor('http://127.0.0.1:41001', 'dev-id'));
+ assert.notEqual(partitionFor('http://127.0.0.1:41001'), partitionFor('http://127.0.0.1:42001'));
+});
diff --git a/apps/desktop/test/smoke.cjs b/apps/desktop/test/smoke.cjs
index 9646de7..d7755c8 100644
--- a/apps/desktop/test/smoke.cjs
+++ b/apps/desktop/test/smoke.cjs
@@ -29,7 +29,9 @@ const { createFixture } = require('./fixture.cjs');
assert.equal(result.preferences.sandbox, true);
assert.equal(result.preferences.contextIsolation, true);
assert.equal(result.preferences.nodeIntegration, false);
- assert.ok(result.preferences.preload);
+ // Electron 44 no longer includes `preload` in getLastWebPreferences.
+ // Verify the actual contextBridge installed by our preload instead.
+ assert.equal(await page.evaluate(() => typeof window.mutekiDesktop?.getState), 'function');
assert.equal(result.views, 0);
assert.equal(result.remoteChat, false);
assert.deepEqual(errors, []);
diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json
index 1cd8b63..b9d5422 100644
--- a/apps/desktop/tsconfig.json
+++ b/apps/desktop/tsconfig.json
@@ -21,7 +21,8 @@
"@/*": [
"../web/ui/*"
]
- }
+ },
+ "resolveJsonModule": true
},
"include": [
"renderer",
diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts
index f5aee2c..9299ca0 100644
--- a/apps/desktop/vite.config.ts
+++ b/apps/desktop/vite.config.ts
@@ -16,7 +16,10 @@ function packageRoot(name: string) {
export default defineConfig({
root: 'renderer',
base: '/',
- plugins: [react(), {
+ plugins: [react(), {name: 'desktop-no-next-runtime', resolveId(source) {
+ if (source === 'next' || source.startsWith('next/')) throw new Error(`Shared desktop code cannot import ${source}; use the host navigation adapter.`);
+ return null;
+ }}, {
name: 'desktop-shared-dependencies',
async resolveId(source, importer) {
const name = source.startsWith('@') ? source.split('/').slice(0, 2).join('/') : source.split('/')[0];
@@ -27,7 +30,7 @@ export default defineConfig({
},
}],
publicDir: path.join(ui, 'public'),
- define: { 'process.env.NEXT_PUBLIC_MUTEKI_API': JSON.stringify(''), 'process.env.NODE_ENV': JSON.stringify('production') },
+ define: { 'process.env.NEXT_PUBLIC_MUTEKI_UI_BUILD': JSON.stringify(require('../web/ui/build-info.cjs').uiBuildId()), 'process.env.NEXT_PUBLIC_MUTEKI_API': JSON.stringify(''), 'process.env.NODE_ENV': JSON.stringify('production') },
resolve: { alias: [{ find: '@', replacement: ui }, { find: 'react', replacement: packageRoot('react') }, { find: 'react-dom', replacement: packageRoot('react-dom') }], dedupe: ['react', 'react-dom'] },
css: { postcss: { plugins: [require('@tailwindcss/postcss')({ base: path.resolve('.') })] } },
build: { outDir: '../renderer-dist', emptyOutDir: true, target: 'chrome140' },
diff --git a/apps/web/agent_runtime_api.py b/apps/web/agent_runtime_api.py
index 272bf38..9a7c73e 100644
--- a/apps/web/agent_runtime_api.py
+++ b/apps/web/agent_runtime_api.py
@@ -26,8 +26,8 @@
import asyncio
import json
+import logging
import threading
-import time
import tempfile
from dataclasses import dataclass, field, replace
from pathlib import Path
@@ -60,9 +60,36 @@
DEFAULT_STRUCTURED_ADAPTER_BY_ENGINE,
canonical_adapter_id as _canonical_adapter_id,
engine_for_adapter,
+ normalize_launch_args,
runtime_config_schema,
)
-from muteki.platform.contracts.external_agents import ACCESS_MODE_VALUES, ProbeRequest
+from muteki.platform.contracts.external_agents import ProbeRequest
+from muteki.external_agents.capabilities import (
+ CapabilityProbeReport,
+ describe_injection_path,
+)
+from muteki.external_agents.probe_cache import (
+ BinaryIdentity,
+ ProbeCache,
+ ProbeEntry,
+ ProbeKey,
+ config_hash,
+ credential_scope,
+ driver_binary,
+ probe_cache,
+)
+from muteki.external_agents.descriptors import (
+ DESCRIPTOR_SCHEMA_VERSION,
+ UnknownProviderError,
+ all_descriptors,
+ descriptor_for_adapter,
+ find_adapter_descriptor,
+ find_descriptor,
+ find_descriptor_for_adapter,
+ get_descriptor,
+ public_descriptor,
+ resolve_capabilities,
+)
from muteki.solver.worker_profiles import (
VALID_BASE_ENGINES,
normalize_worker_profile,
@@ -101,50 +128,36 @@ def canonical_adapter_id(adapter_id: str) -> str:
def engine_of_adapter(adapter_id: str) -> str:
- """adapter_id → 九类基础引擎名;无法识别时返回空串。"""
- engine = engine_for_adapter(adapter_id)
- return engine if engine in {*VALID_BASE_ENGINES, "devin"} else ""
+ """adapter_id → 有 Provider Descriptor 的基础引擎名;无法识别时返回空串。"""
+ descriptor = find_descriptor(engine_for_adapter(adapter_id))
+ return descriptor.engine if descriptor is not None else ""
+
+
+def _host_login_only(adapter_id: str) -> bool:
+ descriptor = find_descriptor(engine_of_adapter(adapter_id))
+ return descriptor is not None and descriptor.login.system_login_only
+
+
+def _require_scoped_model_catalog_probe(adapter_id: str) -> None:
+ adapter = find_adapter_descriptor(adapter_id)
+ if adapter is None or not adapter.scoped_model_catalog_probe:
+ supported = ", ".join(
+ item.adapter_id for descriptor in all_descriptors()
+ for item in descriptor.adapters if item.scoped_model_catalog_probe
+ )
+ raise ValueError(f"Scoped model catalog probing is supported by {supported}")
+
+_LOG = logging.getLogger(__name__)
-#: 九类 Runtime 的登录指引(任务书 3.1.6:缺失/未认证显示准确诊断与登录命令)。
-#: 与 muteki.solver.credential_accounts.detect_system_login 的检测口径一一对应。
+#: Runtime 登录指引(任务书 3.1.6:缺失/未认证显示准确诊断与登录命令),来自
+#: Provider Descriptor;与 credential_accounts.detect_system_login 的检测口径一致。
LOGIN_GUIDANCE: dict[str, dict[str, str]] = {
- "devin": {
- "command": "devin auth login",
- "note": "聊天使用本机 Devin CLI 登录;也支持 WINDSURF_API_KEY。",
- },
- "claude": {
- "command": "claude(进入后执行 /login)",
- "note": "macOS 登录态保存在 Keychain;也可在统一凭据中心导入宿主登录。",
- },
- "codex": {
- "command": "codex login",
- "note": "登录后可在统一凭据中心从宿主 ~/.codex/auth.json 导入。",
- },
- "cursor": {
- "command": "cursor-agent login 或设置 CURSOR_API_KEY",
- "note": "headless 模式只读取 CURSOR_API_KEY。",
- },
- "pi": {
- "command": "pi(完成登录后写入 ~/.pi/agent)",
- "note": "",
- },
- "omp": {
- "command": "omp(完成登录后写入 ~/.omp/agent)",
- "note": "",
- },
- "kimi": {
- "command": "kimi(进入后执行 /login,写入 ~/.kimi-code)",
- "note": "",
- },
- "grok": {
- "command": "grok login",
- "note": "",
- },
- "opencode": {
- "command": "opencode auth login",
- "note": "",
- },
+ descriptor.engine: {
+ "command": descriptor.login.guidance_command,
+ "note": descriptor.login.guidance_note,
+ }
+ for descriptor in all_descriptors()
}
#: 环境引用值的合法前缀:只允许引用,不允许内联真实值。
@@ -244,6 +257,7 @@ class RuntimeInstanceConfig:
default_model: str = ""
enabled: bool = True
transport: dict[str, Any] = field(default_factory=dict)
+ launch_args: list[str] = field(default_factory=list)
created_at: str = ""
updated_at: str = ""
@@ -273,6 +287,7 @@ def to_dict(self) -> dict[str, Any]:
"env_refs": dict(self.env_refs),
"enabled": self.enabled,
"transport": dict(self.transport),
+ "launch_args": list(self.launch_args),
"config_schema": runtime_config_schema(self.adapter_id),
"created_at": self.created_at,
"updated_at": self.updated_at,
@@ -333,6 +348,7 @@ def _pick(name: str, default: Any = "") -> Any:
default_model=default_model,
enabled=bool(_pick("enabled", True)),
transport=dict(_pick("transport", {}) or {}),
+ launch_args=list(normalize_launch_args(_pick("launch_args", []), adapter_id)),
created_at=str(base.get("created_at") or now),
updated_at=now,
)
@@ -351,6 +367,7 @@ def from_dict(cls, data: dict[str, Any]) -> "RuntimeInstanceConfig":
default_model=str(data.get("default_model") or ""),
enabled=bool(data.get("enabled", True)),
transport=dict(data.get("transport") or {}),
+ launch_args=[str(item) for item in data.get("launch_args") or []],
created_at=str(data.get("created_at") or ""),
updated_at=str(data.get("updated_at") or ""),
)
@@ -376,14 +393,18 @@ def _load(self) -> None:
return
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
- except (json.JSONDecodeError, OSError):
+ except (json.JSONDecodeError, OSError) as exc:
+ _LOG.error("runtime instance store %s unreadable: %s: %s",
+ self.path, type(exc).__name__, exc)
return
for item in raw.get("instances") or []:
if not isinstance(item, dict):
continue
try:
cfg = RuntimeInstanceConfig.from_dict(item)
- except Exception: # noqa: BLE001 — 单条损坏不拖垮其余
+ except Exception as exc: # noqa: BLE001 — 单条损坏不拖垮其余
+ _LOG.error("runtime instance store %s: skipped invalid instance %r: %s: %s",
+ self.path, item.get("adapter_id"), type(exc).__name__, exc)
continue
if cfg.adapter_id and cfg.instance_id:
self._instances[cfg.key] = cfg
@@ -512,6 +533,40 @@ class RuntimeProbeScopeChangedError(RuntimeError):
code = "runtime.instance.probe_scope_changed"
+def _health_from_entry(entry: ProbeEntry) -> dict[str, Any]:
+ """Capability part of a health snapshot, derived from one cache entry."""
+ probed_at = entry.probed_at.isoformat()
+ if not entry.ok or entry.report is None:
+ error = entry.error
+ return {
+ "healthy": False,
+ "detail": error.describe() if error is not None else "probe returned no report",
+ "error_code": error.code if error is not None else "",
+ "binary_path": "",
+ "runtime_version": "",
+ "probed_at": probed_at,
+ "capabilities": {},
+ "capability_injection": {},
+ "field_sources": {},
+ "degradations": [],
+ "source": "probe_error",
+ }
+ report: CapabilityProbeReport = entry.report
+ caps = report.capabilities
+ return {
+ "healthy": report.healthy,
+ "detail": report.detail,
+ "binary_path": report.binary_path,
+ "runtime_version": str(caps.runtime_version or ""),
+ "probed_at": probed_at,
+ "capabilities": caps.model_dump(mode="json"),
+ "capability_injection": describe_injection_path(caps),
+ "field_sources": dict(report.field_sources),
+ "degradations": list(report.degradations),
+ "source": "probe_cache",
+ }
+
+
class AgentRuntimeService:
"""Runtime 设置面板的业务服务(Handler 与 HTTP router 共用)。
@@ -537,15 +592,34 @@ def __init__(
self.worker_config = worker_config
self.sessions_root = Path(sessions_root)
self.factory = factory
- self._probe_tasks: dict[str, asyncio.Task[dict[str, Any]]] = {}
- self._probe_locks: dict[str, asyncio.Lock] = {}
- self._version_tasks: dict[str, asyncio.Task[dict[str, Any]]] = {}
- self._version_cache: dict[str, tuple[float, dict[str, Any]]] = {}
- self._version_cache_ttl_s = 300.0
+ self.probe_cache: ProbeCache = (
+ registry.cache if registry is not None else probe_cache())
+ self._unsubscribe_probe = self.probe_cache.subscribe(self._on_probe_entry)
self._probe_timeout_s = max(1.0, float(probe_timeout_s))
+ def _on_probe_entry(self, entry: ProbeEntry) -> None:
+ """Mirror every stored instance-scope probe into the persisted health.
+
+ Probes started outside this service (Conversation resume checks) then
+ reach the descriptor/list views too; ``probe_one`` later adds auth and
+ version-check fields to the same snapshot.
+ """
+ if entry.key.scope:
+ return
+ key = entry.key.instance_key
+ adapter_id, instance_id = entry.key.adapter_id, entry.key.instance_id
+ if self.store.get(adapter_id, instance_id) is None and not (
+ instance_id == "default" and engine_of_adapter(adapter_id)
+ ):
+ return
+ previous = self.store.health(key) or {}
+ kept = {name: previous[name] for name in (
+ "auth", "version_check", "binary_source", "binary_env") if name in previous}
+ self.store.save_health(key, {**kept, **_health_from_entry(entry)})
+
def apply_live_config(self, cfg: RuntimeInstanceConfig) -> None:
"""把设置页变更同步到当前进程的共享 AdapterRegistry。"""
+ self.probe_cache.invalidate(cfg.adapter_id, cfg.instance_id)
if self.registry is None or self.factory is None:
return
self.registry.unregister(cfg.adapter_id, cfg.instance_id)
@@ -553,6 +627,7 @@ def apply_live_config(self, cfg: RuntimeInstanceConfig) -> None:
self.factory.register(self.registry, cfg)
def remove_live_config(self, adapter_id: str, instance_id: str) -> None:
+ self.probe_cache.invalidate(adapter_id, instance_id)
if self.registry is not None:
self.registry.unregister(adapter_id, instance_id)
if (
@@ -578,12 +653,10 @@ def _discovered_default(self, adapter_id: str) -> dict[str, Any]:
driver = DRIVERS.get(engine)
binary = str(driver.bin) if driver is not None else ""
- except Exception: # noqa: BLE001 — binary 解析失败只影响展示
+ except Exception as exc: # noqa: BLE001 — binary 解析失败只影响展示
+ _LOG.warning("%s binary resolution failed: %s: %s",
+ adapter_id, type(exc).__name__, exc)
binary = ""
- if adapter_id == "devin.acp":
- from muteki.external_agents.devin import default_devin_binary
-
- binary = default_devin_binary()
structured = not adapter_id.startswith("cli.")
return {
"adapter_id": adapter_id,
@@ -600,8 +673,8 @@ def _discovered_default(self, adapter_id: str) -> dict[str, Any]:
"env_refs": {},
"enabled": True,
"transport": {},
+ "launch_args": [],
"transport_kind": "structured" if structured else "cli",
- "access_modes": list(ACCESS_MODE_VALUES) if structured else [],
"default_for_engine": (
DEFAULT_ADAPTER_BY_ENGINE.get(engine) == adapter_id
),
@@ -647,7 +720,12 @@ def list_instances(
if engine in DEFAULT_CLI_ADAPTER_BY_ENGINE
]
elif transport_kind == "structured":
- adapter_ids = list(DEFAULT_STRUCTURED_ADAPTER_BY_ENGINE.values())
+ adapter_ids = [
+ adapter.adapter_id for descriptor in all_descriptors()
+ for adapter in descriptor.adapters
+ if adapter.role == "default"
+ or (adapter.role == "variant" and adapter.capabilities.access_modes)
+ ]
else:
adapter_ids = list(DEFAULT_STRUCTURED_ADAPTER_BY_ENGINE.values()) + [
f"cli.{engine}" for engine in VALID_BASE_ENGINES]
@@ -681,6 +759,7 @@ def list_instances(
)
row.setdefault("auth", cached_auth or self._auth_view(
row["engine"], str(row.get("credential_ref") or "")))
+ row["access_modes"] = _resolved_access_modes(row)
return out
def _instance_view(self, cfg: RuntimeInstanceConfig, *, discovered: bool) -> dict[str, Any]:
@@ -690,9 +769,6 @@ def _instance_view(self, cfg: RuntimeInstanceConfig, *, discovered: bool) -> dic
view["transport_kind"] = (
"cli" if cfg.adapter_id.startswith("cli.") else "structured"
)
- view["access_modes"] = (
- [] if cfg.adapter_id.startswith("cli.") else list(ACCESS_MODE_VALUES)
- )
view["default_for_engine"] = (
cfg.instance_id == "default"
and DEFAULT_ADAPTER_BY_ENGINE.get(cfg.engine)
@@ -721,6 +797,7 @@ def get_instance(self, adapter_id: str, instance_id: str) -> Optional[dict[str,
)
view["auth"] = cached_auth or self._auth_view(
view["engine"], str(view.get("credential_ref") or ""))
+ view["access_modes"] = _resolved_access_modes(view)
return view
# -- 认证状态与登录指引 --------------------------------------------------------
@@ -826,103 +903,81 @@ async def probe_one(
force_version_check: bool = False,
credential_id: str = "",
environment: str = "local",
+ refresh: bool = False,
) -> dict[str, Any]:
- """Share only probes with the same Runtime configuration and owner."""
+ """Probe one instance through the shared ``ProbeCache`` and publish health.
+
+ ``refresh=True`` bypasses a fresh cache entry; concurrent callers of
+ the same Runtime configuration and credential scope share one probe.
+ """
adapter_id = canonical_adapter_id(adapter_id)
key = f"{adapter_id}:{instance_id}"
if environment != "local":
raise ValueError("Runtime probes execute in the local conversation environment")
credential_id = canonical_credential_id(credential_id, engine=engine_of_adapter(adapter_id))
- if credential_id and adapter_id != "codex.app_server":
- raise ValueError("Scoped model catalog probing is supported by codex.app_server")
+ if credential_id:
+ _require_scoped_model_catalog_probe(adapter_id)
cfg = self.store.get(adapter_id, instance_id)
configuration = json.loads(json.dumps(cfg.to_dict())) if cfg is not None else None
- task_key = json.dumps([key, credential_id, environment, configuration], sort_keys=True)
- existing = self._probe_tasks.get(task_key)
- if existing is not None and not existing.done():
- return await asyncio.shield(existing)
-
- async def _perform() -> dict[str, Any]:
- try:
- # Registered adapters keep their last report on the instance.
- # Serialize different credential probes so that report cannot
- # be read back as the other request's result.
- async with self._probe_locks.setdefault(key, asyncio.Lock()):
- return await self._probe_one_unlocked(
- adapter_id, instance_id,
- force_version_check=force_version_check,
- credential_id=credential_id, environment=environment,
- configuration=configuration,
- )
- except Exception as exc:
- if credential_id or isinstance(exc, RuntimeProbeScopeChangedError):
- raise
- engine = engine_of_adapter(adapter_id)
- previous = self.store.health(key) or {}
- self.store.save_health(key, {
- "healthy": False,
- "detail": f"{type(exc).__name__}: {exc}",
- "binary_path": "",
- "runtime_version": "",
- "probed_at": utcnow().isoformat(),
- "capabilities": {},
- "capability_injection": {},
- "field_sources": {},
- "degradations": [],
- "auth": self._auth_view(engine, "") if engine else {},
- "source": "probe_error",
- "version_check": dict(previous.get("version_check") or {}),
- })
- raise
-
- task = asyncio.create_task(_perform())
- self._probe_tasks[task_key] = task
try:
return await asyncio.wait_for(
- asyncio.shield(task), timeout=self._probe_timeout_s,
+ self._probe_one_unlocked(
+ adapter_id, instance_id,
+ force_version_check=force_version_check,
+ credential_id=credential_id, environment=environment,
+ configuration=configuration, refresh=refresh,
+ ),
+ timeout=self._probe_timeout_s,
)
- except asyncio.CancelledError:
- task.cancel()
- try:
- await task
- except asyncio.CancelledError:
- pass
- raise
except asyncio.TimeoutError as exc:
- task.cancel()
- try:
- await task
- except asyncio.CancelledError:
- pass
- engine = engine_of_adapter(adapter_id)
- previous = self.store.health(key) or {}
detail = f"Runtime 探测超过 {self._probe_timeout_s:g} 秒"
if credential_id:
raise TimeoutError(detail) from exc
- self.store.save_health(key, {
- "healthy": False,
- "detail": detail,
- "binary_path": "",
- "runtime_version": "",
- "probed_at": utcnow().isoformat(),
- "capabilities": {},
- "capability_injection": {},
- "field_sources": {},
- "degradations": [detail],
- "auth": self._auth_view(engine, "") if engine else {},
- "source": "probe_timeout",
- "version_check": dict(previous.get("version_check") or {}),
- })
+ await self._save_probe_failure(key, adapter_id, detail, source="probe_timeout")
raise TimeoutError(detail) from exc
- finally:
- if task.done() and self._probe_tasks.get(task_key) is task:
- self._probe_tasks.pop(task_key, None)
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ if credential_id or isinstance(exc, RuntimeProbeScopeChangedError):
+ raise
+ await self._save_probe_failure(
+ key, adapter_id, f"{type(exc).__name__}: {exc}", source="probe_error",
+ code=str(getattr(exc, "code", "") or ""))
+ raise
+
+ async def _save_probe_failure(
+ self, key: str, adapter_id: str, detail: str, *, source: str, code: str = "",
+ ) -> None:
+ engine = engine_of_adapter(adapter_id)
+ previous = self.store.health(key) or {}
+ auth = await asyncio.to_thread(self._auth_view, engine, "") if engine else {}
+ self.store.save_health(key, {
+ "healthy": False,
+ "detail": detail,
+ **({"error_code": code} if code else {}),
+ "binary_path": "",
+ "runtime_version": "",
+ "probed_at": utcnow().isoformat(),
+ "capabilities": {},
+ "capability_injection": {},
+ "field_sources": {},
+ "degradations": [detail] if source == "probe_timeout" else [],
+ "auth": auth,
+ "source": source,
+ "version_check": dict(previous.get("version_check") or {}),
+ })
async def _probe_credential_catalog(
self, adapter_id: str, instance_id: str, credential_id: str,
- configuration: dict[str, Any] | None,
+ configuration: dict[str, Any] | None, *, refresh: bool,
) -> tuple[Any, str]:
- """Prepare a small temporary native home for the selected credential."""
+ """Probe in a small temporary native home for the selected credential.
+
+ The cache scope carries the credential id and revision, so a changed
+ credential never reads another revision's catalog. The temporary
+ home lives inside the shared probe task; joined callers do not depend
+ on the first caller's directory.
+ """
from muteki.conversation.chat_plugins import ChatPluginService
from muteki.external_agents.factory import RuntimeAdapterConfig
from muteki.external_agents.probe_environment import PROBE_ENVIRONMENT
@@ -931,45 +986,54 @@ async def _probe_credential_catalog(
CredentialAccountStore, account_id_from_credential_id, account_store_root,
)
+ _require_scoped_model_catalog_probe(adapter_id)
+ engine = engine_of_adapter(adapter_id)
accounts = CredentialAccountStore(account_store_root(self.sessions_root))
account_id = account_id_from_credential_id(credential_id)
revision = accounts.revision(account_id) if account_id else ""
+ request = ProbeRequest(runtime_instance_id=instance_id, include_models=True)
+
+ async def run() -> CapabilityProbeReport:
+ base = self.sessions_root / "_runtime_probe_environments"
+ base.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with tempfile.TemporaryDirectory(prefix="probe-", dir=base) as directory:
+ root = Path(directory)
+
+ def prepare() -> dict[str, str]:
+ resolved = resolve_credential_env(
+ credential_id, engine=engine, sessions_root=self.sessions_root,
+ container=False, agent_state_dir=root / "credentials",
+ )
+ cfg = RuntimeAdapterConfig.from_value(configuration or {
+ "adapter_id": adapter_id, "instance_id": instance_id})
+ configured = self.factory._env(cfg) if self.factory is not None else {}
+ return ChatPluginService(root / "native").prepare_environment(
+ engine, f"probe:{adapter_id}:{instance_id}:{credential_id}",
+ {**configured, **resolved.env}, include_assets=False,
+ )
- base = self.sessions_root / "_runtime_probe_environments"
- base.mkdir(parents=True, exist_ok=True, mode=0o700)
- with tempfile.TemporaryDirectory(prefix="probe-", dir=base) as directory:
- root = Path(directory)
-
- def prepare() -> dict[str, str]:
- resolved = resolve_credential_env(
- credential_id, engine="codex", sessions_root=self.sessions_root,
- container=False, agent_state_dir=root / "credentials",
- )
- cfg = RuntimeAdapterConfig.from_value(configuration or {
- "adapter_id": adapter_id, "instance_id": instance_id})
- configured = self.factory._env(cfg) if self.factory is not None else {}
- return ChatPluginService(root / "native").prepare_environment(
- "codex", f"probe:{adapter_id}:{instance_id}:{credential_id}",
- {**configured, **resolved.env}, include_assets=False,
- )
-
- preparation = asyncio.create_task(asyncio.to_thread(prepare))
- try:
- env = await asyncio.shield(preparation)
- except asyncio.CancelledError:
- # to_thread cannot be stopped. Wait for its writes to finish
- # before removing the temporary credential/home tree.
- await preparation
- raise
- token = PROBE_ENVIRONMENT.set(env)
- try:
- report = await self.registry.probe(adapter_id, instance_id, request=ProbeRequest(
- runtime_instance_id=instance_id, include_models=True))
- if account_id and accounts.revision(account_id) != revision:
- raise RuntimeProbeScopeChangedError("Credential configuration changed during model catalog probing")
- return report, revision
- finally:
- PROBE_ENVIRONMENT.reset(token)
+ preparation = asyncio.create_task(asyncio.to_thread(prepare))
+ try:
+ env = await asyncio.shield(preparation)
+ except asyncio.CancelledError:
+ # to_thread cannot be stopped. Wait for its writes to finish
+ # before removing the temporary credential/home tree.
+ await preparation
+ raise
+ token = PROBE_ENVIRONMENT.set(env)
+ try:
+ return await self.registry.run_probe(adapter_id, instance_id, request)
+ finally:
+ PROBE_ENVIRONMENT.reset(token)
+
+ key = self.registry.probe_key(
+ adapter_id, instance_id, request=request,
+ scope=credential_scope(credential_id, revision))
+ entry = await self.probe_cache.get(key, run, refresh=refresh)
+ report = entry.require_report()
+ if account_id and accounts.revision(account_id) != revision:
+ raise RuntimeProbeScopeChangedError("Credential configuration changed during model catalog probing")
+ return report, revision
async def _check_version(
self,
@@ -980,44 +1044,32 @@ async def _check_version(
runtime_version: str,
force: bool,
) -> dict[str, Any]:
- """读取发布版本并做五分钟缓存;失败时保留同版本的上次成功结果。"""
- cache_key = f"{engine}\0{binary_path}\0{runtime_version}"
- cached = self._version_cache.get(cache_key)
- now = time.monotonic()
- if cached and not force and now - cached[0] < self._version_cache_ttl_s:
- return dict(cached[1])
- existing = self._version_tasks.get(cache_key)
- if existing is not None and not existing.done():
- return await asyncio.shield(existing)
-
- async def _perform() -> dict[str, Any]:
- from muteki.external_agents.version_check import check_cli_version
-
- result = await check_cli_version(engine, binary_path, runtime_version)
- if result.get("status") == "unknown":
- previous = dict((self.store.health(key) or {}).get("version_check") or {})
- same_install = (
- previous.get("installed_version")
- and previous.get("installed_version") == result.get("installed_version")
- )
- if same_install and previous.get("latest_version"):
- result = {
- **previous,
- "stale": True,
- "attempted_at": result.get("checked_at"),
- "detail": "版本源暂时不可用,沿用上次检查结果",
- "error": result.get("error", ""),
- }
- self._version_cache[cache_key] = (time.monotonic(), dict(result))
- return result
+ """读取发布版本(version_check 内 TTL + single-flight);源失败时保留同版本的上次成功结果。"""
+ from muteki.external_agents.version_check import cached_check_cli_version
- task = asyncio.create_task(_perform())
- self._version_tasks[cache_key] = task
- try:
- return await asyncio.shield(task)
- finally:
- if task.done() and self._version_tasks.get(cache_key) is task:
- self._version_tasks.pop(cache_key, None)
+ result = await cached_check_cli_version(
+ engine, binary_path, runtime_version, force=force)
+ if result.get("status") != "unknown":
+ return result
+ previous = dict((self.store.health(key) or {}).get("version_check") or {})
+ same_install = (
+ previous.get("installed_version")
+ and previous.get("installed_version") == result.get("installed_version")
+ )
+ if engine == "opencode" and result.get("installed_version"):
+ major = str(result["installed_version"]).split(".", 1)[0]
+ same_install = same_install and previous.get("source") == (
+ "npm:opencode-ai" if major == "1" else "npm:@opencode/cli")
+ if same_install and previous.get("latest_version"):
+ return {
+ **previous,
+ "stale": True,
+ "attempted_at": result.get("checked_at"),
+ "detail": "版本源暂时不可用,沿用上次检查结果",
+ "error": result.get("error", ""),
+ "error_code": result.get("error_code", ""),
+ }
+ return result
async def _probe_one_unlocked(
self,
@@ -1028,13 +1080,14 @@ async def _probe_one_unlocked(
credential_id: str = "",
environment: str = "local",
configuration: dict[str, Any] | None = None,
+ refresh: bool = False,
) -> dict[str, Any]:
"""对一个 instance 做 capability probe 并写健康缓存。
优先使用 AdapterRegistry 中已注册的实例(RUNTIME-01);否则对该
引擎的 CLI 兼容路径做本机实测(binary + --version + 覆写检测)。
- probe 失败抛出异常,由调用方归类为该实例的失败事件——不影响其他
- 实例。
+ 两条路径都经共享 ``ProbeCache``。probe 失败抛出 typed 异常,由调用方
+ 归类为该实例的失败事件——不影响其他实例。
"""
engine = engine_of_adapter(adapter_id)
if not engine:
@@ -1052,28 +1105,40 @@ def ensure_configuration() -> None:
report: Any = None
credential_revision = ""
source = "cli_driver"
+ probed_at = utcnow()
+ probe_cached = False
if self.registry is not None and self.registry.record(adapter_id, instance_id) is not None:
if credential_id:
report, credential_revision = await self._probe_credential_catalog(
- adapter_id, instance_id, credential_id, configuration)
+ adapter_id, instance_id, credential_id, configuration, refresh=refresh)
else:
- report = await self.registry.probe(adapter_id, instance_id)
+ entry = await self.registry.probe_entry(
+ adapter_id, instance_id, refresh=refresh)
+ report = entry.require_report()
+ probed_at, probe_cached = entry.probed_at, entry.cached
caps = report.capabilities
source = "registry"
elif adapter_id.startswith("cli."):
- from muteki.external_agents.capabilities import probe_cli_driver
+ from muteki.external_agents.capabilities import probe_cli_driver_async
from muteki.solver.cli_driver import DRIVERS
driver = DRIVERS.get(engine)
if driver is None:
raise ValueError(f"引擎 {engine!r} 没有可用的 CLI Driver")
- report = await asyncio.to_thread(
- probe_cli_driver,
- driver,
- adapter_id=adapter_id,
- instance_id=instance_id,
- include_models=True,
+ probe_key = ProbeKey(
+ adapter_id=adapter_id, instance_id=instance_id,
+ binary=BinaryIdentity.of(driver_binary(driver)),
+ config_hash=config_hash(configuration),
+ )
+ entry = await self.probe_cache.get(
+ probe_key,
+ lambda: probe_cli_driver_async(
+ driver, adapter_id=adapter_id, instance_id=instance_id,
+ include_models=True),
+ refresh=refresh,
)
+ report = entry.require_report()
+ probed_at, probe_cached = entry.probed_at, entry.cached
caps = report.capabilities
else:
raise LookupError(
@@ -1086,22 +1151,24 @@ def ensure_configuration() -> None:
overridden = str(cfg.binary_path if cfg else "").strip()
if overridden:
binary_path = overridden
- from muteki.external_agents.capabilities import _probe_version
+ from muteki.external_agents.capabilities import probe_version
- version = _probe_version(overridden)
- if not version:
+ adapter = (self.registry.get(adapter_id, instance_id)
+ if self.registry is not None and self.registry.record(adapter_id, instance_id) is not None else None)
+ version_argv = getattr(adapter, "probe_version_argv", None)
+ override = await probe_version(version_argv() if callable(version_argv) else overridden)
+ if not override.ok:
healthy = False
- detail = f"实例配置的 binary 不可运行:{overridden}"
+ detail = f"实例配置的 binary 不可运行:{override.describe()}"
elif not caps.runtime_version:
- caps = caps.model_copy(update={"runtime_version": version})
+ caps = caps.model_copy(update={"runtime_version": override.version})
auth = await asyncio.to_thread(
self._auth_view,
engine,
str(cfg.credential_ref if cfg else ""),
- detect_host=adapter_id.startswith("cli.") or adapter_id == "devin.acp",
+ detect_host=adapter_id.startswith("cli.") or _host_login_only(adapter_id),
)
- from muteki.external_agents.capabilities import describe_injection_path
binary_source = ""
binary_env = ""
if adapter_id.startswith("cli."):
@@ -1113,8 +1180,8 @@ def ensure_configuration() -> None:
binary_source = resolve_engine_bin_source(engine)
binary_env = str(_ENV_OVERRIDE.get(engine) or "")
- except Exception: # noqa: BLE001 — 仅影响诊断元数据
- pass
+ except Exception as exc: # noqa: BLE001 — 仅影响诊断元数据,原因写入该字段
+ binary_source = f"unresolved: {type(exc).__name__}: {exc}"
runtime_version = str(getattr(caps, "runtime_version", "") or "")
version_check = await self._check_version(
@@ -1132,7 +1199,8 @@ def ensure_configuration() -> None:
"binary_env": binary_env,
"runtime_version": runtime_version,
"version_check": version_check,
- "probed_at": utcnow().isoformat(),
+ "probed_at": probed_at.isoformat(),
+ "probe_cached": probe_cached,
"capabilities": caps.model_dump(mode="json") if hasattr(caps, "model_dump") else {},
"capability_injection": describe_injection_path(caps),
"field_sources": dict(getattr(report, "field_sources", {}) or {}),
@@ -1174,10 +1242,12 @@ async def probe_all(
*,
transport_kind: str = "",
force_version_check: bool = False,
+ refresh: bool = False,
) -> dict[str, Any]:
"""批量 probe(配置实例 + 该范围引擎的默认发现实例)。
- 单实例失败被隔离进结果条目,不中断其余实例。
+ 单实例失败被隔离进结果条目,不中断其余实例。``refresh=False``
+ 时 TTL 内的缓存结果直接复用。
"""
views = self.list_instances(
adapter_id,
@@ -1199,16 +1269,19 @@ async def probe(view: dict[str, Any]) -> dict[str, Any]:
view["adapter_id"],
view["instance_id"],
force_version_check=force_version_check,
+ refresh=refresh,
)
entry["ok"] = True
entry["healthy"] = bool(health.get("healthy"))
entry["detail"] = str(health.get("detail") or "")
+ entry["probe_cached"] = bool(health.get("probe_cached"))
entry["version_check"] = dict(
health.get("version_check") or {})
except Exception as exc: # noqa: BLE001 — 故障隔离
entry["ok"] = False
entry["healthy"] = False
- entry["detail"] = f"{type(exc).__name__}: {str(exc)[:160]}"
+ entry["error_code"] = str(getattr(exc, "code", "") or "")
+ entry["detail"] = f"{type(exc).__name__}: {exc}"
return entry
results = list(await asyncio.gather(*(probe(view) for view in views)))
@@ -1227,7 +1300,9 @@ def _raw_profiles(self) -> list[dict[str, Any]]:
return []
try:
cfg = self.worker_config.get()
- except Exception: # noqa: BLE001
+ except Exception as exc: # noqa: BLE001 — profiles only add discovered views
+ _LOG.error("worker config unreadable; worker profiles omitted: %s: %s",
+ type(exc).__name__, exc)
return []
return [p for p in cfg.get("worker_profiles") or [] if isinstance(p, dict)]
@@ -1482,8 +1557,8 @@ def _plan_probe(self, command: CommandEnvelope) -> CommandPlan:
if not isinstance(raw_credential, str) or environment != "local":
raise ValueError("credential_id must be a string and environment must be local")
credential_id = canonical_credential_id(raw_credential, engine=engine_of_adapter(adapter_id))
- if credential_id and adapter_id != "codex.app_server":
- raise ValueError("Scoped model catalog probing is supported by codex.app_server")
+ if credential_id:
+ _require_scoped_model_catalog_probe(adapter_id)
except ValueError as exc:
raise CommandFailed(make_error(
"runtime.instance.probe_scope_invalid", str(exc), ErrorCategory.VALIDATION,
@@ -1491,12 +1566,15 @@ def _plan_probe(self, command: CommandEnvelope) -> CommandPlan:
async def _probe() -> SideEffectResult:
try:
+ # An explicit probe command is the refresh action: it never
+ # answers from a fresh cache entry.
health = await service.probe_one(
adapter_id,
instance_id,
force_version_check=True,
credential_id=credential_id,
environment=environment,
+ refresh=True,
)
except Exception as exc: # noqa: BLE001 — 归类为该实例的失败
return SideEffectResult(
@@ -1581,6 +1659,7 @@ async def _refresh() -> SideEffectResult:
adapter_id or None,
transport_kind=transport_kind,
force_version_check=True,
+ refresh=True,
)
failed = [r for r in summary["results"] if not r["ok"]]
return SideEffectResult(
@@ -1908,6 +1987,92 @@ async def handle(self, query: Any, ctx: HandlerContext) -> QueryResult:
)
+def _instance_capabilities(view: dict[str, Any]) -> dict[str, Any]:
+ """Declared capabilities of one instance overlaid with its cached probe.
+
+ Reads only the cached health snapshot; never starts a probe.
+ """
+ from pydantic import ValidationError
+ from muteki.platform.contracts.external_agents import AgentCapabilities
+
+ adapter_id = str(view.get("adapter_id") or "")
+ descriptor = descriptor_for_adapter(adapter_id)
+ health = view.get("health") if isinstance(view.get("health"), dict) else {}
+ snapshot = health.get("capabilities") if isinstance(health, dict) else None
+ probe: AgentCapabilities | None = None
+ snapshot_error: dict[str, str] | None = None
+ if isinstance(snapshot, dict) and snapshot:
+ try:
+ probe = AgentCapabilities.model_validate(snapshot)
+ except ValidationError as exc:
+ snapshot_error = {
+ "code": "provider.capability_snapshot_invalid",
+ "message": str(exc),
+ }
+ field_sources = health.get("field_sources") if isinstance(health, dict) else None
+ caps, sources = resolve_capabilities(
+ descriptor, probe, adapter_id=adapter_id,
+ probe_sources=field_sources if isinstance(field_sources, dict) else None,
+ )
+ adapter = descriptor.adapter(adapter_id)
+ row: dict[str, Any] = {
+ "key": str(view.get("key") or ""),
+ "adapter_id": adapter_id,
+ "engine": descriptor.engine,
+ "role": adapter.role,
+ "configured": bool(view.get("configured")),
+ "probed_at": str((health or {}).get("probed_at") or ""),
+ "capabilities": caps.model_dump(mode="json"),
+ "capability_sources": sources,
+ "native_rewind": adapter.native_rewind,
+ "capability_gateway": adapter.capability_gateway,
+ "access_mode_notes": dict(adapter.access_mode_notes),
+ }
+ if snapshot_error is not None:
+ row["capability_snapshot_error"] = snapshot_error
+ return row
+
+
+def _resolved_access_modes(view: dict[str, Any]) -> list[str]:
+ """Access modes the adapter enforces (declared ∩ cached probe).
+
+ A stored instance whose adapter no longer has a descriptor keeps ``[]``;
+ ``_instance_capabilities`` stays strict for the descriptor query.
+ """
+ if find_descriptor_for_adapter(str(view.get("adapter_id") or "")) is None:
+ return []
+ return list(_instance_capabilities(view)["capabilities"]["access_modes"])
+
+
+class ProviderDescriptorQueryHandler:
+ """Provider descriptors plus per-instance resolved capabilities."""
+
+ query_types = {"runtime.descriptor.list"}
+
+ def __init__(self, service: AgentRuntimeService) -> None:
+ self._service = service
+
+ async def handle(self, query: Any, ctx: HandlerContext) -> QueryResult:
+ engine = str(query.params.get("engine") or "").strip().lower()
+ descriptors = (get_descriptor(engine),) if engine else all_descriptors()
+ engines = {item.engine for item in descriptors}
+ instances = [
+ _instance_capabilities(view)
+ for view in self._service.list_instances(include_discovered=True)
+ if engine_of_adapter(str(view.get("adapter_id") or "")) in engines
+ ]
+ return QueryResult(
+ query_id=query.query_id,
+ query_type=query.query_type,
+ result={
+ "schema_version": DESCRIPTOR_SCHEMA_VERSION,
+ "descriptors": [public_descriptor(item) for item in descriptors],
+ "instances": instances,
+ "count": len(descriptors),
+ },
+ )
+
+
class WorkerProfileQueryHandler:
"""Worker Profile 只读查询(含 Runtime instance 确定映射)。"""
@@ -1945,6 +2110,8 @@ def register_agent_runtime_handlers(
api.register_query(RuntimeInstanceQueryHandler(service))
if "worker_profile.list" not in known_queries:
api.register_query(WorkerProfileQueryHandler(service))
+ if "runtime.descriptor.list" not in known_queries:
+ api.register_query(ProviderDescriptorQueryHandler(service))
# ---------------------------------------------------------------------------
@@ -2053,6 +2220,19 @@ async def list_worker_profiles() -> Any:
result = await command_api.query(_query("worker_profile.list"))
return result.result
+ @router.get("/api/agent-runtimes/descriptors")
+ async def list_provider_descriptors(engine: str = "") -> Any:
+ selected = str(engine or "").strip().lower()
+ if selected and find_descriptor(selected) is None:
+ error = ErrorEnvelope(
+ code=UnknownProviderError.code,
+ message=f"no provider descriptor for engine {selected!r}",
+ category=ErrorCategory.NOT_FOUND,
+ )
+ return JSONResponse({"error": error.model_dump(mode="json")}, status_code=404)
+ result = await command_api.query(_query("runtime.descriptor.list", engine=selected))
+ return result.result
+
@router.get("/api/agent-runtimes/{instance_key}")
async def get_runtime(instance_key: str) -> Any:
adapter_id, instance_id = _parse_key(instance_key)
@@ -2192,6 +2372,7 @@ def _query(query_type: str, **params: Any) -> Any:
__all__ = [
"AgentRuntimeService",
"LOGIN_GUIDANCE",
+ "ProviderDescriptorQueryHandler",
"OPERATOR",
"PRODUCER",
"RuntimeInstanceCommandHandler",
diff --git a/apps/web/auth.py b/apps/web/auth.py
index 9a4caf3..aa78a88 100644
--- a/apps/web/auth.py
+++ b/apps/web/auth.py
@@ -1,85 +1,41 @@
-"""Web command-deck authentication (P3).
-
-The deck was historically wide open: it bound to 127.0.0.1 and trusted any
-local caller. That is fine for a single operator on a laptop but unsafe the
-moment the backend is reachable from anywhere else (a remote host, a shared
-box, an untrusted same-machine process) — the `/api` surface can read/write
-credential accounts (Claude/Codex/Cursor subscription tokens), start/kill runs,
-and inject operator commands.
-
-This module adds a single-password gate in front of `/api`:
-
- * The operator sets MUTEKI_WEB_PASSWORD.
- * The browser POSTs it to /api/auth/login and gets back a short-lived,
- HMAC-signed session token (the password itself is never stored client-side).
- * Every /api request carries `Authorization: Bearer `; the middleware
- verifies the signature + expiry in constant time.
- * SSE/WebSocket connections (which a browser cannot send headers on) use a
- one-time ticket minted by an authenticated POST /api/auth/ticket.
-
-Design notes / threat model live in docs/_local/plan_p3_auth.md. The RCP
-container<->host control channel (control_receiver.py) has its OWN per-run
-token and is orthogonal to this — not touched here.
-"""
+"""Service-owned access settings and sessions for Web, desktop and API clients.
+Only password hashes and signing material are persisted under state/_secrets.
+Web sessions use HttpOnly cookies; native/API clients use audience-bound tokens.
+SSE/WS still use short-lived, single-use tickets.
+"""
from __future__ import annotations
+import asyncio
import base64
import hashlib
import hmac
+import json
+import logging
import os
import secrets
+import tempfile
+import threading
import time
+from contextlib import contextmanager
from dataclasses import dataclass, field
-from typing import Optional
-
-# ---------------------------------------------------------------------------
-# Env knobs
-# ---------------------------------------------------------------------------
-# MUTEKI_WEB_PASSWORD the operator login password. If set, auth is ENFORCED
-# on every /api route (even on loopback).
-# MUTEKI_WEB_BIND the bind host run.sh passes to uvicorn (it is also
-# exported to the process env so create_app can see it;
-# uvicorn's --host is NOT visible to the app otherwise).
-# Used for the fail-fast: a non-loopback bind with no
-# password set is a refuse-to-start misconfiguration.
-# MUTEKI_WEB_TOKEN_TTL session-token lifetime in seconds (default 12h).
-# MUTEKI_WEB_AUTH_SECRET optional explicit HMAC signing secret. If unset, a
-# secret is derived from the password (stable across
-# restarts so existing tokens survive a reboot) — or,
-# if there is no password, a random per-process secret.
+from pathlib import Path
+from typing import Any, Optional
+from urllib.parse import parse_qs
+
+from starlette.requests import HTTPConnection
PASSWORD_ENV = "MUTEKI_WEB_PASSWORD"
BIND_ENV = "MUTEKI_WEB_BIND"
TTL_ENV = "MUTEKI_WEB_TOKEN_TTL"
SECRET_ENV = "MUTEKI_WEB_AUTH_SECRET"
-
DEFAULT_TTL_S = 12 * 3600
-
-# Paths under the gate that must stay reachable WITHOUT a token, otherwise the
-# operator could never obtain one. Everything else under /api requires auth.
-# /api/auth/login — exchange password for a token (you have no token yet)
-# /api/health — liveness probe used by compose and load balancers
-# NOTE: /api/auth/ticket and /api/auth/me are deliberately NOT public — minting a
-# ticket or reading identity both require an already-valid token.
-PUBLIC_API_PATHS = frozenset({"/api/auth/login", "/api/health"})
-
-_LOOPBACK_HOSTS = frozenset({
- "127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1", "",
-})
+PUBLIC_API_PATHS = frozenset({"/api/auth/login", "/api/auth/status", "/api/health"})
+_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1", ""})
def is_loopback_host(host: Optional[str]) -> bool:
- """True if `host` is a loopback / unset bind address.
-
- A wildcard bind (0.0.0.0 / ::) is explicitly NOT loopback: it exposes the
- server on every interface, which is exactly the case the fail-fast guards.
- """
h = (host or "").strip().lower()
- # strip optional IPv6 brackets and a trailing :port a user might pass in
- # MUTEKI_WEB_BIND (e.g. "[::1]:8000" or "127.0.0.1:8000"). A bare IPv6 like
- # "::1" has multiple colons and no brackets — don't mistake its last colon
- # for a port separator.
if h.startswith("[") and "]" in h:
h = h[1:h.index("]")]
elif h.count(":") == 1:
@@ -87,179 +43,455 @@ def is_loopback_host(host: Optional[str]) -> bool:
return h in _LOOPBACK_HOSTS
-def _derive_secret(password: str) -> bytes:
- """Stable signing key derived from the password.
+def _b64u(raw: bytes) -> str:
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
+
- Deriving from the password (rather than a random per-process key) means
- issued tokens survive a backend restart, and rotating the password
- invalidates all old tokens for free. A dedicated MUTEKI_WEB_AUTH_SECRET
- overrides this when an operator wants to rotate the password without logging
- everyone out (or vice-versa).
- """
- return hashlib.sha256(
- b"muteki-web-auth-v1\x00" + password.encode("utf-8")).digest()
+def _b64u_decode(value: str) -> bytes:
+ return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
+
+
+def _password_hash(password: str) -> str:
+ salt = secrets.token_bytes(16)
+ key = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=32768, r=8, p=1, maxmem=64 * 1024 * 1024)
+ return f"scrypt-v1${_b64u(salt)}${_b64u(key)}"
+
+
+def _valid_hash(value: str) -> bool:
+ try:
+ version, salt, digest = value.split("$")
+ return version == "scrypt-v1" and len(_b64u_decode(salt)) == 16 and len(_b64u_decode(digest)) == 64
+ except (ValueError, TypeError):
+ return False
+
+
+def _matches_hash(value: str, password: str) -> bool:
+ if not _valid_hash(value):
+ return False
+ _, salt, digest = value.split("$")
+ key = hashlib.scrypt(password.encode("utf-8"), salt=_b64u_decode(salt), n=32768, r=8, p=1, maxmem=64 * 1024 * 1024)
+ return hmac.compare_digest(key, _b64u_decode(digest))
+
+
+def _read_private(path: Path) -> dict[str, Any]:
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ valid = (isinstance(data, dict) and data.get("version") == 1
+ and data.get("mode") in {"environment", "settings"}
+ and len(_b64u_decode(data["signing_key"])) == 32
+ and isinstance(data["revision"], str) and len(data["revision"]) >= 32
+ and all(isinstance(data[k], str) and (not data[k] or _valid_hash(data[k])) for k in ("password_hash", "environment_hash"))
+ and (data["mode"] != "settings" or bool(data["password_hash"]))
+ and isinstance(data["revoked"], dict)
+ and all(isinstance(k, str) and isinstance(v, int) for k, v in data["revoked"].items())
+ and isinstance(data["secret_fingerprint"], str))
+ if not valid:
+ raise ValueError("invalid access configuration")
+ return data
+ except (OSError, ValueError, TypeError, KeyError) as exc:
+ raise RuntimeError(f"访问配置无法读取:{path}。请修复配置后重启,未回退到环境变量。") from exc
+
+
+def _file_version(path: Path) -> tuple[int, int, int]:
+ stat = path.stat()
+ return stat.st_ino, stat.st_mtime_ns, stat.st_size
+
+
+@contextmanager
+def _file_guard(path: Path | None):
+ if path is None:
+ yield
+ return
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with path.with_suffix(".lock").open("a+b") as lock:
+ os.chmod(lock.name, 0o600)
+ if os.name == "nt":
+ import msvcrt
+ if lock.seek(0, 2) == 0:
+ lock.write(b"\0")
+ lock.flush()
+ lock.seek(0)
+ msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1)
+ else:
+ import fcntl
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
+ try:
+ yield
+ finally:
+ if os.name == "nt":
+ lock.seek(0)
+ msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1)
+ else:
+ fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
+
+
+def _write_private(path: Path, data: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as tmp:
+ temporary = Path(tmp.name)
+ try:
+ os.chmod(temporary, 0o600)
+ json.dump(data, tmp, separators=(",", ":"))
+ tmp.flush()
+ os.fsync(tmp.fileno())
+ os.replace(temporary, path)
+ finally:
+ temporary.unlink(missing_ok=True)
@dataclass
class AuthConfig:
- """Resolved auth configuration for one server instance."""
-
- password: str = ""
+ password: str = field(default="", repr=False) # compatibility for an in-memory caller; never persisted
bind_host: str = ""
+ ui_bind_host: str = ""
ttl_s: int = DEFAULT_TTL_S
- secret: bytes = b""
+ secret: bytes = field(default=b"", repr=False)
+ service_id: str = "local"
+ _path: Path | None = field(default=None, repr=False)
+ _state: dict[str, Any] = field(default_factory=dict, repr=False)
+ _path_version: tuple[int, int, int] | None = field(default=None, repr=False)
+ _explicit_secret: str = field(default="", repr=False)
+ _lock: Any = field(default_factory=threading.RLock, repr=False)
+ _failures: dict[str, list[float]] = field(default_factory=dict, repr=False)
@property
def enabled(self) -> bool:
- """Auth is enforced iff a password is configured."""
- return bool(self.password)
+ self.refresh()
+ return bool(self.effective_hash or self.password) or os.environ.get("MUTEKI_MANAGED_DESKTOP") == "1"
+
+ @property
+ def effective_hash(self) -> str:
+ return str(self._state.get("password_hash" if self.source == "settings" else "environment_hash", ""))
+
+ @property
+ def source(self) -> str:
+ return "settings" if self._state.get("mode") == "settings" else "environment"
+
+ @property
+ def revision(self) -> str:
+ self.refresh()
+ return str(self._state.get("revision", "ephemeral"))
+
+ @property
+ def cookie_name(self) -> str:
+ return "muteki_session_" + hashlib.sha256(self.service_id.encode()).hexdigest()[:16]
@classmethod
- def from_env(cls) -> "AuthConfig":
+ def from_env(cls, state_root: Path | str | None = None, *, service_id: str = "local") -> "AuthConfig":
password = (os.environ.get(PASSWORD_ENV) or "").strip()
- bind_host = (os.environ.get(BIND_ENV) or "").strip()
+ explicit = (os.environ.get(SECRET_ENV) or "").strip()
try:
ttl = int(os.environ.get(TTL_ENV) or "")
- except (TypeError, ValueError):
- ttl = DEFAULT_TTL_S
- if ttl <= 0:
+ except (ValueError, TypeError):
ttl = DEFAULT_TTL_S
- explicit_secret = (os.environ.get(SECRET_ENV) or "").strip()
- if explicit_secret:
- secret = hashlib.sha256(explicit_secret.encode("utf-8")).digest()
- elif password:
- secret = _derive_secret(password)
- else:
- # No password → auth disabled → secret is never used to verify a real
- # session, but generate a random one so the type stays non-empty.
- secret = secrets.token_bytes(32)
- return cls(password=password, bind_host=bind_host, ttl_s=ttl, secret=secret)
-
- def fail_fast_check(self) -> None:
- """Refuse to start in an obviously-unsafe configuration.
-
- Non-loopback bind + no password = the wide-open `/api` surface is
- reachable from the network. That is almost never intended; make it a
- loud startup error instead of a silent exposure.
- """
- if not self.password and not is_loopback_host(self.bind_host):
- raise RuntimeError(
- f"Refusing to start: bound to non-loopback host "
- f"{self.bind_host!r} with no {PASSWORD_ENV} set — the /api "
- f"surface (including credential accounts) would be exposed "
- f"unauthenticated. Set {PASSWORD_ENV}, or bind to 127.0.0.1."
- )
-
-
-# ---------------------------------------------------------------------------
-# Session tokens: HMAC-signed "." (stateless, expiring)
-# ---------------------------------------------------------------------------
-# Token = base64url(payload) + "." + base64url(hmac_sha256(secret, payload))
-# payload = b"v1\x00" + str(exp_unix_seconds). We keep it tiny; there is exactly
-# one principal (the operator), so the token carries no identity beyond "valid".
-
-
-def _b64u(raw: bytes) -> str:
- return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
-
-
-def _b64u_decode(s: str) -> bytes:
- pad = "=" * (-len(s) % 4)
- return base64.urlsafe_b64decode(s + pad)
+ ttl = ttl if ttl > 0 else DEFAULT_TTL_S
+ path = Path(state_root) / "_secrets" / "web-access.json" if state_root is not None else None
+ with _file_guard(path):
+ existed = path is not None and path.exists()
+ if existed:
+ data = _read_private(path)
+ os.chmod(path, 0o600)
+ else:
+ data = {"version": 1, "mode": "environment", "password_hash": "", "environment_hash": "",
+ "signing_key": _b64u(secrets.token_bytes(32)), "revision": secrets.token_hex(24),
+ "revoked": {}, "secret_fingerprint": ""}
+ env_changed = bool(password) != bool(data["environment_hash"]) or bool(password and not _matches_hash(data["environment_hash"], password))
+ secret_fingerprint = hashlib.sha256(explicit.encode()).hexdigest() if explicit else ""
+ if env_changed:
+ data["environment_hash"] = _password_hash(password) if password else ""
+ secret_changed = secret_fingerprint != data["secret_fingerprint"]
+ if (env_changed and data["mode"] == "environment") or secret_changed:
+ data["revision"] = secrets.token_hex(24)
+ data["revoked"] = {}
+ data["secret_fingerprint"] = secret_fingerprint
+ if path is not None and (not existed or env_changed or secret_changed):
+ _write_private(path, data)
+ key = _b64u_decode(data["signing_key"])
+ # Explicit secrets supplement the per-installation random key.
+ secret = hmac.digest(key, explicit.encode(), "sha256")
+ return cls(bind_host=(os.environ.get(BIND_ENV) or "").strip(), ttl_s=ttl,
+ secret=secret, service_id=service_id, _explicit_secret=explicit, _path_version=_file_version(path) if path is not None else None, ui_bind_host=(os.environ.get("MUTEKI_UI_HOST") or "").strip(), _path=path, _state=data)
+
+ def refresh(self) -> None:
+ if self._path is None:
+ return
+ with self._lock:
+ try:
+ version = _file_version(self._path)
+ except OSError as exc:
+ raise RuntimeError("访问配置不可用,服务已拒绝继续认证。") from exc
+ if version == self._path_version:
+ return
+ data = _read_private(self._path)
+ fingerprint = hashlib.sha256(self._explicit_secret.encode()).hexdigest() if self._explicit_secret else ""
+ if fingerprint != data["secret_fingerprint"]:
+ raise RuntimeError("认证签名环境已变更,请重启当前服务进程。")
+ self.secret = hmac.digest(_b64u_decode(data["signing_key"]), self._explicit_secret.encode(), "sha256")
+ self._state, self._path_version = data, version
+ @property
+ def can_disable(self) -> bool:
+ return os.environ.get("MUTEKI_MANAGED_DESKTOP") != "1" and is_loopback_host(self.bind_host) and is_loopback_host(self.ui_bind_host)
-def _sign(secret: bytes, payload: bytes) -> bytes:
- return hmac.new(secret, payload, hashlib.sha256).digest()
+ def fail_fast_check(self) -> None:
+ if not self.enabled and not self.can_disable:
+ raise RuntimeError(f"Refusing to start: non-loopback bind {self.bind_host!r} requires an access password. Set {PASSWORD_ENV}, or configure a password in Settings on loopback first.")
+
+ def settings(self) -> dict[str, Any]:
+ with self._lock:
+ self.refresh()
+ return {"auth_required": self.enabled, "source": self.source if self.enabled else "none",
+ "environment_configured": bool(self._state.get("environment_hash") or self.password),
+ "has_override": self.source == "settings", "session_ttl_seconds": self.ttl_s,
+ "can_disable": self.can_disable, "service_id": self.service_id}
+
+ def update_password(self, *, action: str, current_password: str = "", new_password: str = "") -> None:
+ with self._lock, _file_guard(self._path):
+ self.refresh()
+ if (self.effective_hash or self.password) and not check_password(self, current_password):
+ raise AccessSettingsError("auth.current_password_invalid", "当前密码不正确。", "current_password", 403)
+ if action == "set":
+ try:
+ valid_password = isinstance(new_password, str) and len(new_password) >= 8 and len(new_password.encode()) <= 1024
+ except UnicodeError:
+ valid_password = False
+ if not valid_password:
+ raise AccessSettingsError("auth.password_invalid", "新密码需为 8 个字符以上,且不超过 1024 字节。", "new_password", 422)
+ password_hash = _password_hash(new_password)
+ mode = "settings"
+ elif action == "inherit":
+ if not self._state.get("environment_hash") and not self.can_disable:
+ raise AccessSettingsError("auth.password_required", "当前服务对外开放,恢复环境变量前需先配置环境变量密码。", "action", 409)
+ password_hash, mode = "", "environment"
+ else:
+ raise AccessSettingsError("auth.action_invalid", "请选择设置密码或恢复环境变量。", "action", 422)
+ candidate = {**self._state, "mode": mode, "password_hash": password_hash,
+ "revision": secrets.token_hex(24), "revoked": {}}
+ if self._path is not None:
+ _write_private(self._path, candidate)
+ self._path_version = _file_version(self._path)
+ self._state = candidate
+
+ def revoke(self, token: str | None) -> None:
+ with self._lock, _file_guard(self._path):
+ self.refresh()
+ payload = token_payload(self, token)
+ if payload is None:
+ return
+ revoked = {k: v for k, v in self._state.get("revoked", {}).items() if v > time.time()}
+ revoked[payload["jti"]] = payload["exp"]
+ candidate = {**self._state, "revoked": revoked}
+ if self._path is not None:
+ _write_private(self._path, candidate)
+ self._path_version = _file_version(self._path)
+ self._state = candidate
+
+ def allow_login(self, peer: str) -> bool:
+ with self._lock:
+ now = time.monotonic()
+ self._failures = {k: [ts for ts in v if now - ts < 60] for k, v in self._failures.items() if v and now - v[-1] < 60}
+ return len(self._failures.get(peer, [])) < 10 and len(self._failures) < 2048
+
+ def login_failed(self, peer: str) -> None:
+ with self._lock:
+ self._failures.setdefault(peer, []).append(time.monotonic())
+
+
+class AccessSettingsError(ValueError):
+ def __init__(self, code: str, message: str, field: str, status: int):
+ super().__init__(message)
+ self.code, self.field, self.status = code, field, status
def issue_token(cfg: AuthConfig, *, now: Optional[float] = None) -> str:
- """Mint a signed session token valid for cfg.ttl_s seconds."""
ts = int(now if now is not None else time.time())
- exp = ts + cfg.ttl_s
- payload = b"v1\x00" + str(exp).encode("ascii")
- sig = _sign(cfg.secret, payload)
- return _b64u(payload) + "." + _b64u(sig)
+ with cfg._lock:
+ payload = json.dumps({"v": 2, "aud": cfg.service_id, "rev": cfg.revision,
+ "iat": ts, "exp": ts + cfg.ttl_s, "jti": secrets.token_urlsafe(24)}, separators=(",", ":")).encode()
+ return _b64u(payload) + "." + _b64u(hmac.digest(cfg.secret, payload, "sha256"))
-def verify_token(cfg: AuthConfig, token: Optional[str], *,
- now: Optional[float] = None) -> bool:
- """Constant-time verify a session token's signature and expiry."""
- if not token:
- return False
+def token_payload(cfg: AuthConfig, token: str | None, *, now: float | None = None) -> dict[str, Any] | None:
+ if not isinstance(token, str) or not token or len(token) > 4096:
+ return None
try:
payload_b64, sig_b64 = token.split(".", 1)
- payload = _b64u_decode(payload_b64)
- sig = _b64u_decode(sig_b64)
- except (ValueError, Exception): # malformed token
- return False
- expected = _sign(cfg.secret, payload)
- if not hmac.compare_digest(sig, expected):
- return False
- if not payload.startswith(b"v1\x00"):
- return False
- try:
- exp = int(payload[3:].decode("ascii"))
- except (ValueError, UnicodeDecodeError):
- return False
- ts = now if now is not None else time.time()
- return ts < exp
+ payload, sig = _b64u_decode(payload_b64), _b64u_decode(sig_b64)
+ with cfg._lock:
+ cfg.refresh()
+ if not hmac.compare_digest(sig, hmac.digest(cfg.secret, payload, "sha256")):
+ return None
+ data = json.loads(payload)
+ ts = now if now is not None else time.time()
+ if (data.get("v") != 2 or data.get("aud") != cfg.service_id or data.get("rev") != cfg.revision
+ or not isinstance(data.get("exp"), int) or not isinstance(data.get("iat"), int)
+ or not isinstance(data.get("jti"), str) or not data["jti"]
+ or ts >= data["exp"] or data["iat"] > ts + 30
+ or data["jti"] in cfg._state.get("revoked", {})):
+ return None
+ return data
+ except (ValueError, TypeError, AttributeError):
+ return None
+
+
+def verify_token(cfg: AuthConfig, token: Optional[str], *, now: Optional[float] = None) -> bool:
+ return token_payload(cfg, token, now=now) is not None
def check_password(cfg: AuthConfig, password: Optional[str]) -> bool:
- """Constant-time password comparison (avoid timing oracle on length/prefix)."""
- if not cfg.password:
+ if not isinstance(password, str):
return False
- return hmac.compare_digest(
- (password or "").encode("utf-8"), cfg.password.encode("utf-8"))
+ try:
+ if len(password.encode("utf-8")) > 1024:
+ return False
+ except UnicodeError:
+ return False
+ with cfg._lock:
+ cfg.refresh()
+ if cfg.effective_hash:
+ return _matches_hash(cfg.effective_hash, password)
+ return bool(cfg.password) and hmac.compare_digest(password.encode(), cfg.password.encode())
def bearer_from_header(authorization: Optional[str]) -> Optional[str]:
- """Extract the token from an `Authorization: Bearer ` header."""
if not authorization:
return None
parts = authorization.split(None, 1)
- if len(parts) == 2 and parts[0].lower() == "bearer":
- return parts[1].strip()
- return None
+ return parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else None
+
+
+def request_token(cfg: AuthConfig, request: Any) -> str | None:
+ return bearer_from_header(request.headers.get("Authorization")) or request.cookies.get(cfg.cookie_name)
-# ---------------------------------------------------------------------------
-# One-time tickets for SSE / WebSocket
-# ---------------------------------------------------------------------------
-# A browser EventSource / WebSocket cannot set an Authorization header. Rather
-# than smuggle the long-lived session token through the URL query string (which
-# leaks into referers, history and proxy logs), an authenticated client mints a
-# single-use, short-TTL ticket and redeems it once on connect.
+def allowed_web_origins() -> set[str]:
+ port = os.environ.get("MUTEKI_UI_PORT", "3001")
+ origins = {f"http://127.0.0.1:{port}", f"http://localhost:{port}", f"http://[::1]:{port}"}
+ origins.update(value.strip().rstrip("/") for value in os.environ.get("MUTEKI_WEB_ORIGINS", "").split(",") if value.strip())
+ return origins
+
+
+def browser_request_error(cfg: AuthConfig, request: Any) -> str | None:
+ """Bearer clients are independent of cookies. Browser mutations require CSRF proof."""
+ if request.method in {"GET", "HEAD", "OPTIONS"}:
+ return None
+ origin = request.headers.get("origin")
+ if origin and origin != str(request.base_url).rstrip("/") and origin not in allowed_web_origins():
+ return "请求来源不受信任,请从工作台页面重试。"
+ cookie = request.cookies.get(cfg.cookie_name)
+ if cookie and not bearer_from_header(request.headers.get("Authorization")) and request.headers.get("X-Muteki-CSRF") != "1":
+ return "缺少访问校验,请刷新工作台后重试。"
+ if origin and not bearer_from_header(request.headers.get("Authorization")) and request.headers.get("X-Muteki-CSRF") != "1":
+ return "缺少访问校验,请刷新工作台后重试。"
+ return None
@dataclass
class TicketStore:
ttl_s: float = 30.0
- _tickets: dict[str, float] = field(default_factory=dict) # ticket -> expiry
+ _tickets: dict[str, float] = field(default_factory=dict)
+ _bindings: dict[str, str] = field(default_factory=dict, repr=False)
- def mint(self, *, now: Optional[float] = None) -> str:
+ def mint(self, *, now: Optional[float] = None, token: str | None = None) -> str:
ts = now if now is not None else time.time()
self._evict(ts)
ticket = secrets.token_urlsafe(32)
self._tickets[ticket] = ts + self.ttl_s
+ if token:
+ self._bindings[ticket] = token
return ticket
- def redeem(self, ticket: Optional[str], *, now: Optional[float] = None) -> bool:
- """Consume a ticket. Single-use: a redeemed ticket is removed."""
+ def redeem(self, ticket: Optional[str], *, now: Optional[float] = None, scope: dict | None = None) -> bool:
if not ticket:
return False
ts = now if now is not None else time.time()
self._evict(ts)
exp = self._tickets.pop(ticket, None)
- if exp is None:
- return False
- return ts < exp
+ token = self._bindings.pop(ticket, None)
+ valid = exp is not None and ts < exp
+ if valid and token and scope is not None:
+ scope["muteki_auth_session"] = token
+ return valid
+
+ def clear(self) -> None:
+ self._tickets.clear()
+ self._bindings.clear()
def _evict(self, now: float) -> None:
- if len(self._tickets) < 256:
- # cheap fast-path; only sweep when the table grows
- stale = [k for k, e in self._tickets.items() if e <= now]
- else:
- stale = [k for k, e in self._tickets.items() if e <= now]
- for k in stale:
- self._tickets.pop(k, None)
+ for key in [k for k, expiry in self._tickets.items() if expiry <= now]:
+ self._tickets.pop(key, None)
+ self._bindings.pop(key, None)
+
+
+class _SessionEnded(Exception):
+ pass
+
+
+class ServiceSessionMiddleware:
+ """Expire established SSE/WS sessions as well as subsequent API requests."""
+
+ def __init__(self, app: Any, config: AuthConfig):
+ self.app, self.config = app, config
+
+ async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
+ if scope["type"] not in {"http", "websocket"} or not scope.get("path", "").startswith("/api/"):
+ await self.app(scope, receive, send)
+ return
+ cfg = self.config
+ revision = cfg.revision
+ connection = HTTPConnection(scope)
+ query = parse_qs(scope.get("query_string", b"").decode("ascii", errors="replace"))
+ token = request_token(cfg, connection) or (query.get("token") or [None])[0]
+ started, ended, revoked = False, False, False
+ monitor = None
+ owner = asyncio.current_task()
+
+ def valid() -> bool:
+ with cfg._lock:
+ try:
+ return revision == cfg.revision and (not cfg.enabled or verify_token(cfg, scope.get("muteki_auth_session") or token))
+ except RuntimeError as exc:
+ logging.getLogger(__name__).error("auth.config_unavailable: %s", exc)
+ return False
+
+ async def watch() -> None:
+ nonlocal revoked
+ while True:
+ await asyncio.sleep(1)
+ if not valid():
+ revoked = True
+ owner.cancel()
+ return
+
+ async def guarded_send(message: dict) -> None:
+ nonlocal started, ended, monitor
+ if message["type"] == "websocket.accept" or (message["type"] == "http.response.start"
+ and any(name.lower() == b"content-type" and b"text/event-stream" in value for name, value in message.get("headers", []))):
+ started = True
+ monitor = asyncio.create_task(watch())
+ if started and message["type"] in {"http.response.body", "websocket.send"} and not valid():
+ raise _SessionEnded()
+ await send(message)
+ if message["type"] == "websocket.close" or (message["type"] == "http.response.body" and not message.get("more_body", False)):
+ ended = True
+
+ async def close() -> None:
+ if not started or ended:
+ return
+ if scope["type"] == "websocket":
+ await send({"type": "websocket.close", "code": 4401, "reason": "Session expired"})
+ else:
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
+
+ try:
+ await self.app(scope, receive, guarded_send)
+ except _SessionEnded:
+ await close()
+ except asyncio.CancelledError:
+ if not revoked:
+ raise
+ await close()
+ finally:
+ if monitor is not None:
+ monitor.cancel()
+ await asyncio.gather(monitor, return_exceptions=True)
diff --git a/apps/web/capability_management_api.py b/apps/web/capability_management_api.py
index 7692add..bc34a6f 100644
--- a/apps/web/capability_management_api.py
+++ b/apps/web/capability_management_api.py
@@ -36,6 +36,7 @@
from muteki.platform.contracts.objects import AgentSession, Thread
from muteki.platform.contracts.base import new_id
from muteki.platform.store import PlatformStore
+from muteki.conversation.computer_control import server_status as computer_server_status
from muteki.capability_management import (
enabled as capability_resource_enabled,
set_enabled as set_capability_resource_enabled,
@@ -403,6 +404,8 @@ async def overview() -> Any:
and _grant_state(item) == "active"
)
mcp_enabled = capability_resource_enabled("mcp", "muteki-control")
+ computer_enabled = capability_resource_enabled("mcp", "computer-use")
+ computer_status = computer_server_status()
blackboard_enabled = capability_resource_enabled(
"skills", "muteki-blackboard")
browser_enabled = capability_resource_enabled("skills", "agent-browser")
@@ -440,6 +443,20 @@ async def overview() -> Any:
"tools": len(tools),
"active_grants": active_mcp,
"lifecycle": "new_sessions",
+ }, {
+ "id": "computer-use",
+ "name": "聊天电脑操控(本机 Mac · Codex Computer Use)",
+ "enabled": computer_enabled,
+ "health": ("disabled" if not computer_enabled
+ else "ready" if computer_status["available"] else "unavailable"),
+ "source": "codex_plugin",
+ "scope": "local_mac",
+ "endpoint": "/api/capability",
+ "protocol_version": "2026-07-28",
+ "tools": computer_status["tools"],
+ "status": computer_status,
+ "active_grants": active_mcp,
+ "lifecycle": "new_sessions",
}],
"extension_sources": [
item for item in extensions
diff --git a/apps/web/chat_plugins_api.py b/apps/web/chat_plugins_api.py
index 71a4901..44c20b6 100644
--- a/apps/web/chat_plugins_api.py
+++ b/apps/web/chat_plugins_api.py
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
-from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException
@@ -62,6 +61,7 @@ async def listing(engine: str = "codex", mode: str = "chat", run_id: str = ""):
return {"engines": list(ENGINES), "modes": list(MODES), "engine": engine, "revision": plugins.revision(engine),
"host_discovery_enabled": host_discovery_enabled(),
"packages": [plugins.public(r) for r in plugins.records()], "native_skills": native,
+ "bundled_plugins": [{"id": "muteki-visualize", "name": "Muteki Visualize", "modes": ["chat"]}],
"builtin_skills": [{"id": "agent-browser", "modes": ["pentest"],
"enabled": capability_enabled("skills", "agent-browser"),
"scope": "new_workers"}],
@@ -71,38 +71,32 @@ async def listing(engine: str = "codex", mode: str = "chat", run_id: str = ""):
"runtime_mcp": plugins.runtime_mcp_health(engine, mode, run_id if mode != "chat" else "")}
@router.get("/visualizations/{thread_id}")
- async def visualization(thread_id: str, path: str):
+ async def visualization(thread_id: str, path: str, message_id: str = ""):
+ from muteki.conversation.visualizations import VisualizationError
+ from muteki.external_agents.factory import engine_for_adapter
thread = conversation.manager.get_thread(thread_id)
if thread is None or thread.mode != "conversation":
raise HTTPException(404, "对话不存在")
- root = plugins.visualization_root(thread_id)
try:
- target = Path(path).resolve(strict=True)
- if not target.is_relative_to(root) or target.suffix.lower() != ".html" or target.stat().st_size > 1_000_000:
- raise ValueError("invalid visualization")
- # An arbitrary path is not a file-reading API. The assistant must
- # have attached this exact path in this conversation's output.
- import json
- import re
- attached = False
- for message in conversation.conv.list_current_messages(thread_id):
- if message.role != "assistant":
- continue
- for marked, plain in re.findall(r"(?:visualize(\{[^\n]*?\})|^\s*(?:muteki-visualize|visualize)[ \t]+(\{[^\n]*\})[ \t]*$)", message.text or "", re.MULTILINE):
- raw = marked or plain
- try:
- if Path(json.loads(raw).get("path", "")).resolve() == target:
- attached = True
- except (ValueError, TypeError):
- continue
- if not attached:
- raise ValueError("not attached")
- from muteki.external_agents.factory import engine_for_adapter
engine = engine_for_adapter(conversation.manager.runtime_selection(thread_id).adapter_id)
- return {"html": await asyncio.to_thread(target.read_text, encoding="utf-8"),
- "assets": await asyncio.to_thread(plugins.visualization_assets, engine)}
- except (OSError, ValueError):
- raise HTTPException(404, "图形文件尚未生成或不属于当前对话") from None
+ return await asyncio.to_thread(
+ plugins.visualization_document, thread_id,
+ conversation.conv.list_current_messages(thread_id), engine, path, message_id,
+ )
+ except VisualizationError as exc:
+ raise HTTPException(404 if exc.code == "visualization.not_attached" else 422,
+ {"code": exc.code, "message": str(exc)}) from exc
+ except (OSError, ValueError) as exc:
+ raise HTTPException(500, {"code": "visualization.store_failed", "message": str(exc)}) from exc
+
+ @router.post("/bundled/muteki-visualize")
+ async def install_visualize():
+ try:
+ result = await asyncio.to_thread(plugins.install_visualize)
+ await changed()
+ return result
+ except ChatPluginError as exc:
+ raise HTTPException(400, {"code": exc.code, "message": str(exc)}) from exc
@router.post("/install")
async def install(body: InstallBody):
diff --git a/apps/web/conversation_api.py b/apps/web/conversation_api.py
index f46c2fc..8988721 100644
--- a/apps/web/conversation_api.py
+++ b/apps/web/conversation_api.py
@@ -13,8 +13,6 @@
GET /api/threads/{thread_id}/events (SSE:snapshot + watermark + after)
POST /api/threads/{thread_id}/commands (conversation.* 命令统一入口)
POST /api/threads/{thread_id}/uploads (multipart 上传 → artifact.attach)
- GET /api/agent-runtimes (委托 RUNTIME-05 AgentRuntimeService)
- POST /api/agent-runtimes/{instance_id}/probe
约束:
@@ -23,9 +21,7 @@
- SSE 用 snapshot + watermark + ``after`` sequence 恢复,事件来自
PlatformStore 的 Thread 聚合流(Public Event 白名单字段),不直接读取
Adapter 私有事件;
-- 最后两个 agent-runtimes 端点委托 RUNTIME-05 的 ``AgentRuntimeService``;
- 与 ``agent_runtime_api`` router 同路径,INTEG-01 挂载时二选一
- (``include_runtime_routes=False`` 可关闭本 router 内的这两条)。
+- Agent Runtime 端点由 ``agent_runtime_api`` router 提供。
"""
from __future__ import annotations
@@ -44,6 +40,7 @@
from fastapi import HTTPException, APIRouter, Body, Form, Request, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
+from apps.web.ui_preferences import UiPreferencesWrite
from muteki.platform.command_handlers.base import CommandAPIError
from muteki.platform.contracts.commands import (
@@ -60,12 +57,15 @@
resolve_composer_catalog,
)
from muteki.conversation.inbox import InboxBroker, collect_attention_rows
-from muteki.conversation.manager import ConversationError
-from muteki.conversation.module import ConversationService
-from muteki.external_agents.c24_cu_fixtures import (
- build_cu_fixture,
- fixture_id_from_title,
+from muteki.conversation.manager import (
+ INTERACTION_MODE_INVALID_CODE,
+ THREAD_NOT_FOUND_CODE,
+ TURN_NOT_FOUND_CODE,
+ ConversationError,
+ InteractionModeError,
)
+from muteki.conversation.module import CONVERSATION_PREFS_KEY, ConversationService
+from muteki.external_agents.descriptors import all_descriptors, find_descriptor
from muteki.external_agents.factory import engine_for_adapter
#: Web 传输入口的本地操作员身份(单操作员产品默认,与 RUNTIME-05 一致)。
@@ -92,6 +92,64 @@ def _github_pr_api_token() -> str:
return ""
return result.stdout.strip() if result.returncode == 0 else ""
+
+class GitHubAPIError(RuntimeError):
+ def __init__(self, status: int, message: str) -> None:
+ super().__init__(message)
+ self.status = status
+
+
+def _github_api_json(
+ method: str,
+ url: str,
+ token: str,
+ payload: dict[str, Any] | None = None,
+ *,
+ timeout: float = 15,
+) -> Any:
+ """Call the GitHub REST API; HTTP failures carry GitHub's own message."""
+ import urllib.error
+ import urllib.request
+
+ headers = {
+ "Accept": "application/vnd.github+json",
+ "User-Agent": "muteki/c15",
+ "X-GitHub-Api-Version": "2022-11-28",
+ }
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ data = None
+ if payload is not None:
+ data = json.dumps(payload).encode()
+ headers["Content-Type"] = "application/json"
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ return json.loads(response.read().decode() or "null")
+ except urllib.error.HTTPError as exc:
+ raw = exc.read().decode(errors="replace")
+ try:
+ parsed = json.loads(raw)
+ except ValueError:
+ parsed = None
+ parts: list[str] = []
+ if isinstance(parsed, dict):
+ if parsed.get("message"):
+ parts.append(str(parsed["message"]))
+ for item in parsed.get("errors") or []:
+ if isinstance(item, dict):
+ detail = item.get("message") or " ".join(
+ str(item[key]) for key in ("resource", "field", "code") if item.get(key)
+ )
+ if detail:
+ parts.append(str(detail))
+ elif item:
+ parts.append(str(item))
+ elif raw.strip():
+ parts.append(raw.strip())
+ message = "; ".join(parts) or (exc.reason and str(exc.reason)) or ""
+ raise GitHubAPIError(exc.code, f"GitHub API HTTP {exc.code}: {message}".rstrip(": ")) from exc
+
#: /commands 入口允许的命令命名空间(其余命令走各自领域 API)。
ALLOWED_COMMAND_PREFIX = "conversation."
@@ -187,6 +245,7 @@ class ComposerCapabilitiesBody(BaseModel):
thread_id: str = ""
adapter_id: str = Field(default="", max_length=120)
+ instance_id: str = Field(default="", max_length=120)
workspace_id: str = Field(default="", max_length=160)
project_id: str = Field(default="", max_length=160)
trigger: str = Field(pattern=r"^[/@$]$")
@@ -207,6 +266,32 @@ class ThreadGitCheckoutBody(BaseModel):
create: bool = False
+class ThreadGitCommitBody(BaseModel):
+ """暂存并提交 Thread 工作区改动;paths 为空时提交全部改动。"""
+
+ message: str = Field(default="", max_length=20_000)
+ paths: list[str] | None = Field(default=None, max_length=2_000)
+ amend: bool = False
+
+
+class ThreadGitPushBody(BaseModel):
+ """None 表示仅在分支没有上游时自动设置上游。"""
+
+ set_upstream: bool | None = None
+
+
+class ThreadGitPullBody(BaseModel):
+ rebase: bool = False
+
+
+class ThreadPullRequestCreateBody(BaseModel):
+ title: str = Field(default="", max_length=1_000)
+ body: str = Field(default="", max_length=65_000)
+ base: str = Field(default="", max_length=255)
+ draft: bool = False
+ push_first: bool = False
+
+
class WorkspaceDeleteWorktreeBody(BaseModel):
workspace_id: str = Field(min_length=1, max_length=160)
force: bool = False
@@ -317,22 +402,42 @@ def _composer_runtime_payload(
}
+def _session_import_source(engine: str) -> tuple[Any, Path] | None:
+ """Scanner and default host directory for a provider session import."""
+ from muteki.conversation.provider_import import scan_claude_sessions, scan_codex_sessions
+
+ descriptor = find_descriptor(engine)
+ if descriptor is None or descriptor.session_import == "none":
+ return None
+ scanner = {
+ "claude_projects": scan_claude_sessions,
+ "codex_sessions": scan_codex_sessions,
+ }[descriptor.session_import]
+ return scanner, Path.home() / descriptor.environment.home_relative
+
+
+def _session_import_invalid_message() -> str:
+ names = " 或 ".join(
+ item.identity.display_name for item in all_descriptors()
+ if item.session_import != "none"
+ )
+ return f"请选择 {names} 历史来源。"
+
+
def create_conversation_router(
*,
command_api: Any,
service: ConversationService,
- runtime_service: Any = None,
actor: ActorRef = OPERATOR,
register_handlers: bool = True,
- include_runtime_routes: bool = True,
sse_metrics: Any = None,
extension_service: Any = None,
+ browser_control: Any = None,
) -> Any:
"""构造 Conversation API router。
``register_handlers`` 为 True 时把 CONV-01 Handler 注册到该 Command
- API(幂等)。``runtime_service`` 为 RUNTIME-05 的
- ``AgentRuntimeService``;缺省时 agent-runtimes 两端点返回 503。
+ API(幂等)。
"""
if register_handlers:
service.register(command_api)
@@ -398,6 +503,7 @@ def _error_response(
*,
status_code: int,
recovery_hint: str = "",
+ detail: dict[str, Any] | None = None,
) -> JSONResponse:
error = ErrorEnvelope(
code=code,
@@ -405,6 +511,8 @@ def _error_response(
category=category,
recovery_hint=recovery_hint,
)
+ if detail:
+ error = error.model_copy(update={"detail": dict(detail)})
return JSONResponse(
{"error": error.model_dump(mode="json")},
status_code=status_code,
@@ -430,16 +538,7 @@ async def composer_capabilities(body: ComposerCapabilitiesBody) -> Any:
selection = service.manager.runtime_selection(thread.thread_id)
adapter_id = adapter_id or selection.adapter_id
workspace_id = str(thread.workspace_id or "")
- fixture_key = fixture_id_from_title(thread.title or "")
- if fixture_key:
- runtime_snapshot, matrix_from_fixture, _caps = build_cu_fixture(
- fixture_key
- )
- service.executor._capability_cache[thread.thread_id] = runtime_snapshot
- runtime_diagnostics.append(
- f"C24 CU fixture active: {fixture_key}"
- )
- elif adapter_id == selection.adapter_id:
+ if adapter_id == selection.adapter_id:
runtime_snapshot = service.executor.cached_runtime_capabilities(
thread.thread_id)
sel_iid = selection.instance_id or "default"
@@ -523,26 +622,19 @@ async def composer_capabilities(body: ComposerCapabilitiesBody) -> Any:
matrix_payload = None
try:
if body.thread_id:
- thread_for_matrix = service.manager.get_thread(body.thread_id)
- fixture_key = fixture_id_from_title(
- thread_for_matrix.title if thread_for_matrix else ""
+ # Matrix identity must follow the requested Runtime, not a
+ # stale thread-scoped snapshot from another Provider/instance.
+ sel = service.manager.runtime_selection(body.thread_id)
+ matrix_iid = (
+ (sel.instance_id or "default")
+ if adapter_id == (sel.adapter_id or "")
+ else "default"
+ )
+ matrix_payload = service.executor.interaction_matrix_for(
+ body.thread_id,
+ adapter_id=adapter_id,
+ instance_id=matrix_iid,
)
- if fixture_key and runtime_snapshot is not None:
- matrix_payload = runtime_snapshot.public_matrix()
- else:
- # Matrix identity must follow the requested Runtime, not a
- # stale thread-scoped snapshot from another Provider/instance.
- sel = service.manager.runtime_selection(body.thread_id)
- matrix_iid = (
- (sel.instance_id or "default")
- if adapter_id == (sel.adapter_id or "")
- else "default"
- )
- matrix_payload = service.executor.interaction_matrix_for(
- body.thread_id,
- adapter_id=adapter_id,
- instance_id=matrix_iid,
- )
elif runtime_snapshot is not None and runtime_snapshot.matrix is not None:
matrix_payload = runtime_snapshot.public_matrix()
except Exception as exc:
@@ -552,6 +644,12 @@ async def composer_capabilities(body: ComposerCapabilitiesBody) -> Any:
return {
"engine": engine,
"trigger": body.trigger,
+ **service.manager.access_mode_availability(
+ adapter_id,
+ body.instance_id or ((selection.instance_id or "default")
+ if body.thread_id and adapter_id == selection.adapter_id else "default"),
+ str(workspace.root_path) if workspace is not None else "",
+ ),
"items": items,
"count": len(items),
"partial": bool(section_errors),
@@ -916,7 +1014,17 @@ async def get_thread(
except CommandAPIError as exc:
status = 404 if exc.error.category is ErrorCategory.NOT_FOUND else 400
return JSONResponse(_error_body(exc), status_code=status)
- return result.result
+ view = result.result
+ # Subagent Threads surface a lineage banner; resolve the parent title
+ # here so the UI does not need a second lookup.
+ lineage = (view.get("state") or {}).get("lineage")
+ if lineage:
+ parent = service.manager.get_thread(str(lineage.get("parent_thread_id") or ""))
+ view["lineage"] = {
+ **lineage,
+ "parent_title": (parent.title if parent is not None else "") or None,
+ }
+ return view
@router.get("/api/threads/{thread_id}/impact-preview")
async def impact_preview(
@@ -935,12 +1043,12 @@ async def impact_preview(
file_mode=file_mode,
)
except ConversationError as exc:
- status = 404 if "unknown" in str(exc).lower() or "不在" in str(exc) else 400
+ not_found = exc.code in {THREAD_NOT_FOUND_CODE, TURN_NOT_FOUND_CODE}
return _error_response(
- "conversation.impact.preview_failed",
+ exc.code if not_found else "conversation.impact.preview_failed",
str(exc),
- ErrorCategory.VALIDATION,
- status_code=status,
+ ErrorCategory.NOT_FOUND if not_found else ErrorCategory.VALIDATION,
+ status_code=404 if not_found else 400,
)
@router.get("/api/threads/{thread_id}/messages")
@@ -971,7 +1079,7 @@ async def get_turn_process(
turn_id: str,
limit: int = 2000,
after_seq: int = 0,
- watermark: Optional[int] = None,
+ watermark: int | None = None,
) -> Any:
try:
result = await command_api.query(_query(
@@ -1214,6 +1322,272 @@ async def thread_git_checkout(
**_occupancy_payload(root, exclude_thread_id=thread_id),
}
+ _GIT_WRITE_ERRORS: dict[str, tuple[ErrorCategory, int]] = {
+ "conversation.git.empty_message": (ErrorCategory.VALIDATION, 400),
+ "conversation.git.invalid_path": (ErrorCategory.VALIDATION, 400),
+ "conversation.git.path_outside": (ErrorCategory.VALIDATION, 400),
+ "conversation.git.invalid_branch": (ErrorCategory.VALIDATION, 400),
+ "conversation.git.stage_failed": (ErrorCategory.VALIDATION, 400),
+ "conversation.git.commit_failed": (ErrorCategory.VALIDATION, 400),
+ "conversation.git.not_repo": (ErrorCategory.STATE, 409),
+ "conversation.git.detached_head": (ErrorCategory.STATE, 409),
+ "conversation.git.no_remote": (ErrorCategory.STATE, 409),
+ "conversation.git.no_upstream": (ErrorCategory.STATE, 409),
+ "conversation.git.nothing_to_commit": (ErrorCategory.CONFLICT, 409),
+ "conversation.git.push_failed": (ErrorCategory.CONFLICT, 409),
+ "conversation.git.pull_failed": (ErrorCategory.CONFLICT, 409),
+ "conversation.git.pull_conflict": (ErrorCategory.CONFLICT, 409),
+ "conversation.git.timeout": (ErrorCategory.TIMEOUT, 504),
+ }
+ _GIT_WRITE_HINTS: dict[str, str] = {
+ "conversation.git.pull_conflict": "在终端中解决冲突后继续(git rebase --continue / git merge --continue),或中止该操作",
+ "conversation.git.timeout": "检查网络与凭据配置(服务端不会弹出凭据输入)",
+ }
+
+ def _git_write_error(exc: Any) -> JSONResponse:
+ code = str(getattr(exc, "code", "") or "conversation.git.error")
+ category, status_code = _GIT_WRITE_ERRORS.get(code, (ErrorCategory.VALIDATION, 400))
+ return _error_response(
+ code,
+ str(exc),
+ category,
+ status_code=status_code,
+ recovery_hint=_GIT_WRITE_HINTS.get(code, ""),
+ )
+
+ def _thread_git_write_root(thread_id: str) -> tuple[str, Any] | JSONResponse:
+ """与 git/checkout 相同的归属检查:Thread 存在、已绑定工作区、根目录未被其他活动会话占用。"""
+ thread, workspace = _thread_workspace(thread_id)
+ if thread is None:
+ return _error_response(
+ "conversation.thread.not_found",
+ f"unknown thread: {thread_id}",
+ ErrorCategory.NOT_FOUND,
+ status_code=404,
+ )
+ root = str(workspace.root_path or "").strip() if workspace is not None else ""
+ if not root:
+ return _error_response(
+ "conversation.workspace.not_found",
+ "会话尚未绑定工作目录",
+ ErrorCategory.NOT_FOUND,
+ status_code=404,
+ )
+ occupancy = _occupancy_payload(root, exclude_thread_id=thread_id)
+ if occupancy["occupied"]:
+ return _error_response(
+ "conversation.git.root_occupied",
+ "工作区正被其他活动会话使用,拒绝执行 Git 写操作以免影响对方",
+ ErrorCategory.CONFLICT,
+ status_code=409,
+ recovery_hint="为当前会话新建独立 worktree",
+ )
+ return root, workspace
+
+ def _git_status_payload(thread_id: str, workspace: Any, root: str, status: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "thread_id": thread_id,
+ "workspace_id": workspace.workspace_id if workspace else None,
+ **status,
+ **_occupancy_payload(root, exclude_thread_id=thread_id),
+ }
+
+ @router.post("/api/threads/{thread_id}/git/commit")
+ async def thread_git_commit(thread_id: str, body: ThreadGitCommitBody) -> Any:
+ """暂存并提交 Thread 工作区改动(全部或指定工作区内路径)。"""
+ from muteki.conversation.git_workspace import GitWorkspaceError, commit_git_changes
+
+ resolved = _thread_git_write_root(thread_id)
+ if isinstance(resolved, JSONResponse):
+ return resolved
+ root, workspace = resolved
+ try:
+ result = await asyncio.to_thread(
+ commit_git_changes,
+ root,
+ message=body.message,
+ paths=body.paths,
+ amend=body.amend,
+ )
+ except GitWorkspaceError as exc:
+ return _git_write_error(exc)
+ status = result.pop("status")
+ return {**result, "git": _git_status_payload(thread_id, workspace, root, status)}
+
+ @router.post("/api/threads/{thread_id}/git/push")
+ async def thread_git_push(thread_id: str, body: ThreadGitPushBody | None = None) -> Any:
+ """推送当前分支;分支没有上游时自动设置上游。"""
+ from muteki.conversation.git_workspace import GitWorkspaceError, push_git_branch
+
+ resolved = _thread_git_write_root(thread_id)
+ if isinstance(resolved, JSONResponse):
+ return resolved
+ root, workspace = resolved
+ try:
+ options = body or ThreadGitPushBody()
+ result = await asyncio.to_thread(push_git_branch, root, set_upstream=options.set_upstream)
+ except GitWorkspaceError as exc:
+ return _git_write_error(exc)
+ status = result.pop("status")
+ return {
+ **result,
+ "ahead": status.get("ahead"),
+ "behind": status.get("behind"),
+ "upstream": status.get("upstream"),
+ "git": _git_status_payload(thread_id, workspace, root, status),
+ }
+
+ @router.post("/api/threads/{thread_id}/git/pull")
+ async def thread_git_pull(thread_id: str, body: ThreadGitPullBody | None = None) -> Any:
+ """拉取上游;默认仅快进(--ff-only),rebase=true 时使用 --rebase。"""
+ from muteki.conversation.git_workspace import GitWorkspaceError, pull_git_branch
+
+ resolved = _thread_git_write_root(thread_id)
+ if isinstance(resolved, JSONResponse):
+ return resolved
+ root, workspace = resolved
+ try:
+ options = body or ThreadGitPullBody()
+ result = await asyncio.to_thread(pull_git_branch, root, rebase=options.rebase)
+ except GitWorkspaceError as exc:
+ return _git_write_error(exc)
+ status = result.pop("status")
+ return {
+ **result,
+ "ahead": status.get("ahead"),
+ "behind": status.get("behind"),
+ "git": _git_status_payload(thread_id, workspace, root, status),
+ }
+
+ @router.post("/api/threads/{thread_id}/git/pull-request")
+ async def thread_create_pull_request(thread_id: str, body: ThreadPullRequestCreateBody) -> Any:
+ """为 Thread 工作区当前分支创建 GitHub PR(使用操作员的 GitHub token)。"""
+ from muteki.conversation.git_workspace import (
+ GitWorkspaceError,
+ detect_github_remote,
+ inspect_git_workspace,
+ push_git_branch,
+ upstream_remote_branch,
+ )
+
+ title = body.title.strip()
+ if not title:
+ return _error_response(
+ "conversation.github.empty_title",
+ "PR 标题不能为空",
+ ErrorCategory.VALIDATION,
+ status_code=400,
+ )
+ resolved = _thread_git_write_root(thread_id)
+ if isinstance(resolved, JSONResponse):
+ return resolved
+ root, _workspace = resolved
+
+ try:
+ origin = await asyncio.to_thread(detect_github_remote, root)
+ except GitWorkspaceError as exc:
+ return _git_write_error(exc)
+ if not origin["remote_url"]:
+ return _error_response(
+ "conversation.git.no_remote", "仓库未配置远端 origin", ErrorCategory.STATE, status_code=409,
+ )
+ if not origin["github"]:
+ return _error_response(
+ "conversation.github.not_github",
+ f"远端 origin 不是 GitHub 仓库:{origin['remote_url']}",
+ ErrorCategory.STATE,
+ status_code=409,
+ )
+ token = await asyncio.to_thread(_github_pr_api_token)
+ if not token:
+ return _error_response(
+ "conversation.github.token_missing",
+ "未配置 GitHub token",
+ ErrorCategory.PERMISSION,
+ status_code=403,
+ recovery_hint="设置 MUTEKI_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN,或在服务端运行 gh auth login",
+ )
+
+ pushed_now = False
+ try:
+ status = await asyncio.to_thread(inspect_git_workspace, root)
+ needs_push = not status.get("upstream") or bool(status.get("ahead"))
+ if needs_push:
+ if not body.push_first:
+ return _error_response(
+ "conversation.git.push_required",
+ "分支尚未推送到远端(或有未推送的提交),请先推送再创建 PR",
+ ErrorCategory.STATE,
+ status_code=409,
+ recovery_hint="先推送,或以 push_first=true 重新请求",
+ )
+ await asyncio.to_thread(push_git_branch, root)
+ pushed_now = True
+ tracking = await asyncio.to_thread(upstream_remote_branch, root)
+ head_remote = tracking["remote"] or "origin"
+ head_info = (
+ origin if head_remote == "origin"
+ else await asyncio.to_thread(detect_github_remote, root, head_remote)
+ )
+ except GitWorkspaceError as exc:
+ return _git_write_error(exc)
+
+ owner, repo = origin["owner"], origin["repo"]
+ remote_branch = tracking["remote_branch"] or tracking["branch"]
+ if head_info.get("github") and head_info["owner"] and head_info["owner"] != owner:
+ head = f"{head_info['owner']}:{remote_branch}"
+ else:
+ head = remote_branch
+
+ api = f"https://api.github.com/repos/{owner}/{repo}"
+ try:
+ base = body.base.strip()
+ if not base:
+ repo_info = await asyncio.to_thread(_github_api_json, "GET", api, token)
+ base = str((repo_info or {}).get("default_branch") or "").strip()
+ if not base:
+ return _error_response(
+ "conversation.github.base_unknown",
+ "无法确定仓库默认分支,请指定 base",
+ ErrorCategory.VALIDATION,
+ status_code=400,
+ )
+ created = await asyncio.to_thread(
+ _github_api_json,
+ "POST",
+ f"{api}/pulls",
+ token,
+ {"title": title, "head": head, "base": base, "body": body.body, "draft": body.draft},
+ )
+ except GitHubAPIError as exc:
+ if exc.status == 422:
+ category, status_code, code = ErrorCategory.CONFLICT, 409, "conversation.github.pr_rejected"
+ elif exc.status in (401, 403, 404):
+ category, status_code, code = ErrorCategory.PERMISSION, 403, "conversation.github.forbidden"
+ else:
+ category, status_code, code = ErrorCategory.RUNTIME, 502, "conversation.github.api_failed"
+ return _error_response(code, str(exc), category, status_code=status_code)
+ except Exception as exc:
+ return _error_response(
+ "conversation.github.api_failed",
+ f"GitHub API 请求失败:{exc}",
+ ErrorCategory.RUNTIME,
+ status_code=502,
+ )
+
+ created = created if isinstance(created, dict) else {}
+ return {
+ "number": created.get("number"),
+ "url": created.get("html_url", ""),
+ "state": "draft" if created.get("draft") else created.get("state", ""),
+ "title": created.get("title", title),
+ "base": base,
+ "head": head,
+ "owner": owner,
+ "repo": repo,
+ "pushed": pushed_now,
+ }
+
def _surface_error(message: str, status_code: int = 400) -> JSONResponse:
return _error_response(
"conversation.workspace.surface_failed",
@@ -1441,7 +1815,7 @@ async def terminal_session_stream(
cfg: AuthConfig = websocket.app.state.auth
if cfg.enabled:
ticket_store = websocket.app.state.tickets
- authed = ticket_store.redeem(websocket.query_params.get("ticket")) or verify_token(
+ authed = ticket_store.redeem(websocket.query_params.get("ticket"), scope=websocket.scope) or verify_token(
cfg,
websocket.query_params.get("token")
or bearer_from_header(websocket.headers.get("Authorization")),
@@ -1603,7 +1977,7 @@ async def delete_memory(
@router.get("/api/threads/{thread_id}/events")
async def thread_events(
thread_id: str, request: Request, after: int = 0,
- snapshot: bool = True,
+ snapshot: bool = True, browser_host: str = "",
) -> Any:
"""Thread 事件流(SSE)。
@@ -1651,23 +2025,36 @@ def _frame(event_name: str, data: Any, event_id: int = 0) -> bytes:
# Last-Event-ID 后跳过本轮已产生的工具/正文事件。
# 客户端应在重连时传入 after=<已应用水位>;snapshot 仍可刷新视图。
cursor = max(0, int(after) if snapshot else resume_after)
- while True:
- if await request.is_disconnected():
- return
- page = store.public_events_for(
- conv_events.AGGREGATE_THREAD, thread_id,
- after_seq=cursor, limit=SSE_PAGE_LIMIT)
- sent = False
- for seq, event in page:
- cursor = max(cursor, seq)
- payload = event.model_dump(mode="json")
- payload["seq"] = seq
- yield _frame("event", payload, seq)
- sent = True
- if not sent:
- # 心跳注释行,保持连接并便于代理保活。
- yield b": heartbeat\n\n"
- await asyncio.sleep(SSE_POLL_SECONDS)
+ # 右侧浏览器请求不入事件日志:无 id 行,不影响 Last-Event-ID 水位。
+ browser = (browser_control.subscribe(thread_id, browser_host)
+ if browser_control is not None and browser_host in {"desktop", "web"} else None)
+ try:
+ while True:
+ if await request.is_disconnected():
+ return
+ sent = False
+ for item in browser.drain() if browser else ():
+ yield _frame("browser_request", item)
+ sent = True
+ page = store.public_events_for(
+ conv_events.AGGREGATE_THREAD, thread_id,
+ after_seq=cursor, limit=SSE_PAGE_LIMIT)
+ for seq, event in page:
+ cursor = max(cursor, seq)
+ payload = event.model_dump(mode="json")
+ payload["seq"] = seq
+ yield _frame("event", payload, seq)
+ sent = True
+ if not sent:
+ # 心跳注释行,保持连接并便于代理保活。
+ yield b": heartbeat\n\n"
+ if browser:
+ await browser.wait(SSE_POLL_SECONDS)
+ else:
+ await asyncio.sleep(SSE_POLL_SECONDS)
+ finally:
+ if browser:
+ browser_control.unsubscribe(browser)
async def _stream():
if sse_metrics is not None:
@@ -1686,6 +2073,18 @@ async def _stream():
"X-Accel-Buffering": "no"},
)
+ @router.post("/api/threads/{thread_id}/browser/{request_id}")
+ async def browser_result(thread_id: str, request_id: str, body: dict[str, Any] = Body(...)) -> Any:
+ """客户端回传右侧浏览器请求的执行结果。"""
+ if browser_control is None or not browser_control.resolve(thread_id, request_id, body):
+ return _error_response(
+ "chat.browser.request_unknown",
+ "浏览器请求不存在、已超时或已有结果",
+ ErrorCategory.NOT_FOUND,
+ status_code=404,
+ )
+ return {"accepted": True}
+
# -- 命令统一入口(conversation.*) ---------------------------------------------
@router.post("/api/threads/{thread_id}/commands")
@@ -2054,21 +2453,17 @@ async def import_scan(
limit: int = 100,
) -> Any:
from pathlib import Path as _Path
- from muteki.conversation.provider_import import (
- scan_claude_sessions, scan_codex_sessions, scan_to_dict,
- )
- if adapter not in {"claude", "codex"}:
- return _error_response("conversation.import.provider_invalid", "请选择 Claude 或 Codex 历史来源。", ErrorCategory.VALIDATION, status_code=400)
+ from muteki.conversation.provider_import import scan_to_dict
+ source = _session_import_source(adapter)
+ if source is None:
+ return _error_response("conversation.import.provider_invalid", _session_import_invalid_message(), ErrorCategory.VALIDATION, status_code=400)
+ scanner, default_base = source
if not path and os.environ.get("MUTEKI_HOST_DISCOVERY", "1").strip() == "0":
return _error_response("conversation.import.host_discovery_disabled", "服务宿主自动发现已禁用,请明确选择历史文件目录。", ErrorCategory.VALIDATION, status_code=409)
- base = _Path(path).expanduser() if path else (
- _Path.home() / ".claude" if adapter == "claude"
- else _Path.home() / ".codex"
- )
+ base = _Path(path).expanduser() if path else default_base
if not base.exists():
return JSONResponse({"scans": [], "base_path": str(base), "exists": False})
limit = max(1, min(limit, 500))
- scanner = scan_claude_sessions if adapter == "claude" else scan_codex_sessions
try:
scans = await asyncio.to_thread(scanner, base, limit=limit, retain_messages=False)
except ValueError as exc:
@@ -2082,12 +2477,12 @@ async def import_scan(
@router.post("/api/import/apply")
async def import_apply(body: dict[str, Any] = Body(...)) -> Any:
from pathlib import Path as _Path
- from muteki.conversation.provider_import import (
- scan_claude_sessions, scan_codex_sessions, batch_import,
- )
+ from muteki.conversation.provider_import import batch_import
adapter_id = str(body.get("adapter_id") or "claude")
- if adapter_id not in {"claude", "codex"}:
- return _error_response("conversation.import.provider_invalid", "请选择 Claude 或 Codex 历史来源。", ErrorCategory.VALIDATION, status_code=400)
+ source = _session_import_source(adapter_id)
+ if source is None:
+ return _error_response("conversation.import.provider_invalid", _session_import_invalid_message(), ErrorCategory.VALIDATION, status_code=400)
+ scanner, default_base = source
source_path = str(body.get("source_path") or "")
if not source_path and os.environ.get("MUTEKI_HOST_DISCOVERY", "1").strip() == "0":
return _error_response("conversation.import.host_discovery_disabled", "服务宿主自动发现已禁用,请明确选择历史文件目录。", ErrorCategory.VALIDATION, status_code=409)
@@ -2097,14 +2492,10 @@ async def import_apply(body: dict[str, Any] = Body(...)) -> Any:
project_id = str(body.get("project_id") or "")
dry_run = bool(body.get("dry_run", False))
- base = _Path(source_path).expanduser() if source_path else (
- _Path.home() / ".claude" if adapter_id == "claude"
- else _Path.home() / ".codex"
- )
+ base = _Path(source_path).expanduser() if source_path else default_base
if not base.exists():
return JSONResponse({"error": f"path not found: {base}"}, status_code=404)
- scanner = scan_claude_sessions if adapter_id == "claude" else scan_codex_sessions
try:
all_scans = await asyncio.to_thread(scanner, base, limit=500)
except ValueError as exc:
@@ -2131,6 +2522,24 @@ async def import_apply(body: dict[str, Any] = Body(...)) -> Any:
"total": result.total,
})
+ # UI preferences are shared by the service's operator (not a per-login user).
+ @router.get("/api/settings/ui")
+ async def get_ui_preferences() -> Any:
+ return JSONResponse(service.conv.get_sidebar_prefs("ui") or {"version": 0, "values": {}},
+ headers={"Cache-Control": "no-store"})
+
+ @router.put("/api/settings/ui")
+ async def put_ui_preferences(body: UiPreferencesWrite) -> Any:
+ from muteki.platform.store import OptimisticConcurrencyError
+ try:
+ saved = service.conv.save_sidebar_prefs(
+ {"values": body.values.model_dump(exclude_unset=True)},
+ key="ui", expected_version=body.version)
+ except OptimisticConcurrencyError:
+ return JSONResponse(service.conv.get_sidebar_prefs("ui") or {"version": 0, "values": {}},
+ status_code=409, headers={"Cache-Control": "no-store"})
+ return JSONResponse(saved, headers={"Cache-Control": "no-store"})
+
# -- Sidebar 偏好(C30)--------------------------------------------------
@router.get("/api/sidebar-preferences")
@@ -2153,62 +2562,135 @@ async def put_sidebar_preferences(body: dict[str, Any] = Body(...)) -> Any:
return JSONResponse(current, status_code=409)
return JSONResponse(saved)
- # -- Agent Runtime(委托 RUNTIME-05 的 service) ---------------------------------
-
- if include_runtime_routes:
-
- @router.get("/api/agent-runtimes")
- async def list_agent_runtimes(adapter_id: str = "") -> Any:
- if runtime_service is None:
- return _error_response(
- "conversation.runtime.unavailable",
- "AgentRuntimeService 未装配",
- ErrorCategory.RUNTIME,
- status_code=503,
- )
- instances = runtime_service.list_instances(adapter_id or None)
- return {"instances": instances, "count": len(instances)}
-
- @router.post("/api/agent-runtimes/{instance_id}/probe")
- async def probe_agent_runtime(instance_id: str) -> Any:
- if runtime_service is None:
- return _error_response(
- "conversation.runtime.unavailable",
- "AgentRuntimeService 未装配",
- ErrorCategory.RUNTIME,
- status_code=503,
- )
- # instance_id 形如 ``cli.kimi:default`` 或 bare instance id。
- from apps.web.agent_runtime_api import canonical_adapter_id
- from muteki.solver.worker_profiles import parse_runtime_instance_ref
+ # -- Conversation preferences the service acts on without an open window ----
- ref = parse_runtime_instance_ref(instance_id)
- if ref is not None:
- adapter_id, iid = ref
- else:
- adapter_id, iid = "", instance_id
- matches = [c for c in runtime_service.store.list()
- if c.instance_id == iid]
- if len(matches) == 1:
- adapter_id = matches[0].adapter_id
- adapter_id = canonical_adapter_id(adapter_id)
- if not adapter_id:
+ def _preference_body(
+ prefs: dict[str, Any],
+ *,
+ thread_id: str = "",
+ interaction_mode: str = "",
+ ) -> dict[str, Any]:
+ body: dict[str, Any] = {
+ "version": int(prefs.get("version") or 0),
+ "auto_resume_on_quota_reset": prefs.get("auto_resume_on_quota_reset") is True,
+ }
+ if thread_id:
+ body["thread_id"] = thread_id
+ body["interaction_mode"] = interaction_mode or "default"
+ return body
+
+ @router.get("/api/conversation/preferences")
+ async def get_conversation_preferences(thread_id: str = "") -> Any:
+ prefs = service.conv.get_sidebar_prefs(CONVERSATION_PREFS_KEY) or {"version": 0}
+ thread_id = str(thread_id or "").strip()
+ mode = ""
+ if thread_id:
+ if service.manager.get_thread(thread_id) is None:
return _error_response(
- "conversation.runtime.unknown",
- f"unknown runtime instance: {instance_id}",
+ THREAD_NOT_FOUND_CODE,
+ f"unknown thread: {thread_id}",
ErrorCategory.NOT_FOUND,
status_code=404,
)
- try:
- health = await runtime_service.probe_one(adapter_id, iid)
- except Exception as exc: # noqa: BLE001 — 单实例故障隔离
+ mode = service.manager.runtime_selection(thread_id).interaction_mode
+ return JSONResponse(_preference_body(
+ prefs, thread_id=thread_id, interaction_mode=mode))
+
+ @router.put("/api/conversation/preferences")
+ async def put_conversation_preferences(
+ body: dict[str, Any] = Body(...),
+ thread_id: str = "",
+ ) -> Any:
+ has_mode = "interaction_mode" in body
+ has_resume = "auto_resume_on_quota_reset" in body
+ thread_id = str(body.get("thread_id") or thread_id or "").strip()
+ if not has_mode:
+ value = body.get("auto_resume_on_quota_reset")
+ if not isinstance(value, bool):
return _error_response(
- "conversation.runtime.probe_failed",
- f"{type(exc).__name__}: {str(exc)[:200]}",
- ErrorCategory.RUNTIME,
- status_code=502,
+ "conversation.preferences.invalid",
+ "auto_resume_on_quota_reset 必须是布尔值",
+ ErrorCategory.VALIDATION,
+ status_code=400,
)
- return {"instance": f"{adapter_id}:{iid}", "health": health}
+ from muteki.platform.store import OptimisticConcurrencyError
+ expected = body.get("version")
+ if expected is not None and (type(expected) is not int or expected < 0):
+ raise HTTPException(422, "version must be a non-negative integer")
+ try:
+ saved = service.conv.save_sidebar_prefs(
+ {"auto_resume_on_quota_reset": value}, key=CONVERSATION_PREFS_KEY,
+ expected_version=expected)
+ except OptimisticConcurrencyError:
+ return JSONResponse(_preference_body(service.conv.get_sidebar_prefs(CONVERSATION_PREFS_KEY) or {}), status_code=409)
+ return JSONResponse({
+ "version": saved["version"],
+ "auto_resume_on_quota_reset": saved["auto_resume_on_quota_reset"],
+ })
+ if has_resume and not isinstance(body.get("auto_resume_on_quota_reset"), bool):
+ return _error_response(
+ "conversation.preferences.invalid",
+ "auto_resume_on_quota_reset 必须是布尔值",
+ ErrorCategory.VALIDATION,
+ status_code=400,
+ )
+ if not thread_id:
+ return _error_response(
+ "conversation.preferences.invalid",
+ "interaction_mode 按会话保存,需要 thread_id",
+ ErrorCategory.VALIDATION,
+ status_code=400,
+ )
+ if service.manager.get_thread(thread_id) is None:
+ return _error_response(
+ THREAD_NOT_FOUND_CODE,
+ f"unknown thread: {thread_id}",
+ ErrorCategory.NOT_FOUND,
+ status_code=404,
+ )
+ raw_mode = body.get("interaction_mode")
+ if not isinstance(raw_mode, str):
+ return _error_response(
+ INTERACTION_MODE_INVALID_CODE,
+ "interaction_mode 必须是 default 或 plan",
+ ErrorCategory.VALIDATION,
+ status_code=400,
+ detail={"interaction_mode": raw_mode, "allowed": ["default", "plan"]},
+ )
+ try:
+ selection = service.manager.save_runtime_selection(
+ thread_id,
+ {"interaction_mode": raw_mode},
+ validate_credential=False,
+ )
+ except InteractionModeError as exc:
+ return _error_response(
+ exc.code,
+ str(exc),
+ ErrorCategory.VALIDATION,
+ status_code=400,
+ detail=exc.detail,
+ )
+ except ConversationError as exc:
+ not_found = exc.code in {THREAD_NOT_FOUND_CODE, TURN_NOT_FOUND_CODE}
+ return _error_response(
+ exc.code if not_found else "conversation.preferences.invalid",
+ str(exc),
+ ErrorCategory.NOT_FOUND if not_found else ErrorCategory.VALIDATION,
+ status_code=404 if not_found else 400,
+ )
+ if has_resume:
+ saved = service.conv.save_sidebar_prefs(
+ {"auto_resume_on_quota_reset": body["auto_resume_on_quota_reset"]},
+ key=CONVERSATION_PREFS_KEY)
+ else:
+ saved = service.conv.get_sidebar_prefs(CONVERSATION_PREFS_KEY) or {
+ "version": 0, "auto_resume_on_quota_reset": False}
+ return JSONResponse(_preference_body(
+ saved,
+ thread_id=thread_id,
+ interaction_mode=selection.interaction_mode,
+ ))
return router
diff --git a/apps/web/conversation_shares.py b/apps/web/conversation_shares.py
index ffa4368..210c11c 100644
--- a/apps/web/conversation_shares.py
+++ b/apps/web/conversation_shares.py
@@ -21,7 +21,7 @@
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field
-from apps.web.auth import bearer_from_header, verify_token
+from apps.web.auth import request_token, verify_token
from muteki.conversation.workspace_surfaces import content_disposition_header
MAX_MESSAGES = 500
@@ -292,7 +292,7 @@ async def revoke(thread_id: str, share_id: str):
def read_scope(share_id: str, request: Request):
cfg = request.app.state.auth
- authenticated = cfg.enabled and verify_token(cfg, bearer_from_header(request.headers.get("Authorization")))
+ authenticated = cfg.enabled and verify_token(cfg, request_token(cfg, request))
token = request.headers.get("X-Muteki-Share-Token", "")
if len(token) != 43:
raise HTTPException(404, "分享不存在或链接不完整")
diff --git a/apps/web/desktop_runtime.py b/apps/web/desktop_runtime.py
new file mode 100644
index 0000000..6983400
--- /dev/null
+++ b/apps/web/desktop_runtime.py
@@ -0,0 +1,107 @@
+"""Private desktop child entrypoint; readiness and session travel over fd 3."""
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+from pathlib import Path
+import socket
+import sys
+import threading
+
+
+def main() -> None:
+ # Python -I prevents ambient PYTHONPATH / user site packages. Only this
+ # release's source tree is added, before importing any application code.
+ code_root = Path(__file__).resolve().parents[2]
+ sys.path.insert(0, str(code_root))
+ bootstrap = json.loads(sys.stdin.readline())
+ root = Path(os.environ["MUTEKI_ENVIRONMENT_ROOT"]).resolve()
+ manifest = json.loads((root / "environment.json").read_text())
+ if manifest["id"] != os.environ["MUTEKI_ENVIRONMENT_ID"]:
+ raise RuntimeError("Desktop environment identity mismatch")
+ if os.getcwd() != str(root):
+ raise RuntimeError("Desktop backend must start in its environment directory")
+ os.umask(0o077)
+ lock = (root / "backend.lock").open("a+b")
+ if os.name == "nt":
+ import msvcrt
+ lock.write(b"0")
+ lock.flush()
+ lock.seek(0)
+ msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1)
+ else:
+ import fcntl
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
+
+ # No reusable bootstrap credential is placed in argv, environment, disk or
+ # the renderer. A fresh service-bound native session goes to the parent pipe.
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ listener.bind(("127.0.0.1", 0))
+ listener.listen(128)
+ port = listener.getsockname()[1]
+ os.environ["MUTEKI_WEB_PORT"] = str(port)
+ os.environ["MUTEKI_RELEASE_ROOT"] = str(code_root)
+ os.environ["MUTEKI_BACKEND"] = f"http://127.0.0.1:{port}"
+ os.environ["MUTEKI_CAPABILITY_GATEWAY_ENDPOINT"] = f"http://127.0.0.1:{port}/api/capability"
+ from apps.web.server import app
+ from apps.web.auth import issue_token, token_payload
+ import uvicorn
+ runtime = {"environment_id": manifest["id"], "channel": manifest["channel"],
+ "generation": bootstrap["generation"], "release": bootstrap["release"],
+ "pid": os.getpid(), "api_port": port, "maintenance": os.environ.get("MUTEKI_DESKTOP_MAINTENANCE") == "1"}
+ app.state.desktop_runtime = runtime
+
+ class DesktopServer(uvicorn.Server):
+ async def startup(self, sockets=None):
+ app.state.desktop_loop = asyncio.get_running_loop()
+ await super().startup(sockets=sockets)
+ if not self.started:
+ return
+ token = issue_token(app.state.auth)
+ payload = token_payload(app.state.auth, token)
+ ready = {**runtime, "service_id": app.state.auth.service_id,
+ "cookie_name": app.state.auth.cookie_name,
+ "token": token, "expires_at": payload["exp"],
+ "control_port": app.state.platform_stack.control_receiver_status.get("port")}
+ with os.fdopen(3, "w", closefd=False) as pipe:
+ pipe.write(json.dumps(ready) + "\n")
+ pipe.flush()
+
+ server = DesktopServer(uvicorn.Config(app, host="127.0.0.1", port=port, access_log=False, timeout_graceful_shutdown=20))
+
+ def watch_parent():
+ # Closing the ownership pipe also stops the child after an Electron crash.
+ try:
+ for line in sys.stdin:
+ request = json.loads(line)
+ if request.get("command") == "activate":
+ async def activate():
+ app.state.desktop_activate.set()
+ await app.state.desktop_activated.wait()
+ if getattr(app.state, "desktop_activation_error", None):
+ raise RuntimeError(app.state.desktop_activation_error)
+ app.state.desktop_draining = False
+ runtime["maintenance"] = False
+ asyncio.run_coroutine_threadsafe(activate(), app.state.desktop_loop).result(timeout=120)
+ elif request.get("command") != "session":
+ raise RuntimeError("Invalid private desktop command")
+ with app.state.auth._lock:
+ token = issue_token(app.state.auth)
+ payload = token_payload(app.state.auth, token)
+ with os.fdopen(3, "w", closefd=False) as pipe:
+ pipe.write(json.dumps({"request_id": request["request_id"], "token": token, "expires_at": payload["exp"]}) + "\n")
+ pipe.flush()
+ finally:
+ server.should_exit = True
+
+ threading.Thread(target=watch_parent, name="desktop-owner", daemon=True).start()
+ try:
+ asyncio.run(server.serve(sockets=[listener]))
+ finally:
+ listener.close()
+ lock.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/web/drivers.py b/apps/web/drivers.py
index 0059efa..d3fda51 100644
--- a/apps/web/drivers.py
+++ b/apps/web/drivers.py
@@ -654,14 +654,7 @@ async def drive(run: Run) -> None:
or body.get("worker_container_scope")
or wc.get("worker_container_scope") or "run"
).strip()
- if mode == "pentest":
- if (runtime_policy.get("worker_backend") not in {None, "container"}
- or runtime_policy.get("worker_container_scope") not in {None, "run"}):
- from apps.web.run_recovery import WorkerRuntimePolicyUnavailable
- raise WorkerRuntimePolicyUnavailable(
- "Pentest Run isolation conflicts with its saved runtime policy")
- worker_container_scope = "run"
- elif (body.get("worker_container_scope") is not None
+ if (body.get("worker_container_scope") is not None
and runtime_policy.get("worker_backend") == "container"
and runtime_policy.get("worker_container_scope")
and str(body["worker_container_scope"]).strip()
@@ -804,14 +797,13 @@ async def drive(run: Run) -> None:
# fallback, and the web-container override all owned by the single resolver
# so the settings health endpoints resolve the SAME effective backend.
worker_backend = resolve_worker_backend(
- request_backend=("container" if mode == "pentest"
- else runtime_policy.get("worker_backend")
+ request_backend=(runtime_policy.get("worker_backend")
or body.get("worker_backend")),
config_backend=wc.get("worker_backend"),
env_backend=os.environ.get("MUTEKI_WORKER_BACKEND"),
in_web_container=is_web_container(),
)
- if (mode != "pentest" and body.get("worker_backend") is not None
+ if (body.get("worker_backend") is not None
and runtime_policy.get("worker_backend")
and resolve_worker_backend(
request_backend=body["worker_backend"],
@@ -1569,13 +1561,6 @@ async def drive(run: Run) -> None:
worker_container_scope = str(
runtime_policy.get("worker_container_scope")
or wc.get("worker_container_scope") or "run")
- if mode == "pentest":
- if (runtime_policy.get("worker_backend") not in {None, "container"}
- or runtime_policy.get("worker_container_scope") not in {None, "run"}):
- from apps.web.run_recovery import WorkerRuntimePolicyUnavailable
- raise WorkerRuntimePolicyUnavailable(
- "Pentest Run isolation conflicts with its saved runtime policy")
- worker_container_scope = "run"
if root.is_symlink():
if root.resolve() != mgr.storage.shared_workspace(run.run_id).resolve():
raise RuntimeError("Run workspace points outside its shared pool slot")
@@ -1606,8 +1591,7 @@ async def drive(run: Run) -> None:
)
transport = base_engine_for_profile(profile or winner_engine)
worker_backend = resolve_worker_backend(
- request_backend=("container" if mode == "pentest"
- else runtime_policy.get("worker_backend")),
+ request_backend=runtime_policy.get("worker_backend"),
config_backend=wc.get("worker_backend"),
env_backend=os.environ.get("MUTEKI_WORKER_BACKEND"),
in_web_container=is_web_container(),
diff --git a/apps/web/operations_api.py b/apps/web/operations_api.py
index 74344b7..2c77c50 100644
--- a/apps/web/operations_api.py
+++ b/apps/web/operations_api.py
@@ -24,7 +24,10 @@
PlatformSubmission,
SyncCursor,
)
+from muteki.platform.command_handlers.base import CommandAPIError
+from muteki.platform.contracts.errors import ErrorCategory
from muteki.platform.contracts.objects import AgentSession
+from muteki.platform.reconciler import unfinished_receipts_by_command_type
def _age_seconds(value: str | None) -> float:
if not value:
@@ -136,6 +139,8 @@ def metrics(self) -> dict[str, Any]:
"platform": {
"event_watermark": platform.event_watermark,
"pending_receipts": platform.pending_receipts,
+ "pending_receipts_by_type": unfinished_receipts_by_command_type(
+ self.stack.store),
"projection_lag_max": max(watermark_lags, default=0),
"outbox": self._outbox_summary(platform_conn, "outbox"),
"effects": dict(Counter(item.state.value for item in effects)),
@@ -192,10 +197,14 @@ def alerts(self, metrics: dict[str, Any] | None = None) -> list[dict[str, Any]]:
"detail": f"最早未完成 outbox 已等待 {int(outbox['oldest_pending_seconds'])} 秒",
})
if data[domain]["pending_receipts"]:
+ by_type = data[domain].get("pending_receipts_by_type") or {}
+ breakdown = ",".join(f"{name} {count}" for name, count in by_type.items())
alerts.append({
"code": f"{domain}.receipt.pending",
"severity": "warning",
- "detail": f"存在 {data[domain]['pending_receipts']} 个未完成回执",
+ "detail": f"存在 {data[domain]['pending_receipts']} 个未完成回执"
+ + (f"({breakdown})" if breakdown else ""),
+ **({"by_type": dict(by_type)} if by_type else {}),
})
if data["platform"]["projection_lag_max"]:
alerts.append({
@@ -326,6 +335,8 @@ def recovery_preview(self) -> dict[str, Any]:
"event_watermark": platform.event_watermark,
"stream_gap_count": int(event_gaps),
"pending_receipts": platform.pending_receipts,
+ "pending_receipts_by_type": unfinished_receipts_by_command_type(
+ self.stack.store),
"pending_outbox": platform.pending_outbox,
"projection_watermarks": platform.projection_watermarks,
},
@@ -467,11 +478,15 @@ async def outbox(limit: int = 200) -> Any:
async def receipt(command_id: str) -> Any:
try:
return await observability.receipt_trace(command_id)
- except Exception as exc:
+ except CommandAPIError as exc:
+ # Only a missing receipt is a 404; storage or decoding failures
+ # must surface as real errors rather than "not found".
+ if exc.error.category is not ErrorCategory.NOT_FOUND:
+ raise
return Response(
content=json.dumps({"error": {
- "code": "operations.receipt.not_found",
- "message": str(exc),
+ "code": exc.error.code,
+ "message": exc.error.message,
}}, ensure_ascii=False),
status_code=404,
media_type="application/json",
diff --git a/apps/web/platform_stack.py b/apps/web/platform_stack.py
index 27154db..c416c85 100644
--- a/apps/web/platform_stack.py
+++ b/apps/web/platform_stack.py
@@ -54,6 +54,15 @@
from muteki.external_agents.factory import (
RuntimeAdapterFactory,
)
+from muteki.external_agents.process_supervisor import (
+ ProcessIdentityError,
+ ProcessLedgerError,
+ configure_process_ledger,
+ default_ledger_path,
+ default_process_ledger,
+ reap_orphans,
+ terminate_owned,
+)
from muteki.external_agents.registry import AdapterRegistry
from muteki.platform.command_api import MutekiCommandApiImpl
from muteki.platform.command_handlers.base import CommandAPIError
@@ -420,12 +429,24 @@ def __init__(self, manager: Any) -> None:
self.competition_store,
submission_service=self.competition_services.submissions,
)
+ from muteki.conversation.browser_control import BrowserControlBroker
from muteki.conversation.chat_plugin_gateway import ChatPluginGateway
+ from muteki.conversation.computer_control import ComputerControlBroker
+ from apps.web.worker_models import conversation_model_input
+ self.browser_control = BrowserControlBroker(self.root / "browser-captures")
+ self.computer_control = ComputerControlBroker(self.root / "computer-use")
+ self.conversation.executor.computer_control = self.computer_control
self.capability_gateway = ChatPluginGateway(
self.store,
self.routed_command_api,
plugins=self.chat_plugins,
selection=self.conversation.manager.runtime_selection,
+ browser=self.browser_control,
+ computer=self.computer_control,
+ model_input=lambda thread_id: conversation_model_input(
+ self.root.parent, self.conversation.manager.runtime_selection(thread_id)),
+ current_turn=lambda thread_id: self.conversation.manager.conv.get_turn(
+ self.conversation.manager.conv.get_state(thread_id).running_turn_id),
binding_service=self.conversation.bindings,
)
# Adapter 只在真实 Gateway 就绪后创建。Claude 的 in-process
@@ -567,11 +588,10 @@ async def capability_http_post(request: Request) -> Response:
create_conversation_router(
command_api=self.routed_command_api,
service=self.conversation,
- runtime_service=self.runtime_service,
register_handlers=False,
- include_runtime_routes=False,
sse_metrics=self.observability,
extension_service=self.extension,
+ browser_control=self.browser_control,
),
create_competition_router(
command_api=self.routed_command_api,
@@ -583,6 +603,51 @@ async def capability_http_post(request: Request) -> Response:
create_extension_router(self.extension, self.routed_command_api),
]
+ async def _recover_process_ledger(self, report: "StartupRecoveryReport") -> None:
+ ledger = configure_process_ledger(default_ledger_path(self.manager.state_root))
+ try:
+ reaped = await reap_orphans(ledger)
+ except (ProcessLedgerError, ProcessIdentityError, OSError) as exc:
+ detail = f"{type(exc).__name__}: {exc}"
+ LOG.error("process ledger recovery failed: %s", detail)
+ # An unreadable ledger would make every later spawn fail on
+ # ``ledger.add``; children stay process-group supervised but are
+ # not recorded until the file is repaired.
+ configure_process_ledger(None)
+ report.add(
+ "runtime.process_ledger", "degraded",
+ detail=detail,
+ impact=("orphaned Runtime processes from earlier server instances were not "
+ "reaped and new Runtime processes are not recorded for restart reaping"),
+ evidence={"ledger_path": str(ledger.path) if ledger is not None else ""},
+ )
+ return
+ summary = reaped.to_dict()
+ if reaped.reaped or reaped.failed:
+ LOG.warning("process ledger recovery: %s", json.dumps(summary, ensure_ascii=False))
+ report.add(
+ "runtime.process_ledger",
+ "degraded" if reaped.failed else "ready",
+ detail=(f"reaped {len(reaped.reaped)}, dropped {len(reaped.dropped)} stale, "
+ f"kept {len(reaped.kept_live_owner)} owned by live servers, "
+ f"failed {len(reaped.failed)}"),
+ impact=("some orphaned Runtime process groups could not be stopped"
+ if reaped.failed else ""),
+ evidence=summary,
+ )
+
+ async def _terminate_runtime_processes(self) -> None:
+ """Stop Runtime process trees this server still owns (idle sessions too)."""
+ if default_process_ledger() is None:
+ return
+ try:
+ report = await terminate_owned()
+ except (ProcessLedgerError, ProcessIdentityError, OSError) as exc:
+ LOG.error("runtime process shutdown failed: %s: %s", type(exc).__name__, exc)
+ return
+ if report.reaped or report.failed:
+ LOG.warning("runtime process shutdown: %s", json.dumps(report.to_dict(), ensure_ascii=False))
+
async def recover(self) -> None:
report = StartupRecoveryReport()
platform = self.platform_reconciler.recover()
@@ -596,9 +661,11 @@ async def recover(self) -> None:
evidence={
"event_watermark": platform.event_watermark,
"unfinished_receipts": len(platform.unfinished_receipts),
+ "unfinished_by_command_type": platform.unfinished_by_command_type,
"pending_outbox": len(platform.pending_outbox),
},
)
+ await self._recover_process_ledger(report)
try:
conversation = await self.conversation.recover()
except Exception as exc:
@@ -852,7 +919,9 @@ async def shutdown(self) -> None:
await self.extension.shutdown()
await self.conversation_metadata.shutdown()
await self.conversation.shutdown()
+ await self._terminate_runtime_processes()
await self.chat_plugins.close()
+ await self.computer_control.close()
self.memory_graph.close()
for graph in self._competition_graphs.values():
try:
diff --git a/apps/web/platform_update.py b/apps/web/platform_update.py
index 2315533..3bfd900 100644
--- a/apps/web/platform_update.py
+++ b/apps/web/platform_update.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
+import os
from pathlib import Path
from typing import Any
@@ -15,17 +16,24 @@ def __init__(self, install_root: Path | None = None) -> None:
self._task: asyncio.Task[None] | None = None
def status(self) -> dict[str, Any]:
+ if os.environ.get("MUTEKI_MANAGED_DESKTOP") == "1":
+ from muteki.version import get_version
+ return {"status": "idle", "running": False, "current_version": get_version(), "install_kind": "desktop", "deployment": "desktop", "available": False, "message": "客户端与后端作为完整桌面版本更新,请使用桌面应用的更新入口。"}
payload = self.manager.status()
payload["running"] = bool(self._task and not self._task.done())
return payload
async def check(self, target: str | None = None) -> dict[str, Any]:
+ if os.environ.get("MUTEKI_MANAGED_DESKTOP") == "1":
+ return self.status()
if self._task and not self._task.done():
return self.status()
await asyncio.to_thread(self.manager.check, target)
return self.status()
async def start(self, target: str | None = None, *, force: bool = False) -> dict[str, Any]:
+ if os.environ.get("MUTEKI_MANAGED_DESKTOP") == "1":
+ raise RuntimeError("托管后端随桌面应用整包更新,请使用桌面应用的更新入口。")
if self._task and not self._task.done():
return self.status()
if self.manager.status().get("deployment") == "compose":
@@ -43,6 +51,8 @@ async def run() -> None:
return self.status()
async def rollback(self) -> dict[str, Any]:
+ if os.environ.get("MUTEKI_MANAGED_DESKTOP") == "1":
+ raise RuntimeError("托管后端必须与桌面应用及数据备份一起恢复,请使用桌面应用的恢复入口。")
if self._task and not self._task.done():
return self.status()
if self.manager.status().get("deployment") == "compose":
diff --git a/apps/web/routes/credentials.py b/apps/web/routes/credentials.py
index 9fb6ad3..94ea63c 100644
--- a/apps/web/routes/credentials.py
+++ b/apps/web/routes/credentials.py
@@ -10,6 +10,8 @@
HTTPException,
Request,
)
+from fastapi.responses import JSONResponse
+from muteki.solver.shared_credentials import SharedCredentialError
from apps.web.routes.common import (
_require_dict_body,
@@ -19,7 +21,9 @@
ensure_engine_supported,
)
from muteki.solver.credential_accounts import (
+ HOST_LOGIN_IMPORT_ENGINES,
CredentialAccountStore,
+ CredentialAccountLockTimeoutError,
account_credential_id,
account_id_from_credential_id,
account_store_root,
@@ -31,6 +35,18 @@
)
def register(app: FastAPI) -> None:
+ @app.exception_handler(SharedCredentialError)
+ async def shared_credential_error(_request: Request, exc: SharedCredentialError) -> JSONResponse:
+ return JSONResponse(status_code=503, content={"error": {"code": exc.code, "message": str(exc)}})
+ @app.exception_handler(CredentialAccountLockTimeoutError)
+ async def credential_lock_timeout_response(
+ request: Request, exc: CredentialAccountLockTimeoutError,
+ ) -> JSONResponse:
+ return JSONResponse(status_code=503, content={"detail": {
+ "code": exc.code,
+ "message": "凭据正在由其它操作更新,请稍后重试。",
+ }})
+
h = app.state.route_helpers
_catalog_runtime_keys_by_engine = h._catalog_runtime_keys_by_engine
_primary_catalog_runtime_key = h._primary_catalog_runtime_key
@@ -40,8 +56,10 @@ def register(app: FastAPI) -> None:
_credential_usage_index = h._credential_usage_index
@app.post("/api/settings/credential-models/refresh-stale")
- async def refresh_stale_credential_models() -> Any:
- return await _refresh_stale_credential_catalogs()
+ async def refresh_stale_credential_models(environment: str = "") -> Any:
+ if environment not in {"", "local", "container"}:
+ raise HTTPException(status_code=422, detail="environment 必须是 local 或 container")
+ return await _refresh_stale_credential_catalogs(environment=environment)
@app.get("/api/settings/credentials")
async def list_credentials(
@@ -64,13 +82,13 @@ async def list_credentials(
detail="environment 必须是 local 或 container",
)
state_root = app.state.manager.state_root
- cfg = app.state.manager.worker_config.get()
+ cfg = await asyncio.to_thread(app.state.manager.worker_config.get)
catalog_environment = requested_environment or backend_for_profile(
worker_backend=str(cfg.get("worker_backend") or ""),
in_web_container=is_web_container(),
)
- usage = _credential_usage_index()
+ usage = await asyncio.to_thread(_credential_usage_index)
def _payload() -> dict[str, Any]:
store = CredentialAccountStore(account_store_root(state_root))
@@ -186,6 +204,8 @@ async def create_model_endpoint_connection(request: Request) -> Any:
target_model=model,
models=[model],
)
+ except SharedCredentialError:
+ raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
from apps.web.llm_credentials import list_model_endpoints, model_endpoint_id
@@ -211,6 +231,8 @@ async def probe_credential(credential_id: str, request: Request) -> Any:
backend = "container" if backend == "container" else "local"
try:
stable_id = canonical_credential_id(credential_id)
+ except SharedCredentialError:
+ raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
account_id = account_id_from_credential_id(stable_id)
@@ -333,6 +355,8 @@ async def refresh_credential_models(credential_id: str, request: Request) -> Any
_reject_temporarily_disabled_engine(body)
try:
stable_id = canonical_credential_id(credential_id)
+ except SharedCredentialError:
+ raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
account_id = account_id_from_credential_id(stable_id)
@@ -480,6 +504,8 @@ async def put_credential_account(account_id: str, request: Request) -> Any:
)
except FileExistsError as exc:
raise HTTPException(status_code=409, detail={"code": str(exc), "message": "凭据已存在或已被其它视图修改,请刷新后重试。"}) from exc
+ except SharedCredentialError:
+ raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
from apps.web.worker_models import CredentialModelCatalogStore
@@ -505,9 +531,11 @@ async def put_credential_account(account_id: str, request: Request) -> Any:
return {"ok": True, "account": account}
@app.delete("/api/settings/credential-accounts/{account_id}")
- async def delete_credential_account(account_id: str, detach_references: bool = False) -> Any:
+ def delete_credential_account(account_id: str, detach_references: bool = False) -> Any:
try:
stable_id = account_credential_id(account_id)
+ except SharedCredentialError:
+ raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
all_usages = _credential_usage_index().get(stable_id) or []
@@ -553,6 +581,9 @@ async def delete_credential_account(account_id: str, detach_references: bool = F
rollback_errors.append({"scope": "runtime_settings", "error": str(rollback)})
if rollback_errors:
raise HTTPException(status_code=500, detail={"code": "credential.delete.rollback_failed", "message": "删除未完成,部分引用需要恢复。", "cause": str(exc), "recovery": rollback_errors}) from exc
+ if isinstance(exc, (CredentialAccountLockTimeoutError, SharedCredentialError)):
+ # Rollback is complete; the application handler returns the typed 503.
+ raise
if isinstance(exc, FileNotFoundError):
raise HTTPException(status_code=404, detail={"code": "credential.account.not_found", "message": "账号不存在,引用没有改变。"}) from exc
raise HTTPException(status_code=500, detail={"code": "credential.delete.failed", "message": "删除失败,账号与引用已恢复。", "cause": str(exc)}) from exc
@@ -578,13 +609,15 @@ async def import_host_codex(account_id: str) -> Any:
store = CredentialAccountStore(account_store_root(app.state.manager.state_root))
try:
account = await asyncio.to_thread(store.import_host_codex_auth, account_id)
+ except SharedCredentialError:
+ raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"ok": True, "account": account}
@app.post("/api/settings/credential-accounts/{account_id}/import-host-login")
async def import_host_login(account_id: str, request: Request) -> Any:
- """Copy a minimal Claude, Kimi Code, or Grok host login into an account.
+ """Copy a minimal host login (HOST_LOGIN_IMPORT_ENGINES) into an account.
Container workers receive the account projection, so they can authenticate
without mounting the operator's complete home directory.
@@ -598,11 +631,16 @@ async def import_host_login(account_id: str, request: Request) -> Any:
)
body = await _require_dict_body(request)
engine = str(body.get("engine") or "").strip().lower()
- if engine not in {"claude", "kimi", "grok"}:
- raise HTTPException(status_code=400, detail="engine must be claude, kimi, or grok")
+ if engine not in HOST_LOGIN_IMPORT_ENGINES:
+ raise HTTPException(
+ status_code=400,
+ detail="engine must be one of: " + ", ".join(HOST_LOGIN_IMPORT_ENGINES),
+ )
store = CredentialAccountStore(account_store_root(app.state.manager.state_root))
try:
account = await asyncio.to_thread(store.import_host_login, account_id, engine)
+ except SharedCredentialError:
+ raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"ok": True, "account": account}
diff --git a/apps/web/routes/events.py b/apps/web/routes/events.py
index af9fbb8..1acf028 100644
--- a/apps/web/routes/events.py
+++ b/apps/web/routes/events.py
@@ -17,6 +17,7 @@
from apps.web.auth import (
AuthConfig,
bearer_from_header,
+ request_token,
verify_token,
)
from apps.web.run_manager import RunManager
@@ -60,9 +61,9 @@ async def events(
# an unauthenticated open can't spawn empty run handles.
cfg: AuthConfig = app.state.auth
if cfg.enabled:
- tok = bearer_from_header(request.headers.get("Authorization"))
+ tok = request_token(cfg, request)
authed = verify_token(cfg, tok) or app.state.tickets.redeem(
- request.query_params.get("ticket"))
+ request.query_params.get("ticket"), scope=request.scope)
if not authed:
raise HTTPException(status_code=401, detail="unauthorized")
manager: RunManager = app.state.manager
@@ -236,7 +237,7 @@ async def terminal(ws: WebSocket, run_id: str) -> None:
# (close 4401) on failure so we never expose an authenticated socket.
cfg: AuthConfig = app.state.auth
if cfg.enabled:
- authed = app.state.tickets.redeem(ws.query_params.get("ticket")) or \
+ authed = app.state.tickets.redeem(ws.query_params.get("ticket"), scope=ws.scope) or \
verify_token(cfg, ws.query_params.get("token"))
if not authed:
await ws.close(code=4401)
diff --git a/apps/web/routes/helpers.py b/apps/web/routes/helpers.py
index 4930cba..48f029a 100644
--- a/apps/web/routes/helpers.py
+++ b/apps/web/routes/helpers.py
@@ -10,8 +10,10 @@
from fastapi import (
FastAPI,
+ HTTPException,
)
+from muteki.external_agents.descriptors import find_adapter_descriptor, get_descriptor
from muteki.platform.contracts.errors import ErrorCategory
from muteki.solver.engine_registry import (
SUPPORTED_ENGINE_IDS,
@@ -113,7 +115,7 @@ async def _perform() -> dict[str, Any]:
}
endpoint_discovery = (
discover_anthropic_compatible_models
- if selected_engine == "claude"
+ if get_descriptor(selected_engine).models.endpoint_protocol == "anthropic"
else discover_openai_compatible_models
)
return await asyncio.to_thread(
@@ -124,6 +126,45 @@ async def _perform() -> dict[str, Any]:
adapter_id, instance_id = selected_runtime.rsplit(":", 1)
runtime = platform_stack.runtime_service.get_instance(
adapter_id, instance_id) or {}
+ adapter = find_adapter_descriptor(adapter_id)
+ if adapter is not None and adapter.scoped_model_catalog_probe:
+ # Runtimes with a scoped catalog probe (Codex App Server, Cursor SDK)
+ # read their own credential-scoped model list.
+ from apps.web.agent_runtime_api import RuntimeProbeScopeChangedError
+
+ if backend != "local" or not persist:
+ return {
+ "ok": False, "models": [], "source": adapter_id,
+ "error_code": "model_catalog.invalid_scope",
+ "detail": f"{adapter_id} 模型目录需要本地环境与已保存的凭据配置。",
+ }
+ try:
+ health = await platform_stack.runtime_service.probe_one(
+ adapter_id, instance_id, credential_id=credential_id,
+ environment="local",
+ )
+ except RuntimeProbeScopeChangedError as exc:
+ # A result from an obsolete configuration must not be saved
+ # over the current Runtime's catalog, even as a failure.
+ raise HTTPException(status_code=409, detail={
+ "code": exc.code, "message": str(exc),
+ }) from exc
+ except Exception as exc:
+ return {
+ "ok": False, "models": [], "source": adapter_id,
+ "error_code": str(getattr(exc, "code", "model_catalog.probe_failed")),
+ "detail": f"{type(exc).__name__}: {exc}",
+ }
+ catalog = health["model_catalog"]
+ ok = catalog.get("refresh_status") == "fresh"
+ return {
+ "ok": ok, "models": catalog.get("discovered_models") or [],
+ "default_model": catalog.get("default_model") or "",
+ "source": catalog.get("source") or adapter_id,
+ "error_code": catalog.get("error_code") or "",
+ "detail": catalog.get("detail") or catalog.get("last_error") or f"已读取 {adapter_id} 模型目录",
+ "evidence": catalog.get("discovery_evidence") if ok else catalog.get("last_attempt_evidence"),
+ }
return await asyncio.to_thread(
discover_worker_models,
profile={
@@ -170,26 +211,26 @@ async def _perform() -> dict[str, Any]:
runtime_instance=selected_runtime,
result=result,
configured_models=configured_models or [],
- default_model=default_model,
+ default_model=default_model or str(result.get("default_model") or ""),
preserve_previous_on_failure=not (
connection == "custom_endpoint" or bool(base_url)
),
)
return result, catalog
- async def _refresh_stale_credential_catalogs() -> dict[str, Any]:
+ async def _refresh_stale_credential_catalogs(environment: str = "") -> dict[str, Any]:
"""Refresh only missing/expired credential catalogs; never run a model turn."""
from apps.web.worker_config import backend_for_profile
from apps.web.worker_models import CredentialModelCatalogStore
from muteki.core.runtime_env import is_web_container
from muteki.solver.credential_accounts import (
- detect_system_login,
+ detect_system_login_async,
invalidate_system_login_cache,
)
invalidate_system_login_cache()
cfg = mgr.worker_config.get()
- environment = backend_for_profile(
+ environment = environment or backend_for_profile(
worker_backend=str(cfg.get("worker_backend") or ""),
in_web_container=is_web_container(),
)
@@ -230,7 +271,7 @@ async def _refresh_stale_credential_catalogs() -> dict[str, Any]:
if environment == "local":
for engine in SUPPORTED_ENGINE_IDS:
credential_id = system_credential_id(engine)
- if detect_system_login(engine) != "present":
+ if await detect_system_login_async(engine) != "present":
continue
for runtime_key in runtime_keys.get(engine) or [
f"cli.{engine}:default"
@@ -295,7 +336,7 @@ async def _dispatch_run_command(
fields["command_id"] = command_id
if idempotency_key:
fields["idempotency_key"] = idempotency_key
- return await platform_stack.command_api.dispatch(CommandEnvelope(**fields))
+ return await platform_stack.routed_command_api.dispatch(CommandEnvelope(**fields))
async def _dispatch_platform_command(
command_type: str,
@@ -306,7 +347,7 @@ async def _dispatch_platform_command(
"""共享平台设置的兼容 HTTP 入口。"""
from muteki.platform.contracts.commands import ActorRef, CommandEnvelope
- return await platform_stack.command_api.dispatch(CommandEnvelope(
+ return await platform_stack.routed_command_api.dispatch(CommandEnvelope(
command_type=command_type,
aggregate_type=aggregate_type,
aggregate_id=aggregate_id,
diff --git a/apps/web/routes/system.py b/apps/web/routes/system.py
index 74665d0..4630484 100644
--- a/apps/web/routes/system.py
+++ b/apps/web/routes/system.py
@@ -9,16 +9,21 @@
Request,
)
from fastapi.responses import JSONResponse
+from starlette.concurrency import run_in_threadpool
from apps.web.auth import (
AuthConfig,
check_password,
issue_token,
+ AccessSettingsError,
+ request_token,
+ token_payload,
)
from apps.web.routes.common import (
_require_dict_body,
)
from muteki.version import get_version
+from apps.web.ui_preferences import SETTINGS_FEATURES
def register(app: FastAPI) -> None:
platform_stack = app.state.platform_stack
@@ -31,48 +36,136 @@ def identity_scope() -> dict[str, str]:
@app.get("/api/health")
async def health() -> Any:
- return {"version": get_version(), **platform_stack.health()}
+ return {"version": get_version(), "features": SETTINGS_FEATURES, **platform_stack.health(),
+ **({"desktop_runtime": app.state.desktop_runtime} if hasattr(app.state, "desktop_runtime") else {})}
@app.get("/api/readiness")
async def readiness() -> Any:
- payload = {"version": get_version(), **platform_stack.health()}
+ payload = {"version": get_version(), "features": SETTINGS_FEATURES, **platform_stack.health()}
return JSONResponse(payload, status_code=200 if platform_stack.ready else 503)
+ @app.get("/api/desktop/activity")
+ async def desktop_activity() -> Any:
+ if not hasattr(app.state, "desktop_runtime"):
+ raise HTTPException(404, "Not a managed desktop service")
+ turns = platform_stack.conversation.conv.list_active_turns()
+ runs = [run.run_id for run in app.state.manager.runs.values() if run.task is not None and not run.task.done()]
+ return {"active": len(turns) + len(runs), "turns": len(turns), "runs": len(runs)}
+
+ @app.post("/api/desktop/drain")
+ async def desktop_drain(request: Request) -> Any:
+ if not hasattr(app.state, "desktop_runtime"):
+ raise HTTPException(404, "Not a managed desktop service")
+ body = await _require_dict_body(request)
+ if body.get("cancel") is True:
+ app.state.desktop_draining = False
+ platform_stack.command_api.resume_admission()
+ return {"draining": False}
+ if app.state.desktop_writes_in_flight or not platform_stack.command_api.pause_admission():
+ raise HTTPException(409, "工作台仍在处理操作,请稍后再更新。")
+ app.state.desktop_draining = True
+ try:
+ activity = await desktop_activity()
+ if activity["active"]:
+ raise HTTPException(409, "请等待当前任务完成,再应用更新。")
+ except BaseException:
+ app.state.desktop_draining = False
+ platform_stack.command_api.resume_admission()
+ raise
+ return {"draining": True, **activity}
+
+ @app.get("/api/auth/status")
+ async def auth_status() -> Any:
+ cfg: AuthConfig = app.state.auth
+ return JSONResponse({"auth_required": cfg.enabled, "session_protocol": 2,
+ **identity_scope()}, headers={"Cache-Control": "no-store"})
+
@app.post("/api/auth/login")
async def auth_login(request: Request) -> Any:
- # Exchange the operator password for a signed session token. This route
- # is intentionally reachable WITHOUT a token (you have none yet). When
- # auth is disabled it still returns a (useless) token so the frontend
- # flow is uniform.
cfg: AuthConfig = app.state.auth
body = await _require_dict_body(request, allow_empty=True)
- if not cfg.enabled:
- return {"ok": True, "token": "", "auth_required": False,
- **identity_scope()}
- if not check_password(cfg, body.get("password")):
- # constant-time compare already done; uniform 401, no "wrong length"
- raise HTTPException(status_code=401, detail="invalid password")
- return {"ok": True, "token": issue_token(cfg), "auth_required": True,
- **identity_scope()}
+ client = body.get("client", "api")
+ if not isinstance(client, str) or client not in {"web", "desktop", "api"} or not isinstance(body.get("remember", True), bool):
+ raise HTTPException(422, "invalid login client or remember field")
+ peer = request.client.host if request.client else "unknown"
+ if not cfg.allow_login(peer):
+ return JSONResponse({"detail": "尝试次数过多,请一分钟后重试。"}, status_code=429, headers={"Retry-After": "60"})
+
+ def authenticate() -> str | None:
+ # Checking the password and issuing a session share one revision.
+ with cfg._lock:
+ if not cfg.enabled:
+ return ""
+ if not check_password(cfg, body.get("password")):
+ cfg.login_failed(peer)
+ return None
+ return issue_token(cfg)
+
+ token = await run_in_threadpool(authenticate)
+ if token is None:
+ raise HTTPException(401, "invalid password")
+ payload = token_payload(cfg, token) if token else None
+ data = {"ok": True, "auth_required": cfg.enabled, "session_protocol": 2,
+ "session_owner": "cookie" if client == "web" else client,
+ "expires_at": payload["exp"] if payload else None, **identity_scope()}
+ if client != "web":
+ data["token"] = token
+ response = JSONResponse(data, headers={"Cache-Control": "no-store"})
+ if client == "web" and token:
+ response.set_cookie(cfg.cookie_name, token, httponly=True,
+ secure=request.url.scheme == "https", samesite="strict", path="/api",
+ max_age=cfg.ttl_s if body.get("remember", True) else None)
+ return response
@app.get("/api/auth/me")
async def auth_me(request: Request) -> Any:
- # Cheap "is my token still valid?" probe. Reachable past the gate only
- # with a valid token (when enabled), so a 200 means authenticated.
- # in_container (P2-v3): tells the UI the coordinator runs inside a
- # container, so the deck must force container mode and disable the
- # "local" worker-isolation toggle (local is rejected server-side anyway).
from muteki.core.runtime_env import is_web_container
cfg: AuthConfig = app.state.auth
- return {"authenticated": True, "auth_required": cfg.enabled,
- "in_container": is_web_container(), **identity_scope()}
+ payload = token_payload(cfg, request_token(cfg, request))
+ return JSONResponse({"authenticated": True, "auth_required": cfg.enabled,
+ "session_protocol": 2, "expires_at": payload["exp"] if payload else None,
+ "in_container": is_web_container(), **identity_scope()},
+ headers={"Cache-Control": "no-store"})
+
+ @app.post("/api/auth/logout")
+ async def auth_logout(request: Request) -> Any:
+ cfg: AuthConfig = app.state.auth
+ await run_in_threadpool(cfg.revoke, request_token(cfg, request))
+ app.state.tickets.clear()
+ response = JSONResponse({"ok": True}, headers={"Cache-Control": "no-store"})
+ response.delete_cookie(cfg.cookie_name, path="/api", httponly=True, samesite="strict")
+ return response
+
+ @app.get("/api/settings/access")
+ async def access_settings() -> Any:
+ return JSONResponse(app.state.auth.settings(), headers={"Cache-Control": "no-store"})
+
+ @app.put("/api/settings/access")
+ async def update_access_settings(request: Request) -> Any:
+ cfg: AuthConfig = app.state.auth
+ body = await _require_dict_body(request)
+ for key in ("action", "current_password", "new_password"):
+ if key in body and not isinstance(body[key], str):
+ raise HTTPException(422, f"{key} must be a string")
+ try:
+ await run_in_threadpool(cfg.update_password, action=body.get("action", ""),
+ current_password=body.get("current_password", ""),
+ new_password=body.get("new_password", ""))
+ except AccessSettingsError as exc:
+ return JSONResponse({"error": {"code": exc.code, "message": str(exc), "field": exc.field,
+ "recovery_hint": "修正字段后重试。"}}, status_code=exc.status)
+ app.state.tickets.clear()
+ response = JSONResponse({"ok": True, "reauthenticate": cfg.enabled, **cfg.settings()},
+ headers={"Cache-Control": "no-store"})
+ response.delete_cookie(cfg.cookie_name, path="/api", httponly=True, samesite="strict")
+ return response
@app.post("/api/auth/ticket")
async def auth_ticket(request: Request) -> Any:
# Mint a one-time, short-TTL ticket for opening an SSE/WS connection
# (which can't carry an Authorization header). Requires a valid token —
# it sits behind the gate, so reaching here already proves auth.
- ticket = app.state.tickets.mint()
+ ticket = app.state.tickets.mint(token=request_token(app.state.auth, request))
return {"ticket": ticket}
@app.get("/api/settings/system-update")
diff --git a/apps/web/routes/workers.py b/apps/web/routes/workers.py
index 97666de..bfa12db 100644
--- a/apps/web/routes/workers.py
+++ b/apps/web/routes/workers.py
@@ -17,9 +17,6 @@
from apps.web.routes.common import (
_require_dict_body,
)
-from muteki.solver.engine_registry import (
- descriptor_payload,
-)
from muteki.solver.credential_accounts import (
account_store_root,
)
@@ -35,10 +32,6 @@ def register(app: FastAPI) -> None:
_engine_refresh_lock = h._engine_refresh_lock
_invalidate_engine_cache = h._invalidate_engine_cache
- @app.get("/api/settings/agent-engines")
- async def list_agent_engines() -> Any:
- return descriptor_payload()
-
@app.get("/api/engines")
async def engines() -> Any:
from muteki.solver.cli_driver import engine_status
diff --git a/apps/web/run_recovery.py b/apps/web/run_recovery.py
index a6227b4..ee50a80 100644
--- a/apps/web/run_recovery.py
+++ b/apps/web/run_recovery.py
@@ -196,15 +196,14 @@ def persist_run_launch_limits(self, run_id: str, body: dict[str, Any]) -> None:
mode = (contract.get("mode") if isinstance(contract, dict) else None)
mode = mode or challenge.get("mode") or body.get("mode") or "ctf"
runtime_backend = resolve_worker_backend(
- request_backend=("container" if mode == "pentest"
- else body.get("worker_backend")),
+ request_backend=body.get("worker_backend"),
config_backend=config.get("worker_backend"),
env_backend=os.environ.get("MUTEKI_WORKER_BACKEND"),
in_web_container=is_web_container(),
)
runtime_scope = str(body.get("worker_container_scope")
or config.get("worker_container_scope") or "run")
- if mode == "pentest" or runtime_backend == "local":
+ if runtime_backend == "local":
runtime_scope = "run"
if runtime_scope not in {"run", "shared"}:
raise RuntimeError("invalid Worker container scope")
diff --git a/apps/web/server.py b/apps/web/server.py
index ed87c06..ef0f58c 100644
--- a/apps/web/server.py
+++ b/apps/web/server.py
@@ -41,7 +41,10 @@
PUBLIC_API_PATHS,
AuthConfig,
TicketStore,
- bearer_from_header,
+ ServiceSessionMiddleware,
+ allowed_web_origins,
+ browser_request_error,
+ request_token,
verify_token,
)
from apps.web.run_manager import RunManager
@@ -77,7 +80,7 @@ def create_app(manager: Optional[RunManager] = None) -> FastAPI:
# sweep hourly. All env-tunable; set MUTEKI_RETENTION_ENABLED=0 to disable
# (pinned runs are NEVER auto-touched).
@asynccontextmanager
- async def lifespan(app: FastAPI):
+ async def normal_lifespan(app: FastAPI):
# Start the reverse-connect control receiver: the in-container supervisors
# DIAL this (host.docker.internal:) — so the host must be listening
# before any container starts. Lazy-starts on first use too, but starting it
@@ -212,7 +215,59 @@ async def _model_catalog_refresh_loop() -> None:
await mgr.shutdown()
await platform_stack.shutdown()
+ @asynccontextmanager
+ async def lifespan(app: FastAPI):
+ if os.environ.get("MUTEKI_DESKTOP_MAINTENANCE") != "1":
+ async with normal_lifespan(app):
+ yield
+ return
+ # Schema construction has completed, but recovery, timers, agents and
+ # external side effects remain stopped until the installer admits writes.
+ app.state.desktop_activate = asyncio.Event()
+ app.state.desktop_activated = asyncio.Event()
+ stopped = asyncio.Event()
+
+ async def activate():
+ await app.state.desktop_activate.wait()
+ try:
+ async with normal_lifespan(app):
+ app.state.desktop_activated.set()
+ await stopped.wait()
+ except Exception as exc:
+ app.state.desktop_activation_error = str(exc)
+ app.state.desktop_activated.set()
+ raise
+
+ task = asyncio.create_task(activate())
+ try:
+ yield
+ finally:
+ stopped.set()
+ if not app.state.desktop_activate.is_set():
+ task.cancel()
+ await mgr.shutdown()
+ await platform_stack.shutdown()
+ results = await asyncio.gather(task, return_exceptions=True)
+ for result in results:
+ if isinstance(result, Exception):
+ raise result
+
app = FastAPI(title="Project Muteki — Agent 安全工作台", lifespan=lifespan)
+ app.state.desktop_draining = os.environ.get("MUTEKI_DESKTOP_MAINTENANCE") == "1"
+ app.state.desktop_writes_in_flight = 0
+
+ @app.middleware("http")
+ async def desktop_write_gate(request: Request, call_next):
+ mutation = request.method not in {"GET", "HEAD", "OPTIONS"} and request.url.path.startswith("/api/") and request.url.path != "/api/desktop/drain"
+ if not mutation:
+ return await call_next(request)
+ if app.state.desktop_draining:
+ return JSONResponse({"error": {"code": "desktop.maintenance", "message": "工作台正在切换版本,暂不接受新操作。"}}, status_code=503)
+ app.state.desktop_writes_in_flight += 1
+ try:
+ return await call_next(request)
+ finally:
+ app.state.desktop_writes_in_flight -= 1
@app.exception_handler(RequestValidationError)
async def _request_validation_error(
@@ -264,92 +319,51 @@ async def _engine_temporarily_unsupported(
for router in platform_stack.routers():
app.include_router(router)
- # Auth (P3): a single-password gate in front of /api. fail_fast_check refuses
- # to start if bound to a non-loopback host with no password — see auth.py and
- # docs/_local/plan_p3_auth.md. When no password is set AND the bind is
- # loopback, auth is disabled and the deck behaves exactly as before.
- auth = AuthConfig.from_env()
+ auth = AuthConfig.from_env(mgr.state_root, service_id=platform_stack.store.installation_id)
auth.fail_fast_check()
app.state.auth = auth
app.state.tickets = TicketStore()
+ cors_origins = allowed_web_origins()
+ app.add_middleware(CORSMiddleware, allow_origins=sorted(cors_origins),
+ allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
- # Dev convenience: the Next dev server (:3001) can talk to this backend
- # directly. Connecting the browser's EventSource straight here (instead of
- # through Next's dev rewrite proxy) avoids the proxy BUFFERING the SSE stream
- # — the proxy holds events until the connection closes, which makes a live
- # run look frozen until it finishes. In prod the static UI is served same-
- # origin by this app, so CORS is a no-op there. Allowlist localhost only.
- app.add_middleware(
- CORSMiddleware,
- allow_origin_regex=r"http://(localhost|127\.0\.0\.1)(:\d+)?",
- allow_methods=["*"],
- allow_headers=["*"],
- )
-
- # Auth gate. Added AFTER CORS, so CORS wraps it (outermost) — preflight
- # OPTIONS are answered by CORS and never reach here. We still bypass OPTIONS
- # defensively (a same-origin request via the Next proxy often omits Origin,
- # so CORS does not short-circuit it). Only /api is gated; the Next server
- # (:3001) owns the UI/login page and must be secured separately when exposed
- # (reverse proxy / loopback bind) — see docs/_local/plan_p3_auth.md.
- #
- # @app.middleware("http") does NOT see websocket scope; the /terminal WS and
- # the SSE /events stream do their own ticket/token check in-handler.
- #
- # IMPORTANT (CORS): a middleware that SHORT-CIRCUITS with its own Response
- # bypasses CORSMiddleware's response path, so a cross-origin 401 would arrive
- # at the browser WITHOUT Access-Control-Allow-Origin — the browser then
- # reports a network error instead of a 401, and the frontend can't tell "needs
- # login" from "backend down". The Next dev UI (:3001) talks to this backend
- # (:8000) cross-origin, so we must mirror the CORS allow-origin header onto the
- # 401 ourselves. (CORSMiddleware only auto-adds headers when the inner app
- # actually runs; our early return never reaches it.)
- _cors_origin_re = re.compile(r"http://(localhost|127\.0\.0\.1)(:\d+)?$")
-
- def _unauthorized(request: Request) -> JSONResponse:
- resp = JSONResponse({"error": "unauthorized"}, status_code=401)
+ def _auth_error(request: Request, message: str, status: int) -> JSONResponse:
+ resp = JSONResponse({"error": {"code": "auth.unauthorized" if status == 401 else "auth.origin_invalid",
+ "message": message}}, status_code=status)
origin = request.headers.get("origin")
- if origin and _cors_origin_re.match(origin):
+ if origin in cors_origins:
resp.headers["Access-Control-Allow-Origin"] = origin
+ resp.headers["Access-Control-Allow-Credentials"] = "true"
resp.headers["Vary"] = "Origin"
+ resp.headers["Cache-Control"] = "no-store"
return resp
@app.middleware("http")
async def _auth_gate(request: Request, call_next):
cfg: AuthConfig = app.state.auth
- if not cfg.enabled:
- return await call_next(request)
path = request.url.path
- if request.method == "OPTIONS":
+ if request.method == "OPTIONS" or not path.startswith("/api/"):
return await call_next(request)
- if not path.startswith("/api/"):
- return await call_next(request) # static/UI (only present if built)
- if path in PUBLIC_API_PATHS:
+ # The capability bridge authenticates its separate session grants.
+ if path == "/api/capability":
+ return await call_next(request)
+ origin_error = browser_request_error(cfg, request)
+ if origin_error:
+ return _auth_error(request, origin_error, 403)
+ if path in PUBLIC_API_PATHS or not cfg.enabled:
return await call_next(request)
- # Only these exact read routes accept a separate snapshot-scoped token.
- # Their handlers check token, access mode, expiry and revocation.
from apps.web.conversation_shares import is_public_share_read
if is_public_share_read(request.method, path):
return await call_next(request)
- # Capability Bridge 使用 Session Grant Bearer 自行认证;Web operator
- # token 不能替代 grant,也不应在此处抢先拒绝。
- if path == "/api/capability":
- return await call_next(request)
- # Legacy run streams enforce their own ticket check. Do not treat an
- # arbitrary endpoint named /events as a public route.
if request.method == "GET" and re.fullmatch(r"/api/runs/[^/]+/events", path):
- return await call_next(request)
+ return await call_next(request) # handler consumes the one-time ticket
+ token = request_token(cfg, request)
if request.method == "GET" and (
- path == "/api/threads/inbox/events"
- or re.fullmatch(r"/api/threads/[^/]+/events", path)
- ):
- token = bearer_from_header(request.headers.get("Authorization"))
- if not (verify_token(cfg, token) or app.state.tickets.redeem(request.query_params.get("ticket"))):
- return _unauthorized(request)
+ path == "/api/threads/inbox/events" or re.fullmatch(r"/api/threads/[^/]+/events", path)
+ ) and app.state.tickets.redeem(request.query_params.get("ticket"), scope=request.scope):
return await call_next(request)
- token = bearer_from_header(request.headers.get("Authorization"))
if not verify_token(cfg, token):
- return _unauthorized(request)
+ return _auth_error(request, "登录已失效,请重新登录。", 401)
return await call_next(request)
platform_prefixes = (
@@ -398,6 +412,7 @@ async def index() -> Any:
if UI_DIR.exists():
app.mount("/ui", StaticFiles(directory=str(UI_DIR)), name="ui")
+ app.add_middleware(ServiceSessionMiddleware, config=auth)
return app
diff --git a/apps/web/storage_layout.py b/apps/web/storage_layout.py
index 7d15999..748adba 100644
--- a/apps/web/storage_layout.py
+++ b/apps/web/storage_layout.py
@@ -72,6 +72,12 @@ def resolve(
runtime_root=state / "runtime",
)
layout.validate()
+ environment_root = os.environ.get("MUTEKI_ENVIRONMENT_ROOT")
+ if environment_root:
+ root = _absolute(environment_root)
+ for name, directory in (("state", state), ("sessions", sessions), ("control", control), ("graph", graph)):
+ if directory == root or not _contains(directory, root):
+ raise ValueError(f"{name} must be inside the selected desktop environment")
return layout
def validate(self) -> None:
diff --git a/apps/web/ui/app/api/[...path]/route.ts b/apps/web/ui/app/api/[...path]/route.ts
index 61f0d6f..5cba107 100644
--- a/apps/web/ui/app/api/[...path]/route.ts
+++ b/apps/web/ui/app/api/[...path]/route.ts
@@ -32,6 +32,18 @@ function apiUrl(req: Request, path: string[] | undefined): string {
function requestHeaders(req: Request): Headers {
const headers = new Headers(req.headers);
for (const name of hopByHopHeaders) headers.delete(name);
+ // Validate the browser origin here, before mapping it to the backend origin.
+ // Caller-supplied forwarding headers never establish that trust.
+ const origin = req.headers.get("origin");
+ headers.delete("forwarded");
+ headers.delete("x-forwarded-for");
+ headers.delete("x-real-ip");
+ headers.delete("x-forwarded-host");
+ headers.delete("x-forwarded-proto");
+ if (origin) {
+ headers.set("origin", backendApiUrl("/").origin);
+ headers.set("x-forwarded-proto", new URL(origin).protocol.slice(0, -1));
+ }
return headers;
}
@@ -42,11 +54,20 @@ function responseHeaders(upstream: Response): Headers {
}
async function proxy(req: Request, ctx: RouteContext) {
+ const origin = req.headers.get("origin");
+ if (origin) {
+ let sameHost = false;
+ try { sameHost = new URL(origin).host === req.headers.get("host"); } catch { /* reject malformed origin */ }
+ if (!sameHost || (req.headers.get("sec-fetch-site") === "cross-site")) {
+ return Response.json({ error: { code: "auth.origin_invalid", message: "请求来源不受信任,请从工作台页面重试。" } }, { status: 403 });
+ }
+ }
const params = await ctx.params;
const init: RequestInit & { duplex?: "half" } = {
method: req.method,
headers: requestHeaders(req),
cache: "no-store",
+ redirect: "manual",
// 页面关闭/导航离开时取消上游请求,释放长连接。
signal: req.signal,
};
diff --git a/apps/web/ui/app/globals.css b/apps/web/ui/app/globals.css
index 40b3ecf..eb00bcb 100644
--- a/apps/web/ui/app/globals.css
+++ b/apps/web/ui/app/globals.css
@@ -24,8 +24,13 @@
@import "./styles/settings-hub.css";
@import "./styles/settings-agent-extensions.css";
@import "./styles/settings-notifications.css";
-@import "./styles/settings-agents.css";
@import "./styles/task-worker-settings.css";
+@import "./styles/settings-unified.css";
+@import "./styles/settings-unified-agent-extensions.css";
+@import "./styles/settings-unified-capabilities.css";
+@import "./styles/settings-unified-extensions.css";
+@import "./styles/settings-unified-operations.css";
+@import "./styles/settings-unified-data.css";
@import "./styles/pentest.css";
@import "./styles/ux.css";
@import "./styles/brand.css";
@@ -40,3 +45,4 @@
@import "./styles/chat/dialogs.css";
@import "./styles/chat/interaction.css";
@import "./styles/workspace-rail.css";
+@import "./styles/chat/refine.css";
diff --git a/apps/web/ui/app/layout.tsx b/apps/web/ui/app/layout.tsx
index e248e01..2c9e4a5 100644
--- a/apps/web/ui/app/layout.tsx
+++ b/apps/web/ui/app/layout.tsx
@@ -34,8 +34,9 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
+
+
{/* Stable head links are recolored by the palette engine. */}
diff --git a/apps/web/ui/app/not-found.tsx b/apps/web/ui/app/not-found.tsx
index 016a7e3..1513f53 100644
--- a/apps/web/ui/app/not-found.tsx
+++ b/apps/web/ui/app/not-found.tsx
@@ -6,7 +6,7 @@ export default function NotFound() {
找不到这个页面
链接可能已失效。返回首页查找最近的工作区,或打开设置。
- 返回首页 打开设置
+ 返回首页 打开设置
);
diff --git a/apps/web/ui/app/providers.tsx b/apps/web/ui/app/providers.tsx
index a3dba0b..aee03a3 100644
--- a/apps/web/ui/app/providers.tsx
+++ b/apps/web/ui/app/providers.tsx
@@ -1,11 +1,13 @@
"use client";
+import { UiPreferencesSync } from "@/components/UiPreferencesSync";
import { Toast } from "@heroui/react";
import type { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
return (
<>
+
{children}
;
+ searchParams: Promise>;
+}) {
+ const {page} = await params;
+ const query = new URLSearchParams();
+ Object.entries(await searchParams).forEach(([key, value]) => {
+ for (const part of Array.isArray(value) ? value : value === undefined ? [] : [value]) query.append(key, part);
+ });
+ const target = settingsRedirect(`/settings/${page}`, query);
+ if (target) redirect(target);
+ const id = settingsPageFromPath(`/settings/${page}`);
+ if (!id || !SETTINGS_PAGES[id].platforms.includes("web")) notFound();
+ return ;
+}
diff --git a/apps/web/ui/app/settings/agent-extensions/page.tsx b/apps/web/ui/app/settings/agent-extensions/page.tsx
deleted file mode 100644
index 78599fb..0000000
--- a/apps/web/ui/app/settings/agent-extensions/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import { AgentExtensionsSettings } from "@/components/AgentExtensionsSettings";
-
-export default function AgentExtensionsPage() { return ; }
diff --git a/apps/web/ui/app/settings/agents/page.tsx b/apps/web/ui/app/settings/agents/page.tsx
deleted file mode 100644
index 522cbcd..0000000
--- a/apps/web/ui/app/settings/agents/page.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-"use client";
-
-import { ProviderManager } from "@/components/ProviderManager";
-
-export default function AgentSettingsPage() {
- return ;
-}
diff --git a/apps/web/ui/app/settings/appearance/page.tsx b/apps/web/ui/app/settings/appearance/page.tsx
deleted file mode 100644
index 85dcd89..0000000
--- a/apps/web/ui/app/settings/appearance/page.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-"use client";
-
-import { AppearanceWorkspace } from "@/components/WorkerOrchestration";
-
-export default function AppearanceSettingsPage() {
- return ;
-}
diff --git a/apps/web/ui/app/settings/capabilities/page.tsx b/apps/web/ui/app/settings/capabilities/page.tsx
deleted file mode 100644
index f177060..0000000
--- a/apps/web/ui/app/settings/capabilities/page.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-"use client";
-
-import { CapabilityManagement } from "@/components/CapabilityManagement";
-
-export default function CapabilityManagementPage() {
- return ;
-}
diff --git a/apps/web/ui/app/settings/chat-plugins/page.tsx b/apps/web/ui/app/settings/chat-plugins/page.tsx
deleted file mode 100644
index d571761..0000000
--- a/apps/web/ui/app/settings/chat-plugins/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import { redirect } from "next/navigation";
-
-export default function ChatPluginsPage() { redirect("/settings/agent-extensions"); }
diff --git a/apps/web/ui/app/settings/credentials/page.tsx b/apps/web/ui/app/settings/credentials/page.tsx
deleted file mode 100644
index 68eb436..0000000
--- a/apps/web/ui/app/settings/credentials/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from "next/navigation";
-
-export default function CompetitionCredentialRedirectPage() {
- redirect("/competitions?focus=credentials");
-}
diff --git a/apps/web/ui/app/settings/extensions/page.tsx b/apps/web/ui/app/settings/extensions/page.tsx
deleted file mode 100644
index a61f526..0000000
--- a/apps/web/ui/app/settings/extensions/page.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-"use client";
-
-import { ExtensionSettings } from "@/components/ExtensionSettings";
-
-export default function ExtensionSettingsPage() {
- return ;
-}
diff --git a/apps/web/ui/app/settings/notifications/page.tsx b/apps/web/ui/app/settings/notifications/page.tsx
deleted file mode 100644
index e5f1ea2..0000000
--- a/apps/web/ui/app/settings/notifications/page.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-"use client";
-
-import { NotificationSettings } from "@/components/NotificationSettings";
-
-export default function NotificationsSettingsPage() {
- return ;
-}
diff --git a/apps/web/ui/app/settings/operations/page.tsx b/apps/web/ui/app/settings/operations/page.tsx
deleted file mode 100644
index 2807a8b..0000000
--- a/apps/web/ui/app/settings/operations/page.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-"use client";
-
-import { OperationsSettings } from "@/components/OperationsSettings";
-
-export default function OperationsSettingsPage() {
- return ;
-}
diff --git a/apps/web/ui/app/settings/page.tsx b/apps/web/ui/app/settings/page.tsx
index 40fd409..98ce48a 100644
--- a/apps/web/ui/app/settings/page.tsx
+++ b/apps/web/ui/app/settings/page.tsx
@@ -1,5 +1,3 @@
import { redirect } from "next/navigation";
-
-export default function SettingsIndexPage() {
- redirect("/settings/agents");
-}
+import { DEFAULT_SETTINGS_PAGE } from "@/components/settings/catalog";
+export default function SettingsPage() { redirect(`/settings/${DEFAULT_SETTINGS_PAGE}`); }
diff --git a/apps/web/ui/app/settings/secrets/page.tsx b/apps/web/ui/app/settings/secrets/page.tsx
deleted file mode 100644
index 79304a4..0000000
--- a/apps/web/ui/app/settings/secrets/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from "next/navigation";
-
-export default function SecretSettingsRedirectPage() {
- redirect("/competitions?focus=credentials");
-}
diff --git a/apps/web/ui/app/settings/workers/page.tsx b/apps/web/ui/app/settings/workers/page.tsx
deleted file mode 100644
index 6797c36..0000000
--- a/apps/web/ui/app/settings/workers/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from "next/navigation";
-
-export default function WorkerSettingsRedirectPage() {
- redirect("/ctf/workers");
-}
diff --git a/apps/web/ui/app/styles/base.css b/apps/web/ui/app/styles/base.css
index 8704b26..5750eb8 100644
--- a/apps/web/ui/app/styles/base.css
+++ b/apps/web/ui/app/styles/base.css
@@ -451,20 +451,23 @@ html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); c
font: 14px/1.5 var(--font-sans);
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
-.login-gate { position: fixed; inset: 0; z-index: 9999; display: grid; place-items: center; background: var(--bg); color: var(--text); }
-.login-gate-checking { color: var(--muted); font-size: 14px; }
-.login-gate-card { display: flex; flex-direction: column; gap: 14px; width: 320px; padding: 28px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow-pop); }
-.login-gate-copy { text-align: center; }
-.login-gate-title { color: var(--bright); font-size: 17px; font-weight: 700; }
-.login-gate-sub { margin-top: 4px; color: var(--muted); font-size: 13px; }
-.login-gate-input { padding: 10px 12px; border: 1px solid var(--line2); border-radius: 9px; outline: none; color: var(--bright); background: var(--bg); font: inherit; font-size: 14px; caret-color: var(--accent); }
-.login-gate-input::placeholder { color: var(--muted); opacity: 1; }
-.login-gate-input:focus { border-color: var(--focus-ring); box-shadow: none; }
-.login-gate-input.error { border-color: var(--red); }
-.login-gate-error { font-size: 12.5px; color: var(--red); }
-.login-gate-submit { align-self: flex-end; padding: 10px 12px; border: 0; border-radius: 9px; background: var(--accent); color: var(--on-accent, #fff); font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; }
-.login-gate-submit:disabled { opacity: .7; cursor: default; }
-.login-gate-submit:not(:disabled):hover { filter: brightness(1.06); }
+.login-gate { position: fixed; inset: 0; z-index: 9999; display: grid; align-items: center; justify-items: center; overflow-y: auto; padding: 32px 20px; background: var(--cx-bg); color: var(--cx-fg); }
+.login-gate-card { width: min(400px, 100%); min-width: 0; border: 1px solid var(--cx-border); border-radius: 16px; background: var(--cx-elevated); box-shadow: var(--cx-shadow-xs); overflow: hidden; }
+.login-gate-copy { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; padding: 28px 28px 0; }
+.login-gate-copy .muteki-logo { margin-bottom: 16px; }
+.login-gate-copy h1 { margin: 0; font-size: 22px; line-height: 1.45; font-weight: 600; letter-spacing: -.02em; color: var(--cx-fg); }
+.login-gate-copy p { margin: 0; font-size: 14px; line-height: 1.6; color: var(--cx-fg-3); }
+.login-gate-form { display: flex; flex-direction: column; gap: 18px; padding: 24px 28px 28px; }
+.login-gate-error { margin: -8px 0 0; font-size: 13px; line-height: 1.6; color: var(--cx-danger); overflow-wrap: anywhere; }
+.login-gate-notice { margin: 0; padding: 10px 12px; border-radius: 8px; background: var(--cx-bg-subtle); color: var(--cx-fg-2); font-size: 13px; line-height: 1.6; }
+.login-gate-checking { display: flex; align-items: center; gap: 10px; padding: 32px 28px; color: var(--cx-fg-3); font-size: 14px; }
+.login-gate-unavailable { display: flex; flex-direction: column; gap: 20px; padding: 24px 28px 28px; }
+.login-gate-unavailable .login-gate-error { margin: 0; }
+.login-gate-service { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 28px; border-top: 1px solid var(--cx-border-subtle); background: var(--cx-bg-subtle); }
+.login-gate-service > div { display: flex; flex-direction: column; min-width: 0; gap: 2px; color: var(--cx-fg-3); font-size: 12px; line-height: 1.5; }
+.login-gate-service > div > span:last-child { color: var(--cx-fg-2); overflow-wrap: anywhere; }
+@media (max-width: 420px) { .login-gate { padding: 20px 16px; } .login-gate-copy { padding: 24px 24px 0; } .login-gate-form, .login-gate-unavailable { padding-left: 24px; padding-right: 24px; } .login-gate-service { padding-left: 24px; padding-right: 24px; } }
+
/* ── accessibility helpers (additive — no visual change in normal flow) ──────
.sr-only hides content visually while keeping it in the accessibility tree
diff --git a/apps/web/ui/app/styles/chat/composer.css b/apps/web/ui/app/styles/chat/composer.css
index 5b9d0e6..1b7aeba 100644
--- a/apps/web/ui/app/styles/chat/composer.css
+++ b/apps/web/ui/app/styles/chat/composer.css
@@ -16,6 +16,70 @@
box-shadow: var(--cx-shadow-composer-focus);
}
+/* Compact (resting) layout while the reader browses history. Every geometry
+ * change is a padding / margin / min-height tween on the same easing, so the
+ * card height follows one continuous curve. In compact mode the editor pulls
+ * the toolbar row up over its last line with a negative margin; the add and
+ * submit buttons never leave the toolbar, they only slide vertically, and the
+ * middle controls fade out. --cx-composer-submit-w is published by PromptBar. */
+.cx-composer-card {
+ --cx-composer-morph: 320ms var(--cx-ease-drawer);
+}
+.cx-composer-body {
+ padding: 16px 16px 0;
+ margin-bottom: 0;
+ transition:
+ padding var(--cx-composer-morph),
+ margin-bottom var(--cx-composer-morph);
+}
+.cx-composer-body[data-after-chrome] { padding-top: 8px; }
+.cx-composer-body .cx-prompt-editor {
+ transition: min-height var(--cx-composer-morph), max-height var(--cx-composer-morph);
+}
+/* Fixed row heights keep the compact overlap exact even if a toolbar control is
+ * taller than the 28px buttons; such a control overflows into the padding. */
+.cx-composer-toolbar {
+ height: 48px;
+ padding-top: 8px;
+ padding-bottom: 12px;
+ transition: height var(--cx-composer-morph), padding var(--cx-composer-morph);
+}
+.cx-composer-tools {
+ transition:
+ opacity 200ms var(--cx-ease-out) 110ms,
+ transform var(--cx-composer-morph),
+ visibility 0s linear 0s;
+}
+
+.cx-composer-card[data-compact] .cx-composer-body {
+ padding: 10px calc(var(--cx-composer-submit-w, 28px) + 18px) 10px 46px;
+ margin-bottom: -44px;
+}
+.cx-composer-card[data-compact] .cx-composer-body[data-after-chrome] { padding-top: 6px; }
+.cx-composer-card[data-compact]:has(.cx-composer-add:empty) .cx-composer-body { padding-left: 16px; }
+.cx-composer-card[data-compact] .cx-composer-body .cx-prompt-editor {
+ min-height: 24px;
+ max-height: 192px;
+}
+.cx-composer-card[data-compact] .cx-composer-toolbar {
+ height: 44px;
+ padding-bottom: 8px;
+ pointer-events: none;
+}
+.cx-composer-card[data-compact] .cx-composer-add,
+.cx-composer-card[data-compact] .cx-composer-submit {
+ pointer-events: auto;
+}
+.cx-composer-card[data-compact] .cx-composer-tools {
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(4px);
+ transition:
+ opacity 120ms var(--cx-ease-out),
+ transform var(--cx-composer-morph),
+ visibility 0s linear 120ms;
+}
+
/* Inline context refs are built imperatively inside the contenteditable editor. */
.cx-ref-chip {
display: inline-flex;
@@ -27,7 +91,7 @@
padding: 0 7px;
vertical-align: -3px;
border-radius: 6px;
- font-size: 12.5px;
+ font-size: 13px;
font-weight: 500;
line-height: 22px;
white-space: nowrap;
@@ -41,7 +105,7 @@
.cx-ref-chip.is-stale { color: var(--cx-warning); background: var(--cx-warning-soft); }
.cx-ref-chip.is-missing { color: var(--cx-danger); background: var(--cx-danger-soft); }
.cx-ref-chip-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
-.cx-ref-chip-status { font-size: 10.5px; font-weight: 400; opacity: 0.85; }
+.cx-ref-chip-status { font-size: 12px; font-weight: 400; opacity: 0.85; }
.cx-drop-overlay {
background-image:
@@ -55,7 +119,169 @@
box-shadow: 0 0 0 1px var(--cx-border-subtle);
}
+/* Effort slider. The top stop "charges" the panel: a comet trail, drifting sparks
+ * and, on arrival, a shockwave that the popover's overflow clips at the edges. */
+.cx-effort-panel {
+ --effort: var(--cx-accent);
+ --effort-hi: color-mix(in srgb, var(--cx-accent) 45%, white);
+ --effort-glow: color-mix(in srgb, var(--cx-accent) 60%, transparent);
+}
+.cx-effort-track {
+ background: var(--cx-active);
+ box-shadow: inset 0 1px 2px hsl(var(--cx-shadow-color) / 0.08);
+}
+.cx-effort-glow,
+.cx-effort-fill {
+ position: absolute;
+ inset-block: 0;
+ left: 0;
+ border-radius: 999px;
+ pointer-events: none;
+}
+.cx-effort-glow {
+ background: var(--effort);
+ filter: blur(10px);
+ opacity: calc(var(--effort-level, 0) * 0.32);
+ transition: opacity var(--cx-dur-slow) var(--cx-ease-out);
+}
+.cx-effort-fill {
+ right: 0;
+ overflow: hidden;
+ background: var(--effort);
+ transition: background-color var(--cx-dur-slow) var(--cx-ease-out);
+}
+.cx-effort-track[data-max] .cx-effort-fill {
+ background: linear-gradient(90deg,
+ color-mix(in srgb, var(--effort) 62%, transparent) 0%,
+ var(--effort) 48%,
+ color-mix(in srgb, var(--effort) 70%, white) 100%);
+}
+.cx-effort-track[data-max] .cx-effort-glow {
+ opacity: 0.7;
+ animation: cx-effort-glow 2.4s ease-in-out infinite;
+}
+.cx-effort-beam {
+ position: absolute;
+ inset: 0;
+ clip-path: polygon(0 50%, 100% 10%, 100% 90%);
+ background: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--effort-hi) 55%, transparent) 55%, var(--effort-hi) 100%);
+ opacity: 0;
+ transition: opacity 420ms var(--cx-ease-out);
+}
+.cx-effort-track[data-max] .cx-effort-beam { opacity: 0.85; }
+.cx-effort-sheen {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(100deg, transparent 38%, rgb(255 255 255 / 0.5) 50%, transparent 62%);
+ background-size: 260% 100%;
+ opacity: 0;
+ transition: opacity 320ms var(--cx-ease-out);
+}
+.cx-effort-track[data-max] .cx-effort-sheen {
+ opacity: 1;
+ animation: cx-effort-sheen 2.1s linear infinite;
+}
+.cx-effort-spark {
+ position: absolute;
+ border-radius: 999px;
+ background: white;
+ box-shadow: 0 0 4px rgb(255 255 255 / 0.85);
+ opacity: 0;
+ animation: cx-effort-twinkle var(--d, 2.4s) ease-in-out var(--delay, 0s) infinite;
+}
+.cx-effort-track[data-max] .cx-effort-spark {
+ animation: cx-effort-drift var(--drift, 1.4s) linear var(--delay, 0s) infinite;
+}
+.cx-effort-thumb {
+ box-shadow: 0 1px 3px rgb(0 0 0 / 0.28), 0 0 0 0.5px rgb(0 0 0 / 0.08);
+ transition: box-shadow var(--cx-dur-slow) var(--cx-ease-out);
+}
+.cx-effort-thumb:focus-visible {
+ box-shadow: 0 1px 3px rgb(0 0 0 / 0.28), 0 0 0 2px var(--cx-bg), 0 0 0 4px var(--cx-focus);
+}
+.cx-effort-track[data-max] .cx-effort-thumb {
+ box-shadow: 0 1px 3px rgb(0 0 0 / 0.28), 0 0 12px 2px var(--effort-glow), 0 0 26px 4px color-mix(in srgb, var(--effort) 30%, transparent);
+}
+.cx-effort-thumb::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ opacity: 0;
+ pointer-events: none;
+}
+.cx-effort-track[data-max] .cx-effort-thumb::after {
+ animation: cx-effort-halo 1.8s ease-out infinite;
+}
+.cx-effort-title { color: var(--effort); }
+.cx-effort-title.is-max {
+ color: transparent;
+ background: linear-gradient(90deg, var(--effort) 0%, var(--effort-hi) 50%, var(--effort) 100%);
+ background-size: 200% 100%;
+ -webkit-background-clip: text;
+ background-clip: text;
+ filter: drop-shadow(0 0 6px var(--effort-glow));
+ animation: cx-shimmer 2.4s linear infinite;
+}
+.cx-effort-bloom {
+ position: absolute;
+ width: 140px;
+ height: 140px;
+ border-radius: 50%;
+ background: radial-gradient(circle, color-mix(in srgb, var(--effort-hi) 80%, transparent) 0%, var(--effort-glow) 30%, transparent 68%);
+}
+.cx-effort-shock {
+ position: absolute;
+ border-radius: 50%;
+ background: radial-gradient(circle closest-side,
+ transparent 86%,
+ color-mix(in srgb, var(--effort) 30%, transparent) 93%,
+ var(--effort-hi) 97.5%,
+ transparent 100%);
+ box-shadow: 0 0 22px var(--effort-glow);
+}
+.cx-effort-edge {
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ box-shadow:
+ inset 0 0 0 1px color-mix(in srgb, var(--effort-hi) 75%, transparent),
+ inset 0 0 26px color-mix(in srgb, var(--effort) 38%, transparent);
+}
+
+@keyframes cx-effort-twinkle {
+ 0%, 100% { opacity: 0; transform: scale(0.5); }
+ 50% { opacity: 0.8; transform: scale(1); }
+}
+@keyframes cx-effort-drift {
+ 0% { opacity: 0; transform: translateX(-16px) scale(0.6); }
+ 30% { opacity: 1; }
+ 100% { opacity: 0; transform: translateX(26px) scale(1.15); }
+}
+@keyframes cx-effort-sheen {
+ from { background-position: 100% 0; }
+ to { background-position: 0% 0; }
+}
+@keyframes cx-effort-glow {
+ 0%, 100% { opacity: 0.55; }
+ 50% { opacity: 0.9; }
+}
+@keyframes cx-effort-halo {
+ 0% { opacity: 0.9; box-shadow: 0 0 0 0 var(--effort-glow); }
+ 100% { opacity: 0; box-shadow: 0 0 0 11px transparent; }
+}
+
@media (prefers-reduced-motion: reduce) {
- .cx-composer-card { transition-duration: 1ms; }
+ .cx-composer-card,
+ .cx-composer-body,
+ .cx-composer-body .cx-prompt-editor,
+ .cx-composer-toolbar,
+ .cx-composer-tools { transition-duration: 1ms; transition-delay: 0s; }
}
-html[data-motion="reduce"] .cx-composer-card { transition-duration: 1ms; }
+html[data-motion="reduce"] :is(
+ .cx-composer-card,
+ .cx-composer-body,
+ .cx-composer-body .cx-prompt-editor,
+ .cx-composer-toolbar,
+ .cx-composer-tools
+) { transition-duration: 1ms; transition-delay: 0s; }
diff --git a/apps/web/ui/app/styles/chat/diff.css b/apps/web/ui/app/styles/chat/diff.css
index 2e940dc..753d794 100644
--- a/apps/web/ui/app/styles/chat/diff.css
+++ b/apps/web/ui/app/styles/chat/diff.css
@@ -80,7 +80,7 @@
height: 16px;
place-items: center;
border-radius: 4px;
- font: 600 10px/1 var(--cx-font-sans);
+ font: 600 12px/1 var(--cx-font-sans);
flex: none;
}
.cx-diff-status-add { color: var(--cx-add); background: var(--cx-add-bg); }
@@ -94,7 +94,7 @@
border-top: 1px solid var(--cx-border-subtle);
border-bottom: 1px solid var(--cx-border-subtle);
background: color-mix(in srgb, var(--accent) 4%, var(--page));
- font-size: 11.5px;
+ font-size: 12px;
line-height: 22px;
}
@@ -117,7 +117,7 @@
border-bottom: 1px solid var(--cx-border-subtle);
background: var(--cx-bg-subtle);
color: var(--cx-fg-3);
- font: 12.5px/1.5 var(--cx-font-sans);
+ font: 13px/1.5 var(--cx-font-sans);
}
.cx-diff-notice.is-warning { background: var(--cx-warning-soft); color: var(--cx-fg-2); }
.cx-diff-notice.is-raw { background: var(--cx-bg); }
@@ -154,7 +154,7 @@
.cx-diff-num {
padding-right: 8px;
color: var(--cx-fg-4);
- font-size: 11px;
+ font-size: 12px;
text-align: right;
user-select: none;
background: var(--tint-gutter);
diff --git a/apps/web/ui/app/styles/chat/markdown.css b/apps/web/ui/app/styles/chat/markdown.css
index fa22137..98a5c07 100644
--- a/apps/web/ui/app/styles/chat/markdown.css
+++ b/apps/web/ui/app/styles/chat/markdown.css
@@ -1,9 +1,9 @@
/* Chat workbench: markdown. Rules are prefixed `.cx-` and never target other pages. */
.cx-prose {
- --cx-prose-fs: var(--cx-msg-fs, 14px);
- --cx-prose-lh: 1.72;
- --cx-prose-gap: 0.85em;
+ --cx-prose-fs: var(--cx-msg-fs, 15px);
+ --cx-prose-lh: 1.7;
+ --cx-prose-gap: 1em;
min-width: 0;
max-width: 100%;
font-size: var(--cx-prose-fs);
@@ -14,7 +14,7 @@
}
.cx-prose-sm {
- --cx-prose-fs: calc(var(--cx-msg-fs, 14px) - 1px);
+ --cx-prose-fs: calc(var(--cx-msg-fs, 15px) - 1px);
--cx-prose-lh: 1.65;
--cx-prose-gap: 0.6em;
color: var(--cx-fg-2);
@@ -30,13 +30,12 @@
.cx-prose :where(h1, h2, h3, h4, h5, h6) {
font-weight: 600;
- letter-spacing: -0.01em;
- line-height: 1.35;
+ line-height: 1.4;
color: var(--cx-fg);
}
-.cx-prose h1 { font-size: 1.36em; margin-top: 1.5em; }
-.cx-prose h2 { font-size: 1.2em; margin-top: 1.4em; }
-.cx-prose h3 { font-size: 1.07em; margin-top: 1.25em; }
+.cx-prose h1 { font-size: 1.3em; margin-top: 1.7em; }
+.cx-prose h2 { font-size: 1.17em; margin-top: 1.6em; }
+.cx-prose h3 { font-size: 1.05em; margin-top: 1.4em; }
.cx-prose :where(h4, h5, h6) { font-size: 1em; margin-top: 1.15em; }
.cx-prose-sm :where(h1, h2, h3, h4, h5, h6) { font-size: 1em; margin-top: 0.9em; }
@@ -48,10 +47,10 @@
.cx-prose ul { list-style: disc; }
.cx-prose ul ul { list-style: circle; }
.cx-prose ol { list-style: decimal; }
-.cx-prose li { margin: 0.28em 0 0; padding-left: 0.2em; }
+.cx-prose li { margin: 0.4em 0 0; padding-left: 0.2em; }
.cx-prose li::marker { color: var(--cx-fg-4); }
.cx-prose ol > li::marker { font-variant-numeric: tabular-nums; color: var(--cx-fg-3); }
-.cx-prose li > :where(ul, ol) { margin-top: 0.28em; }
+.cx-prose li > :where(ul, ol) { margin-top: 0.4em; }
.cx-prose li > p { margin-top: 0.35em; }
.cx-prose li.task-list-item { list-style: none; margin-left: -1.3em; padding-left: 0; }
@@ -131,7 +130,7 @@
vertical-align: 1px;
background: var(--cx-accent-soft);
color: var(--cx-accent);
- font-size: 10.5px;
+ font-size: 12px;
font-weight: 500;
line-height: 1;
text-decoration: none;
@@ -151,7 +150,7 @@
line-height: 1.55;
}
.cx-md-table-wrap :where(th, td) {
- padding: 7px 12px;
+ padding: 9px 14px;
border-bottom: 1px solid var(--cx-border-subtle);
text-align: left;
vertical-align: top;
diff --git a/apps/web/ui/app/styles/chat/refine.css b/apps/web/ui/app/styles/chat/refine.css
new file mode 100644
index 0000000..9dc4aac
--- /dev/null
+++ b/apps/web/ui/app/styles/chat/refine.css
@@ -0,0 +1,23 @@
+/* /chat-only refinements. Every rule is scoped to the conversation layout so
+ * the other workspaces keep their existing rail and typography. */
+
+/* The global icon rail duplicates what the conversation sidebar footer menu
+ * already offers on desktop. Narrow screens keep it as the only entry point. */
+@media (min-width: 769px) {
+ .workspace-frame[data-conversation-layout="true"] {
+ --workspace-rail-width: 0px;
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .workspace-frame[data-conversation-layout="true"] > .workspace-activity-rail {
+ display: none;
+ }
+}
+
+/* The app-wide -0.006em tracking tightens every glyph by ~0.08px at body
+ * size, which reads as cramped for CJK and mixed text. Reading surfaces use
+ * the font's own spacing. */
+.workspace-frame[data-conversation-layout="true"],
+.ai-conv-shell,
+.cx-portal {
+ letter-spacing: 0;
+}
diff --git a/apps/web/ui/app/styles/chat/sidebar.css b/apps/web/ui/app/styles/chat/sidebar.css
index e24115f..0bfde06 100644
--- a/apps/web/ui/app/styles/chat/sidebar.css
+++ b/apps/web/ui/app/styles/chat/sidebar.css
@@ -26,7 +26,7 @@
background: transparent;
box-shadow: none;
color: var(--cx-fg);
- font-size: 13.5px;
+ font-size: 14px;
font-weight: 500;
line-height: 20px;
cursor: pointer;
@@ -64,7 +64,7 @@
}
.ai-conv-sidebar.cx-sidebar .cx-sb-new kbd {
color: var(--cx-fg-3);
- font: 500 10.5px/1 var(--cx-font-sans);
+ font: 500 12px/1 var(--cx-font-sans);
}
/* Right-edge fade for truncated row titles (no ellipsis, like ChatGPT). */
@@ -73,6 +73,12 @@
mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent);
}
+/* Set on while a sidebar row or folder is being dragged. */
+html[data-cx-sidebar-dragging],
+html[data-cx-sidebar-dragging] * {
+ cursor: grabbing !important;
+}
+
@media (hover: none) {
.ai-conv-sidebar.cx-sidebar .cx-sb-new .cx-sb-new-kbd { display: none; }
}
diff --git a/apps/web/ui/app/styles/chat/timeline.css b/apps/web/ui/app/styles/chat/timeline.css
index f7a5c58..adf35b9 100644
--- a/apps/web/ui/app/styles/chat/timeline.css
+++ b/apps/web/ui/app/styles/chat/timeline.css
@@ -40,3 +40,56 @@
pointer-events: none;
background: linear-gradient(to bottom, transparent, var(--cx-bg));
}
+
+/* Turn process timeline: a rail joins each row's glyph (16px column at 6px inset) to the next. */
+.cx-activity-timeline > [role="listitem"] {
+ position: relative;
+}
+.cx-activity-timeline > [role="listitem"]:has([data-timeline-node]):not(:has([data-timeline-skip])):not(:last-child)::before {
+ content: "";
+ position: absolute;
+ left: 13.5px;
+ top: 27px;
+ bottom: -8px;
+ width: 1px;
+ border-radius: 1px;
+ background: var(--cx-border);
+ transform-origin: center top;
+ animation: cx-timeline-grow 520ms var(--cx-ease-out) both;
+ pointer-events: none;
+}
+@keyframes cx-timeline-grow {
+ from { transform: scaleY(0); }
+ to { transform: scaleY(1); }
+}
+
+/* Finish row: the badge pops in, then its tick draws itself. */
+.cx-timeline-done {
+ animation: cx-timeline-pop 460ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
+}
+.cx-timeline-done [data-tick] {
+ stroke-dasharray: 14;
+ stroke-dashoffset: 14;
+ animation: cx-timeline-draw 360ms var(--cx-ease-out) 180ms forwards;
+}
+@keyframes cx-timeline-pop {
+ 0% { transform: scale(0.5); opacity: 0; }
+ 60% { transform: scale(1.12); opacity: 1; }
+ 100% { transform: scale(1); }
+}
+@keyframes cx-timeline-draw {
+ to { stroke-dashoffset: 0; }
+}
+
+.cx-source-chip {
+ animation: cx-source-chip-in 420ms var(--cx-ease-out) both;
+}
+@keyframes cx-source-chip-in {
+ from { opacity: 0; transform: translateY(4px) scale(0.92); }
+ to { opacity: 1; transform: none; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .cx-timeline-done [data-tick] { stroke-dashoffset: 0; animation: none; }
+}
+html[data-motion="reduce"] .cx-timeline-done [data-tick] { stroke-dashoffset: 0; animation: none; }
diff --git a/apps/web/ui/app/styles/chat/tokens.css b/apps/web/ui/app/styles/chat/tokens.css
index aeaeb9b..dfd9f44 100644
--- a/apps/web/ui/app/styles/chat/tokens.css
+++ b/apps/web/ui/app/styles/chat/tokens.css
@@ -129,7 +129,7 @@
/* ── Root surface ─────────────────────────────────────────────────────────── */
.cx-root {
font-family: var(--cx-font-sans);
- font-size: 14px;
+ font-size: var(--cx-ui-fs, 14px);
line-height: 1.5;
color: var(--cx-fg);
background: var(--cx-bg);
@@ -172,7 +172,7 @@
.cx-portal {
font-family: var(--cx-font-sans);
- font-size: 14px;
+ font-size: var(--cx-ui-fs, 14px);
line-height: 1.5;
color: var(--cx-fg);
-webkit-font-smoothing: antialiased;
diff --git a/apps/web/ui/app/styles/competition-catalog.css b/apps/web/ui/app/styles/competition-catalog.css
index a2cf762..e4ded37 100644
--- a/apps/web/ui/app/styles/competition-catalog.css
+++ b/apps/web/ui/app/styles/competition-catalog.css
@@ -27,7 +27,9 @@
flex-direction: column;
gap: 6px;
max-height: 240px;
+ overflow-x: hidden;
overflow-y: auto;
+ overflow-wrap: anywhere;
}
.cap-grant-item {
padding: 8px 10px;
diff --git a/apps/web/ui/app/styles/pentest.css b/apps/web/ui/app/styles/pentest.css
index fcfc477..f984310 100644
--- a/apps/web/ui/app/styles/pentest.css
+++ b/apps/web/ui/app/styles/pentest.css
@@ -6,42 +6,6 @@
.insp-pentest-outcome>strong.confirmed{color:var(--green)}
.insp-pentest-outcome>span{font-size:11px;line-height:1.5;color:var(--muted);overflow-wrap:anywhere}
.insp-pentest-outcome .insp-panel-writeup{margin-top:8px;width:100%}
-.pentest-document{height:100%;overflow-y:auto;background:var(--bg);color:var(--text);padding:34px clamp(24px,3vw,44px) 80px;scroll-behavior:smooth}
-.pentest-document-loading{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;color:var(--muted)}
-.pentest-document-head{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;padding-bottom:24px;border-bottom:1px solid var(--line)}
-.pentest-document-eyebrow{display:block;color:var(--accent);font-size:10px;font-weight:750;letter-spacing:.18em;margin-bottom:11px}
-.pentest-document-head h1,.pentest-document-loading h1{font-size:27px;line-height:1.2;letter-spacing:-.035em;font-weight:700;margin:0 0 9px}
-.pentest-document-head p{font-size:12px;line-height:1.6;color:var(--muted);margin:0}
-.pentest-document-export,.pentest-document-error button{display:inline-flex;align-items:center;justify-content:center;gap:7px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:8px 11px;font-size:12px;cursor:pointer;white-space:nowrap}
-.pentest-document-export:hover,.pentest-document-error button:hover{border-color:var(--accent);color:var(--accent)}
-.pentest-document-meta{display:grid;grid-template-columns:minmax(0,1.3fr) repeat(2,minmax(0,1fr));border-bottom:1px solid var(--line);padding:17px 0 20px;gap:0}
-.pentest-document-meta div{display:flex;flex-direction:column;gap:7px;padding:0 16px;border-right:1px solid var(--line);min-width:0}
-.pentest-document-meta div:first-child{padding-left:0}
-.pentest-document-meta div:last-child{border-right:0}
-.pentest-document-meta span{color:var(--muted);font-size:11px}
-.pentest-document-meta strong{font-size:13px;font-weight:600;overflow-wrap:anywhere}
-.pentest-document-positive{color:var(--green)}
-.pentest-document-error{display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid color-mix(in srgb,var(--red) 55%,var(--line));background:color-mix(in srgb,var(--red) 8%,var(--bg));color:var(--red);border-radius:8px;padding:12px 14px;margin:18px 0;font-size:12px}
-.pentest-document-layout{display:grid;grid-template-columns:138px minmax(0,1fr);gap:30px;padding-top:26px}
-.pentest-document-outline{position:sticky;top:18px;align-self:start;display:flex;flex-direction:column;gap:2px;border-left:1px solid var(--line);max-height:calc(100vh - 170px);overflow-y:auto}
-.pentest-document-outline>span{font-size:11px;color:var(--muted);font-weight:650;padding:0 0 13px 13px}
-.pentest-document-outline a{display:block;border-left:2px solid transparent;margin-left:-1px;padding:7px 0 7px 12px;color:var(--muted);font-size:12px;text-decoration:none;line-height:1.45}
-.pentest-document-outline a:hover,.pentest-document-outline a:focus-visible{border-color:var(--accent);color:var(--accent)}
-.pentest-document-content{min-width:0;max-width:680px}
-.pentest-document-section{border-bottom:1px solid var(--line);padding:0 0 30px;margin-bottom:30px;scroll-margin-top:20px}
-.pentest-document-section:last-child{border-bottom:0}
-.pentest-document-section-label{color:var(--accent);font-size:10px;font-weight:700;letter-spacing:.09em;margin-bottom:10px}
-.pentest-document-section h2{font-size:19px;line-height:1.35;letter-spacing:-.025em;font-weight:680;margin:0 0 16px}
-.pentest-document-section p{color:var(--text);font-size:13px;line-height:1.85;margin:0 0 13px;overflow-wrap:anywhere;white-space:pre-wrap}
-.pentest-document-section p:last-child{margin-bottom:0}
-.pentest-document-body{max-width:65ch}
-.pentest-document-citations{display:flex;align-items:center;gap:7px;flex-wrap:wrap;margin-top:16px}
-.pentest-document-citations span,.pentest-document-citations small{color:var(--muted);font-size:11px}
-.pentest-document-citations button{border:1px solid color-mix(in srgb,var(--accent) 45%,var(--line));border-radius:6px;background:color-mix(in srgb,var(--accent) 8%,var(--bg));color:var(--accent);font-size:11px;font-weight:650;padding:4px 8px;cursor:pointer}
-.pentest-document-citations button:hover{background:color-mix(in srgb,var(--accent) 18%,var(--bg))}
-.pentest-document-objective{margin-top:20px;color:var(--muted);font-size:11px}
-.pentest-document-objective summary{cursor:pointer}
-.pentest-document-objective p{margin-top:9px;color:var(--muted);font-size:12px}
/* Pentest shares the CTF runtime navigation and timeline graph; its evidence
view keeps the original sources one click away. */
@@ -134,250 +98,256 @@
@media(max-width:850px){.pentest-evidence-layout{display:block}.pentest-evidence-aside{border-left:0;border-top:1px solid var(--line)}.pentest-evidence-main{padding:14px}.pentest-evidence-summary{grid-template-columns:1fr 1fr}.pentest-evidence-summary>div:last-child{grid-column:1/-1;border-right:0;border-top:1px solid var(--line);padding:12px 0 0;margin-top:12px}}
@media(min-width:851px) and (max-width:1300px){.pentest-evidence-layout{display:block}.pentest-evidence-aside{border-left:0;border-top:1px solid var(--line)}.pentest-evidence-aside>section{display:inline-block;vertical-align:top;width:calc(50% - 12px);margin-right:12px}}
-/* The report sizes against its workbench pane, not the browser viewport. */
-.pentest-document:not(.pentest-document-loading){--pentest-report-gutter:40px;display:grid;grid-template-columns:minmax(0,1fr) 248px;grid-template-rows:auto minmax(0,1fr);min-width:0;height:100%;overflow:hidden;padding:0}
-.pentest-document-reading{grid-column:1;grid-row:2;min-width:0;min-height:0;height:auto;overflow-x:hidden;overflow-y:auto;padding:36px var(--pentest-report-gutter) 90px;scroll-behavior:smooth}
-.pentest-document-head h1{font-size:29px;line-height:1.28;max-width:900px;overflow-wrap:anywhere}
-.pentest-report-version{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:10px;color:var(--muted);white-space:nowrap}
-.pentest-document-meta{grid-template-columns:repeat(3,minmax(0,1fr));padding:20px 0 23px}
-.pentest-document-meta strong{font-size:13px}
-.pentest-document-layout{grid-template-columns:132px minmax(0,1fr);gap:28px;padding-top:30px}
-.pentest-document-outline{position:sticky;top:16px;gap:1px}
-.pentest-document-outline button{display:block;width:100%;margin-left:-1px;text-align:left;border:0;border-left:2px solid transparent;background:transparent;color:var(--muted);padding:8px 0 8px 12px;font-size:12px;line-height:1.45;cursor:pointer;overflow:hidden;text-overflow:ellipsis}
-.pentest-document-outline button:hover,.pentest-document-outline button.active,.pentest-document-outline button:focus-visible{color:var(--accent);border-left-color:var(--accent)}
-.pentest-document-outline button:focus-visible,.pentest-document-outline a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
-.pentest-document-outline a{font-size:12px}
-.pentest-document-content{max-width:840px;width:100%}
-.pentest-document-section h2{font-size:20px;margin-bottom:13px}
-.pentest-document-section p{font-size:14px;line-height:1.75;margin-bottom:12px;white-space:pre-wrap}
-.pentest-document-section-label{font-size:10px;letter-spacing:.08em}
-.pentest-report-definition{display:grid;grid-template-columns:90px minmax(0,1fr);gap:11px 16px;margin:0;font-size:13px;line-height:1.65}
-.pentest-report-definition dt{color:var(--muted)}
-.pentest-report-definition dd{margin:0;overflow-wrap:anywhere}
-.pentest-report-finding-list{border-top:1px solid var(--line)}
-.pentest-report-table-wrap{width:100%;overflow-x:auto;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
-.pentest-report-table{width:100%;border-collapse:collapse;min-width:650px;table-layout:fixed}
-.pentest-report-table caption{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
-.pentest-report-table th{text-align:left;color:var(--muted);font-size:10px;font-weight:600;padding:11px 10px;border-bottom:1px solid var(--line)}
-.pentest-report-table th:first-child,.pentest-report-table td:first-child{width:52px;padding-left:4px}
-.pentest-report-table th:nth-child(2){width:47%}
-.pentest-report-table th:nth-child(3){width:72px}
-.pentest-report-table th:last-child{width:76px}
-.pentest-report-table td{padding:15px 10px;border-bottom:1px solid var(--line);vertical-align:top;font-size:11px;line-height:1.5;overflow-wrap:anywhere}
-.pentest-report-table tr:last-child td{border-bottom:0}
-.pentest-report-table tbody tr:hover{background:color-mix(in srgb,var(--accent) 4%,var(--bg))}
-.pentest-report-table td:first-child{color:var(--muted);font-variant-numeric:tabular-nums}
-.pentest-report-page-link{display:flex;flex-direction:column;gap:5px;text-align:left;border:0;background:transparent;color:var(--text);padding:0;cursor:pointer;text-decoration:none}
-.pentest-report-page-link strong{font-size:13px;line-height:1.45;font-weight:600}
-.pentest-report-page-link:hover strong,.pentest-report-page-link:focus-visible strong{color:var(--accent);text-decoration:underline;text-underline-offset:3px}
-.pentest-report-page-link small{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;font-size:11px;color:var(--muted);line-height:1.55}
-.pentest-report-riskline{color:var(--muted)!important;font-size:11px!important;border-top:1px solid var(--line);padding-top:12px;margin-top:16px!important}
-.pentest-report-limitations{border-top:1px solid var(--line);margin-top:24px;padding-top:18px}
-.pentest-report-limitations h3{font-size:12px;margin:0 0 9px}
-.pentest-report-back{display:inline-block;border:0;background:transparent;color:var(--accent);font-size:12px;padding:0;margin:0 0 22px;cursor:pointer;text-decoration:none}
-.pentest-print-full{display:none}
-.pentest-document-outline small{float:right;font-size:10px;color:var(--muted)}
-.pentest-report-finding-row{display:grid;grid-template-columns:38px minmax(0,1fr) 18px;align-items:start;gap:12px;width:100%;text-align:left;border:0;border-bottom:1px solid var(--line);background:transparent;color:var(--text);padding:15px 4px;cursor:pointer}
-.pentest-report-finding-row:hover{background:color-mix(in srgb,var(--accent) 6%,var(--bg))}
-.pentest-report-finding-row>span:nth-child(2){display:flex;flex-direction:column;gap:5px;min-width:0}
-.pentest-report-finding-row strong{font-size:12px;font-weight:650;line-height:1.45}
-.pentest-report-finding-row small{font-size:10px;color:var(--muted);overflow-wrap:anywhere}
-.pentest-report-finding-row em{font-size:11px;font-style:normal;color:var(--text);line-height:1.6;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
-.pentest-report-finding-row>svg{align-self:center;color:var(--muted)}
-.pentest-report-severity{display:inline-flex;align-items:center;justify-content:center;min-width:30px;border-radius:5px;background:var(--panel2);color:var(--muted);padding:3px 6px;font-size:10px;font-weight:700;white-space:nowrap}
-.pentest-report-severity.severity-critical,.pentest-report-severity.severity-high{background:color-mix(in srgb,var(--red) 16%,var(--bg));color:var(--red)}
-.pentest-report-severity.severity-medium{background:color-mix(in srgb,var(--amber) 15%,var(--bg));color:var(--amber)}
-.pentest-report-severity.severity-low{background:color-mix(in srgb,var(--blue) 14%,var(--bg));color:var(--blue)}
-.pentest-report-severity.severity-informational{background:color-mix(in srgb,var(--accent) 13%,var(--bg));color:var(--accent)}
-.pentest-report-finding-intro{display:flex;flex-direction:column;align-items:flex-start;gap:13px;margin-bottom:18px}
-.pentest-report-finding-intro>p{font-size:14px;line-height:1.75;margin:0}
-.pentest-report-proof{border-top:1px solid var(--line);padding:13px 0}
-.pentest-report-proof>button{display:inline-flex;border:1px solid color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:6px;background:color-mix(in srgb,var(--accent) 8%,var(--bg));color:var(--accent);padding:5px 8px;font-size:11px;cursor:pointer;margin-right:9px}
-.pentest-report-proof>strong{font-size:12px;line-height:1.5;font-weight:600}
-.pentest-report-proof>p{font-size:11px;color:var(--muted);margin:8px 0 0}
-.pentest-report-proof details{margin-top:10px;border:1px solid var(--line);border-radius:6px;padding:8px 10px}
-.pentest-report-proof summary{color:var(--accent);font-size:11px;cursor:pointer}
-.pentest-report-proof pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere;font-size:10px;line-height:1.6;max-height:260px;overflow:auto}
-.pentest-report-section-lead{color:var(--muted)!important;margin-bottom:20px!important}
-.pentest-report-evidence-group h3,.pentest-report-reproduction-group h3,.pentest-report-impact-row h3,.pentest-report-advice-row h3{font-size:13px;line-height:1.5;margin:0 0 15px;font-weight:650}
-.pentest-report-evidence-group+.pentest-report-evidence-group,.pentest-report-reproduction-group+.pentest-report-reproduction-group,.pentest-report-impact-row+.pentest-report-impact-row,.pentest-report-advice-row+.pentest-report-advice-row{border-top:1px solid var(--line);padding-top:20px;margin-top:20px}
-.pentest-report-proof-list{display:flex;flex-direction:column;gap:15px}
-.pentest-report-proof-list .pentest-report-proof{border:0;padding:0}
-.pentest-report-proof-heading{display:flex;align-items:center;gap:12px;margin-bottom:8px}
-.pentest-report-proof-heading>button{flex:none;border:1px solid color-mix(in srgb,var(--accent) 48%,var(--line));border-radius:6px;background:color-mix(in srgb,var(--accent) 8%,var(--bg));color:var(--accent);padding:7px 11px;font-size:12px;font-weight:700;cursor:pointer}
-.pentest-report-proof-heading>strong{min-width:0;font-size:13px;font-weight:600;line-height:1.5}
-.pentest-report-proof-body{border:1px solid var(--line);border-radius:8px;background:var(--panel);padding:12px 15px}
-.pentest-evidence-image{margin:12px 0 0}.pentest-evidence-image img{display:block;max-width:100%;max-height:520px;object-fit:contain;border:1px solid var(--line)}.pentest-evidence-image figcaption{margin-top:6px;font-size:11px;color:var(--muted)}
-.pentest-report-screenshots{border-top:1px solid var(--line);margin-top:20px;padding-top:16px}.pentest-report-screenshots h3{font-size:12px;margin:0 0 10px}
-.pentest-report-proof-body>p{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;margin:0 0 9px!important;font-size:12px!important;line-height:1.65!important;white-space:pre-wrap}
-.pentest-report-proof-body>p.pentest-report-proof-significance{color:var(--muted);-webkit-line-clamp:2}
-.pentest-report-proof-body>div{display:grid;grid-template-columns:62px minmax(0,1fr);gap:5px 9px;font-size:11px;line-height:1.55;border-top:1px solid var(--line);padding-top:9px}
-.pentest-report-proof-body>div>span:nth-child(odd){color:var(--muted)}
-.pentest-report-proof-body>div>span:nth-child(2){overflow-wrap:anywhere}
-.pentest-report-proof-body>div>button{border:0;background:transparent;text-align:left;color:var(--accent);font:inherit;cursor:pointer;padding:0}
-.pentest-report-impact-row>.pentest-report-severity{margin:0 0 10px}
-.pentest-report-impact-row>.pentest-report-inference{margin-bottom:14px}
-.pentest-report-steps{list-style:none;margin:0;padding:0}
-.pentest-report-steps li{display:flex;align-items:flex-start;gap:12px;margin:0 0 11px}
-.pentest-report-steps li>span{flex:none;display:grid;place-items:center;width:25px;height:25px;border-radius:50%;background:color-mix(in srgb,var(--accent) 22%,var(--bg));color:var(--accent);font-size:11px;font-weight:700}
-.pentest-report-steps li>p{padding-top:2px;margin:0}
-.pentest-report-inference{border-left:2px solid var(--amber);padding:9px 0 2px 13px;margin-top:17px;background:color-mix(in srgb,var(--amber) 5%,var(--bg))}
-.pentest-report-inference strong{font-size:11px;color:var(--amber)}
-.pentest-report-inference p{margin:7px 0}
-.pentest-report-bullets{padding-left:19px;margin:0;font-size:12px;line-height:1.7}
-.pentest-report-bullets li{margin-bottom:8px}
-.pentest-report-citations{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:16px}
-.pentest-report-citations button{border:1px solid color-mix(in srgb,var(--accent) 45%,var(--line));border-radius:6px;background:color-mix(in srgb,var(--accent) 7%,var(--bg));color:var(--accent);padding:5px 8px;font-size:10px;font-weight:650;cursor:pointer}
-.pentest-report-citations button:hover{background:color-mix(in srgb,var(--accent) 17%,var(--bg))}
-.pentest-report-citations>span{font-size:11px;color:var(--muted)}
-.pentest-report-aside{grid-column:2;grid-row:1/-1;min-width:0;min-height:0;overflow:auto;border-left:1px solid var(--line);padding:28px 18px 60px}
-.pentest-report-aside section{padding:12px 0 19px;border-bottom:1px solid var(--line)}
-.pentest-report-aside section:first-child{padding-top:0}
-.pentest-report-aside h3{font-size:13px;font-weight:650;margin:0 0 14px}
-.pentest-report-aside dl{display:grid;grid-template-columns:80px minmax(0,1fr);gap:10px 12px;margin:0;font-size:12px;line-height:1.5}
-.pentest-report-aside dt,.pentest-report-aside p{color:var(--muted)}
-.pentest-report-aside dd{margin:0;overflow-wrap:anywhere}
-.pentest-report-aside dd.positive{color:var(--green)}
-.pentest-report-aside>section>button:not(.pentest-report-primary-action):not(.pentest-report-secondary-action){display:flex;justify-content:space-between;gap:8px;width:100%;border:0;background:transparent;color:var(--text);text-align:left;padding:8px 5px;font-size:12px;line-height:1.5;cursor:pointer}
-.pentest-report-aside>section>button.selected{color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,var(--bg))}
-.pentest-report-aside>section>button span{color:var(--muted);white-space:nowrap;font-size:10px}
-.pentest-report-version-picker{display:flex;flex-direction:column;gap:7px;color:var(--muted);font-size:10px}
-.pentest-report-version-picker select{width:100%;border:1px solid var(--line);border-radius:6px;padding:8px;color:var(--text);background:var(--panel);font-size:11px}
-.pentest-report-aside p{font-size:12px;line-height:1.6;margin:0 0 8px}
-.pentest-report-aside .pentest-report-citations{margin-top:8px}
-.pentest-report-aside .pentest-report-citations button{font-size:10px}
-.pentest-report-primary-action,.pentest-report-secondary-action{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;min-height:40px;border-radius:7px;padding:8px;font-size:12px;font-weight:650;cursor:pointer}
-.pentest-report-primary-action{border:1px solid color-mix(in srgb,var(--accent) 55%,var(--line));background:var(--accent);color:var(--accent-foreground,var(--bg));margin-bottom:8px}
-.pentest-report-secondary-action{border:1px solid var(--line);background:transparent;color:var(--text);margin-top:7px}
-.pentest-report-secondary-action:hover{border-color:var(--accent);color:var(--accent)}
-.pentest-report-secondary-action:disabled{opacity:.5;cursor:wait}
+/* ── Pentest report ────────────────────────────────────────────────────────
+ Modeled on document-style security pages (GitHub Advisory, HackerOne
+ reports): one reading column, a metadata sidebar, neutral 1px borders.
+ Colour is reserved for severity text/dots and links. */
.pentest-workbench .convo-mainpane:has(> .pentest-document){container-name:pentest-report-pane;container-type:inline-size}
+.pentest-document{--pentest-gutter:32px;--sev:var(--muted);position:relative;display:grid;grid-template-rows:auto minmax(0,1fr);height:100%;min-width:0;overflow:hidden;background:var(--bg);color:var(--text)}
+.pentest-document .severity-critical{--sev:var(--red)}
+.pentest-document .severity-high{--sev:color-mix(in srgb,var(--red) 55%,var(--amber))}
+.pentest-document .severity-medium{--sev:var(--amber)}
+.pentest-document .severity-low{--sev:var(--blue)}
+.pentest-document .severity-informational{--sev:var(--muted)}
+.pentest-document .severity-unrated{--sev:var(--dim)}
+.pentest-document code{font-family:var(--font-mono);font-size:.92em;color:inherit;background:none;padding:0;overflow-wrap:anywhere}
+.pentest-document a{color:var(--accent)}
+.pentest-visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
+
+@keyframes pentest-fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
+@keyframes pentest-pop{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
+@keyframes pentest-shimmer{from{background-position:150% 0}to{background-position:-50% 0}}
+@keyframes pentest-spin{to{transform:rotate(360deg)}}
+.pentest-spin{animation:pentest-spin 1s linear infinite}
+
+/* Loading / fetch error */
+.pentest-document-loading{grid-template-rows:minmax(0,1fr);place-items:center;padding:32px}
+.pentest-loading-card{display:flex;flex-direction:column;align-items:center;gap:6px;max-width:380px;text-align:center;animation:pentest-fade var(--duration-medium) var(--ease-smooth-out) both}
+.pentest-loading-mark{display:grid;place-items:center;margin-bottom:6px;color:var(--muted)}
+.pentest-loading-mark.is-error{color:var(--red)}
+.pentest-loading-card h1{margin:0;color:var(--bright);font-size:16px;font-weight:600}
+.pentest-loading-card p{margin:0;color:var(--muted);font-size:13px;line-height:1.6}
+
+/* Atoms */
+.pentest-doc-kicker{margin:0 0 6px;color:var(--muted);font-size:12.5px;font-weight:500}
+.pentest-count{display:inline-flex;align-items:center;min-width:20px;height:18px;padding:0 6px;border-radius:var(--r-pill);background:var(--panel3);color:var(--muted);font:600 11px/1 var(--font-mono);justify-content:center}
+.pentest-chip{--tone:var(--muted);display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 8px;border:1px solid var(--line2);border-radius:var(--r-pill);color:var(--tone);font-size:12px;font-weight:550;line-height:1;white-space:nowrap}
+.pentest-chip--accent{--tone:var(--accent)}
+.pentest-chip--positive{--tone:var(--green)}
+.pentest-chip--warning{--tone:var(--amber)}
+.pentest-chip--danger{--tone:var(--red)}
+.pentest-report-severity{display:inline-flex;align-items:center;gap:6px;color:var(--sev);font-size:13px;font-weight:600;line-height:1.3;white-space:nowrap}
+.pentest-report-severity>i{flex:none;width:8px;height:8px;border-radius:50%;background:currentColor}
-/* Both editions place controls outside their scrollable document. */
-.pentest-document.pentest-document--reader{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr)}
-.pentest-document--reader .pentest-document-reading{grid-column:1;grid-row:2;overflow-x:hidden;overflow-y:auto;padding:0}
-.pentest-report-edition-bar{grid-column:1;grid-row:1;position:relative;z-index:6;display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0;min-height:64px;padding:9px var(--pentest-report-gutter);border-bottom:1px solid var(--line);background:var(--bg);color:var(--muted);font-size:13px}
-.pentest-report-edition-primary{display:flex;align-items:center;flex-wrap:wrap;gap:8px 14px;min-width:0}
-.pentest-report-edition-bar button,.pentest-report-export-menu summary{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--text);padding:7px 11px;font:inherit;font-weight:600;white-space:nowrap;cursor:pointer}
-.pentest-report-edition-bar button:hover,.pentest-report-export-menu summary:hover{border-color:var(--accent);color:var(--accent)}
-.pentest-report-edition-bar :is(button,select,summary):focus-visible{outline:2px solid var(--accent);outline-offset:2px}
-.pentest-report-edition-switch{display:inline-flex;flex:none;gap:3px;padding:3px;border:1px solid var(--line);border-radius:9px;background:var(--panel2)}
-.pentest-report-edition-switch button{border-color:transparent;background:transparent}
-.pentest-report-edition-switch button[aria-pressed="true"]{border-color:var(--line);background:var(--panel);box-shadow:0 1px 3px color-mix(in srgb,var(--text) 10%,transparent)}
-.pentest-report-edition-bar button:disabled{opacity:.45;cursor:not-allowed}
-.pentest-report-edition-hint{color:var(--muted);font-size:11px}
-.pentest-report-version-control{display:inline-flex;align-items:center;gap:8px;min-width:0;color:var(--muted);font-size:11px;white-space:nowrap}
-.pentest-report-version-control select{min-width:0;max-width:220px;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--text);padding:7px 9px;font:inherit;font-size:13px;cursor:pointer}
+/* Toolbar */
+.pentest-report-edition-bar{position:relative;z-index:6;display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0;min-height:52px;padding:8px var(--pentest-gutter);border-bottom:1px solid var(--line);background:var(--bg)}
+.pentest-report-edition-primary{display:flex;align-items:center;flex-wrap:wrap;gap:8px 10px;min-width:0}
+.pentest-report-edition-switch{display:inline-grid;grid-auto-flow:column;flex:none;gap:2px;height:32px;padding:2px;border:1px solid var(--line);border-radius:8px;background:var(--panel2)}
+.pentest-report-edition-switch button{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 11px;border:0;border-radius:6px;background:transparent;color:var(--muted);font:inherit;font-size:12.5px;font-weight:500;line-height:1;white-space:nowrap;cursor:pointer;transition:color var(--control-feedback-dur) var(--ease-out),background-color var(--control-feedback-dur) var(--ease-out)}
+.pentest-report-edition-switch button:hover:not(:disabled):not([aria-pressed="true"]){color:var(--text)}
+.pentest-report-edition-switch button[aria-pressed="true"]{background:var(--panel);color:var(--bright);box-shadow:0 0 0 1px var(--line),0 1px 2px color-mix(in srgb,var(--shadow-color) 8%,transparent)}
+.pentest-report-edition-switch button:disabled{opacity:.45;cursor:not-allowed}
+.pentest-report-version-control{position:relative;display:inline-flex;align-items:center;min-width:0;color:var(--muted)}
+.pentest-report-version-control>svg{position:absolute;left:10px;pointer-events:none}
+.pentest-report-version-control>svg.pentest-report-version-caret{left:auto;right:9px;color:var(--dim)}
+.pentest-report-version-control select{appearance:none;min-width:0;max-width:340px;height:32px;padding:0 30px 0 31px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);font:inherit;font-size:12.5px;text-overflow:ellipsis;cursor:pointer;transition:border-color var(--control-feedback-dur) var(--ease-out)}
+.pentest-report-version-control select:hover{border-color:var(--line2)}
+.pentest-report-edition-hint{display:inline-flex;align-items:center;gap:5px;color:var(--dim);font-size:12px}
.pentest-report-export-menu{position:relative;flex:none}
-.pentest-report-export-menu summary{list-style:none;user-select:none}
+.pentest-report-export-menu summary{display:inline-flex;align-items:center;gap:7px;height:32px;padding:0 10px 0 11px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);font-size:12.5px;font-weight:500;list-style:none;white-space:nowrap;user-select:none;cursor:pointer;transition:border-color var(--control-feedback-dur) var(--ease-out),background-color var(--control-feedback-dur) var(--ease-out)}
.pentest-report-export-menu summary::-webkit-details-marker{display:none}
-.pentest-report-export-menu[open] summary{border-color:var(--accent);color:var(--accent)}
-.pentest-report-export-options{position:absolute;top:calc(100% + 7px);right:0;z-index:10;display:flex;flex-direction:column;gap:2px;min-width:244px;max-width:min(280px,calc(100vw - 32px));padding:6px;border:1px solid var(--line);border-radius:9px;background:var(--panel);box-shadow:0 10px 30px color-mix(in srgb,var(--text) 13%,transparent)}
-.pentest-report-export-group-label{padding:8px 11px 4px;color:var(--muted);font-size:11px;font-weight:700}
-.pentest-report-export-group-label:not(:first-child){border-top:1px solid var(--line);margin-top:4px}
-.pentest-report-export-options button{justify-content:flex-start;width:100%;border-color:transparent;background:transparent;text-align:left}
-.pentest-report-export-options button:hover{background:var(--panel2)}
-.pentest-reader{width:100%;max-width:920px;margin:0 auto;padding:46px 48px 100px;color:var(--text);font-size:14px;line-height:1.8;overflow-wrap:anywhere}
-.pentest-reader :is(p,li,dd,blockquote,a,h1,h2,h3,h4){overflow-wrap:anywhere}
+.pentest-report-export-menu summary:hover,.pentest-report-export-menu[open] summary{border-color:var(--line2);background:var(--panel2)}
+.pentest-report-export-caret{color:var(--dim);transition:transform var(--control-feedback-dur) var(--ease-out)}
+.pentest-report-export-menu[open] .pentest-report-export-caret{transform:rotate(180deg)}
+.pentest-report-export-options{position:absolute;top:calc(100% + 6px);right:0;z-index:30;display:flex;flex-direction:column;gap:1px;min-width:240px;max-width:min(300px,calc(100vw - 32px));padding:4px;border:1px solid var(--line);border-radius:10px;background:var(--panel);box-shadow:var(--shadow-overlay);animation:pentest-pop var(--duration-quick) var(--ease-smooth-out)}
+.pentest-report-export-group-label{padding:8px 9px 4px;color:var(--dim);font-size:11.5px;font-weight:500}
+.pentest-report-export-group-label:not(:first-child){margin-top:4px;border-top:1px solid var(--line)}
+.pentest-report-export-options button{display:flex;align-items:center;gap:9px;width:100%;min-height:32px;padding:0 9px;border:0;border-radius:6px;background:transparent;color:var(--text);font:inherit;font-size:13px;text-align:left;cursor:pointer;transition:background-color var(--control-feedback-dur) var(--ease-out)}
+.pentest-report-export-options button svg{color:var(--muted)}
+.pentest-report-export-options button:hover:not(:disabled){background:var(--panel2)}
+.pentest-report-export-options button:disabled{opacity:.55;cursor:wait}
+
+/* Scroll container and page frame */
+.pentest-document-reading{min-width:0;min-height:0;overflow-x:hidden;overflow-y:auto;scroll-behavior:smooth;scroll-padding-top:20px}
+.pentest-page{width:100%;max-width:1200px;margin:0 auto;padding:24px var(--pentest-gutter) 96px;animation:pentest-fade var(--duration-medium) var(--ease-smooth-out) both}
+.pentest-page--reader{max-width:840px}
+.pentest-breadcrumb{display:flex;align-items:center;gap:8px;margin:0 0 14px;color:var(--dim);font-size:13px}
+.pentest-breadcrumb>[aria-current]{color:var(--muted)}
+.pentest-report-back{text-decoration:none}
+.pentest-report-back:hover{text-decoration:underline;text-underline-offset:3px}
+
+/* Document header */
+.pentest-doc-header{padding-bottom:20px;border-bottom:1px solid var(--line)}
+.pentest-doc-header h1{margin:0;max-width:960px;color:var(--bright);font-size:clamp(21px,2vw,26px);font-weight:650;line-height:1.35;letter-spacing:-.01em;overflow-wrap:anywhere}
+.pentest-doc-header h1:focus{outline:none}
+.pentest-doc-meta{display:flex;flex-wrap:wrap;align-items:center;gap:6px 18px;margin-top:12px;color:var(--muted);font-size:13px;line-height:1.5}
+.pentest-doc-meta code{color:var(--text)}
+
+/* Notices: tinted fill only */
+.pentest-notices{display:flex;flex-direction:column;gap:8px;margin-top:20px}
+.pentest-notices:empty{display:none}
+.pentest-notice{--tone:var(--accent);display:flex;align-items:flex-start;gap:9px;margin:0;padding:10px 14px;border-radius:8px;background:color-mix(in srgb,var(--tone) 9%,var(--bg));color:var(--text);font-size:13px;line-height:1.6}
+.pentest-notice>svg{flex:none;margin-top:3px;color:var(--tone)}
+.pentest-notice--danger{--tone:var(--red)}
+.pentest-notice--warning{--tone:var(--amber)}
+.pentest-page--reader .pentest-notice{margin-bottom:16px}
+
+/* Review layout: content | sidebar */
+.pentest-document-layout{display:grid;grid-template-columns:minmax(0,1fr) 264px;gap:48px;margin-top:28px}
+.pentest-document-content{min-width:0}
+.pentest-document-section{min-width:0;margin-bottom:40px;scroll-margin-top:20px}
+.pentest-section-title,.pentest-reader-section h2{display:flex;align-items:center;gap:8px;margin:0 0 14px;padding-bottom:8px;border-bottom:1px solid var(--line);color:var(--bright);font-size:17px;font-weight:650;line-height:1.4;overflow-wrap:anywhere}
+.pentest-document-section h3,.pentest-reader-section h3{margin:22px 0 8px;color:var(--bright);font-size:14px;font-weight:600}
+.pentest-document-section p{margin:0 0 12px;color:var(--text);font-size:14px;line-height:1.75;white-space:pre-wrap;overflow-wrap:anywhere}
+.pentest-document-section p:last-child{margin-bottom:0}
+.pentest-empty{padding:16px;border-radius:8px;background:var(--panel2);color:var(--muted)!important;font-size:13.5px!important;white-space:normal!important}
+
+/* Severity counts */
+.pentest-severity-summary{display:grid;grid-auto-columns:minmax(0,1fr);grid-auto-flow:column;gap:1px;margin:0 0 14px;overflow:hidden;border:1px solid var(--line);border-radius:8px;background:var(--line)}
+.pentest-severity-summary>div{min-width:0;padding:10px 14px;background:var(--bg)}
+.pentest-severity-summary dt{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12.5px}
+.pentest-severity-summary dt>i{width:8px;height:8px;border-radius:50%;background:var(--sev)}
+.pentest-severity-summary dd{margin:4px 0 0;color:var(--bright);font-size:20px;font-weight:600;line-height:1.2;font-variant-numeric:tabular-nums}
+.pentest-severity-summary .is-zero dd{color:var(--dim)}
+.pentest-severity-summary .is-zero dt>i{opacity:.4}
+.pentest-severity-summary-total{background:var(--panel2)!important}
+
+/* Findings table */
+.pentest-finding-table{overflow:hidden;border:1px solid var(--line);border-radius:8px}
+.pentest-finding-row{display:grid;grid-template-columns:36px minmax(0,1fr) 84px;align-items:center;gap:12px;padding:12px 14px;color:var(--text);text-decoration:none;transition:background-color var(--control-feedback-dur) var(--ease-out)}
+.pentest-finding-table.has-evidence .pentest-finding-row{grid-template-columns:36px minmax(0,1fr) 84px 52px}
+.pentest-finding-row+.pentest-finding-row{border-top:1px solid var(--line)}
+.pentest-finding-row--head{padding-block:8px;background:var(--panel2);color:var(--muted);font-size:12px;font-weight:600}
+.pentest-document a.pentest-finding-row{color:var(--text)}
+a.pentest-finding-row:hover{background:var(--panel2)}
+a.pentest-finding-row:hover strong{color:var(--accent)}
+.pentest-finding-index,.pentest-finding-evidence{color:var(--muted);font-size:12.5px;font-variant-numeric:tabular-nums}
+.pentest-finding-main{display:flex;flex-direction:column;gap:4px;min-width:0}
+.pentest-finding-main strong{color:var(--bright);font-size:14px;font-weight:600;line-height:1.5;overflow-wrap:anywhere;transition:color var(--control-feedback-dur) var(--ease-out)}
+.pentest-finding-meta{display:flex;flex-wrap:wrap;align-items:center;gap:2px 12px;min-width:0;color:var(--muted);font-size:12px}
+.pentest-finding-meta code{max-width:100%;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.pentest-finding-status{color:var(--amber)}
+
+/* Evidence */
+.pentest-report-proof-list{display:flex;flex-direction:column;gap:12px}
+.pentest-report-proof{overflow:hidden;border:1px solid var(--line);border-radius:8px}
+.pentest-report-proof-heading{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--line);background:var(--panel2)}
+.pentest-report-proof-heading>strong{flex:1;min-width:0;color:var(--bright);font-size:13.5px;font-weight:600;line-height:1.45;overflow-wrap:anywhere}
+.pentest-report-proof-heading>button,.pentest-report-citations button{flex:none;display:inline-flex;align-items:center;gap:3px;height:24px;padding:0 8px 0 6px;border:1px solid var(--line2);border-radius:6px;background:var(--bg);color:var(--text);font:500 11.5px/1 var(--font-mono);cursor:pointer;transition:background-color var(--control-feedback-dur) var(--ease-out),border-color var(--control-feedback-dur) var(--ease-out)}
+.pentest-report-proof-heading>button svg,.pentest-report-citations button svg{color:var(--dim)}
+.pentest-report-proof-heading>button:hover,.pentest-report-citations button:hover{background:var(--panel3)}
+.pentest-report-proof-body{padding:12px 14px 14px}
+.pentest-document .pentest-report-proof-significance{color:var(--muted)}
+.pentest-report-proof-meta{display:grid;grid-template-columns:72px minmax(0,1fr);gap:6px 12px;margin:12px 0 0;font-size:13px;line-height:1.5}
+.pentest-report-proof-meta dt{color:var(--muted)}
+.pentest-report-proof-meta dd{min-width:0;margin:0;overflow-wrap:anywhere}
+.pentest-report-proof-meta dd button{display:inline-flex;align-items:center;gap:4px;padding:0;border:0;background:transparent;color:var(--accent);font:13px/1.5 var(--font-mono);text-align:left;overflow-wrap:anywhere;cursor:pointer}
+.pentest-report-proof-meta dd button:hover{text-decoration:underline;text-underline-offset:3px}
+.pentest-evidence-image{margin:14px 0 0;overflow:hidden;border:1px solid var(--line);border-radius:6px}
+.pentest-evidence-image img{display:block;width:100%;max-height:560px;object-fit:contain;background:var(--panel3)}
+.pentest-evidence-image figcaption{display:flex;align-items:center;gap:6px;padding:7px 10px;border-top:1px solid var(--line);color:var(--muted);font-size:12px;overflow-wrap:anywhere}
+.pentest-evidence-image--loading>div,.pentest-evidence-image--failed>div{display:flex;align-items:center;justify-content:center;gap:6px;height:180px;background:var(--panel2);color:var(--muted);font-size:13px}
+.pentest-skeleton{display:block;width:100%;height:100%;background:linear-gradient(100deg,transparent 30%,color-mix(in srgb,var(--bright) 5%,transparent) 50%,transparent 70%) var(--panel2);background-size:200% 100%;animation:pentest-shimmer 1.4s linear infinite}
+
+/* Steps, callouts, definitions, lists */
+.pentest-report-steps{margin:0;padding-left:24px;list-style:decimal}
+.pentest-report-steps li{padding-left:4px;margin-bottom:10px}
+.pentest-report-steps li:last-child{margin-bottom:0}
+.pentest-report-steps li::marker{color:var(--muted);font-variant-numeric:tabular-nums}
+.pentest-document .pentest-report-steps li>p{margin:0;font-size:14px;line-height:1.75;white-space:pre-wrap;overflow-wrap:anywhere}
+.pentest-report-inference{margin:16px 0;padding:12px 14px;border-radius:8px;background:var(--panel2)}
+.pentest-report-inference>strong{display:inline-flex;align-items:center;gap:6px;margin-bottom:6px;color:var(--bright);font-size:13px;font-weight:600}
+.pentest-report-inference>strong svg{color:var(--muted)}
+.pentest-document .pentest-report-inference p{margin:0 0 6px;font-size:13.5px;line-height:1.7;white-space:pre-wrap}
+.pentest-document .pentest-report-inference p:last-child{margin-bottom:0}
+.pentest-report-definition{display:grid;grid-template-columns:88px minmax(0,1fr);gap:10px 16px;margin:0;font-size:14px;line-height:1.7}
+.pentest-report-definition dt{color:var(--muted)}
+.pentest-report-definition dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}
+.pentest-report-bullets,.pentest-reader-evidence{margin:0;padding-left:20px;list-style:disc;font-size:14px;line-height:1.75}
+.pentest-report-bullets li,.pentest-reader-evidence li{margin-bottom:6px}
+.pentest-report-bullets li::marker,.pentest-reader-evidence li::marker{color:var(--dim)}
+.pentest-report-citations{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:14px}
+.pentest-report-citations>span{color:var(--muted);font-size:12.5px}
+.pentest-print-full{display:none}
+
+/* Prev / next */
+.pentest-pager{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:8px;padding-top:24px;border-top:1px solid var(--line)}
+.pentest-document a.pentest-pager-link{display:flex;flex-direction:column;gap:4px;min-width:0;padding:12px 16px;border:1px solid var(--line);border-radius:8px;color:var(--text);text-decoration:none;transition:border-color var(--control-feedback-dur) var(--ease-out),background-color var(--control-feedback-dur) var(--ease-out)}
+.pentest-pager-link--next{grid-column:2;align-items:flex-end;text-align:right}
+.pentest-pager-link>span{color:var(--muted);font-size:12px}
+.pentest-pager-link>strong{display:-webkit-box;overflow:hidden;color:var(--bright);font-size:13.5px;font-weight:600;line-height:1.5;-webkit-line-clamp:2;-webkit-box-orient:vertical;transition:color var(--control-feedback-dur) var(--ease-out)}
+.pentest-document a.pentest-pager-link:hover{border-color:var(--line2);background:var(--panel2)}
+.pentest-pager-link:hover>strong{color:var(--accent)}
+
+/* Sidebar */
+.pentest-report-aside{min-width:0;font-size:13px}
+.pentest-aside-section{padding:16px 0;border-top:1px solid var(--line)}
+.pentest-aside-section:first-child{padding-top:0;border-top:0}
+.pentest-aside-section h3{display:flex;align-items:center;gap:8px;margin:0 0 10px;color:var(--bright);font-size:13px;font-weight:600}
+.pentest-aside-section dl{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 12px;margin:0;line-height:1.5}
+.pentest-aside-section dt{color:var(--muted)}
+.pentest-aside-section dd{min-width:0;margin:0;overflow-wrap:anywhere}
+.pentest-aside-section dd.tone-positive{color:var(--green)}
+.pentest-aside-section dd.tone-warning{color:var(--amber)}
+.pentest-aside-section dd.tone-accent{color:var(--accent)}
+.pentest-aside-section>p{margin:0;color:var(--muted);line-height:1.6}
+.pentest-aside-section .pentest-report-citations{margin-top:10px}
+.pentest-aside-findings{display:flex;flex-direction:column;gap:1px;max-height:320px;margin:0 -8px;padding:0;overflow-y:auto;list-style:none}
+.pentest-document .pentest-aside-findings a{display:grid;grid-template-columns:8px minmax(0,1fr);align-items:start;gap:9px;padding:6px 8px;border-radius:6px;color:var(--muted);line-height:1.45;text-decoration:none;transition:color var(--control-feedback-dur) var(--ease-out),background-color var(--control-feedback-dur) var(--ease-out)}
+.pentest-aside-findings a>i{width:8px;height:8px;margin-top:5px;border-radius:50%;background:var(--sev)}
+.pentest-aside-findings a>span{display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical}
+.pentest-document .pentest-aside-findings a:hover{background:var(--ink-wash);color:var(--text)}
+.pentest-document .pentest-aside-findings a[aria-current="page"]{background:var(--ink-wash);color:var(--bright);font-weight:600}
+.pentest-aside-toc{position:sticky;top:0;background:var(--bg)}
+.pentest-aside-toc ul{display:flex;flex-direction:column;gap:1px;margin:0 -8px;padding:0;list-style:none}
+.pentest-document .pentest-aside-toc a{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:5px 8px;border-radius:6px;color:var(--muted);text-decoration:none;transition:color var(--control-feedback-dur) var(--ease-out),background-color var(--control-feedback-dur) var(--ease-out)}
+.pentest-document .pentest-aside-toc a:hover{color:var(--text)}
+.pentest-document .pentest-aside-toc a[aria-current]{background:var(--ink-wash);color:var(--bright);font-weight:600}
+
+/* Reader edition: a single reading column */
+.pentest-reader{color:var(--text);font-size:15px;line-height:1.8;overflow-wrap:anywhere}
.pentest-reader :is(pre,code){white-space:pre-wrap;overflow-wrap:anywhere}
-.pentest-reader pre{max-width:100%;overflow-x:auto}
.pentest-reader img{display:block;max-width:100%;height:auto}
-.pentest-reader-header{padding-bottom:28px;border-bottom:1px solid var(--line)}
-.pentest-reader-header h1{margin:0 0 12px;font-size:clamp(28px,2.5vw,38px);line-height:1.25;letter-spacing:-.03em;font-weight:700}
-.pentest-reader-header p{margin:8px 0 0;color:var(--muted)}
-.pentest-reader-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px 20px;margin:0;padding:22px 0;border-bottom:1px solid var(--line)}
-.pentest-reader-meta>div{min-width:0}
-.pentest-reader-meta>span{min-width:0;color:var(--muted);font-size:12px;line-height:1.55;overflow-wrap:anywhere}
-.pentest-reader-meta>span.pentest-report-severity{justify-self:start}
-.pentest-reader-meta dt,.pentest-reader-meta>div>span{display:block;color:var(--muted);font-size:11px;line-height:1.5}
-.pentest-reader-meta dd,.pentest-reader-meta>div>strong{display:block;margin:5px 0 0;font-size:13px;font-weight:650;line-height:1.5;overflow-wrap:anywhere}
-.pentest-reader-header .pentest-reader-meta{padding:14px 0 0;border:0}
-.pentest-reader-nav{display:flex;align-items:center;flex-wrap:wrap;gap:6px 18px;margin:0;padding:22px 0;border-bottom:1px solid var(--line)}
-.pentest-reader-nav a{color:var(--accent);font-size:12px;text-decoration:none}
-.pentest-reader-nav a:hover,.pentest-reader-nav a:focus-visible{text-decoration:underline;text-underline-offset:3px}
-.pentest-reader-section,.pentest-reader-finding{padding:32px 0;border-bottom:1px solid var(--line);scroll-margin-top:80px}
-.pentest-reader-section h2,.pentest-reader-finding h2{margin:0 0 16px;font-size:21px;line-height:1.4;letter-spacing:-.02em}
-.pentest-reader-section h3,.pentest-reader-finding h3{margin:24px 0 10px;font-size:15px;line-height:1.5}
-.pentest-reader-section p,.pentest-reader-finding p{margin:0 0 13px}
-.pentest-reader-section :is(ul,ol),.pentest-reader-finding :is(ul,ol){padding-left:22px;margin:12px 0 20px}
-.pentest-reader-section li,.pentest-reader-finding li{margin:6px 0}
-.pentest-reader .pentest-report-steps{padding-left:0}
-.pentest-reader .pentest-report-steps li>p{font-size:14px;line-height:1.7}
-.pentest-reader-table-wrap{max-width:100%;overflow-x:auto}
-.pentest-reader-table{width:100%;max-width:100%;border-collapse:collapse;table-layout:fixed;font-size:13px;line-height:1.55}
-.pentest-reader-table th,.pentest-reader-table td{padding:12px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top;overflow-wrap:anywhere}
-.pentest-reader-table th{color:var(--muted);font-size:11px;font-weight:650}
-.pentest-reader-table th:first-child,.pentest-reader-table td:first-child{padding-left:0}
-.pentest-reader-table th:last-child,.pentest-reader-table td:last-child{padding-right:0}
-.pentest-reader-table tbody tr:last-child td{border-bottom:0}
-.pentest-reader-table a{color:var(--accent);text-decoration:none}
-.pentest-reader-table a:hover,.pentest-reader-table a:focus-visible{text-decoration:underline;text-underline-offset:3px}
-.pentest-reader-cell-label{display:none}
-.pentest-reader-section .pentest-reader-evidence{max-width:100%;margin:18px 0;padding:16px 18px 16px 38px;border:1px solid var(--line);border-radius:8px;background:var(--panel2);overflow-wrap:anywhere}
-.pentest-reader-evidence p:last-child{margin-bottom:0}
-.pentest-reader-images{max-width:100%;margin:20px 0}
-.pentest-reader-images figure{margin:18px 0}
-.pentest-reader-images img{max-width:100%;max-height:620px;object-fit:contain;border:1px solid var(--line);border-radius:8px}
-.pentest-reader-images figcaption{margin-top:8px;color:var(--muted);font-size:12px;line-height:1.5}
-@container pentest-report-pane (max-width:1300px){
- .pentest-document:not(.pentest-document-loading){--pentest-report-gutter:32px;display:block;overflow:auto}
- .pentest-report-edition-bar{position:sticky;top:0}
- .pentest-document-reading,.pentest-document--reader .pentest-document-reading{height:auto;min-height:0;overflow:visible;padding:32px var(--pentest-report-gutter) 72px}
- .pentest-document--reader .pentest-document-reading{padding:0}
- .pentest-document-outline{top:80px}
- .pentest-document-section,.pentest-reader-section,.pentest-reader-finding{scroll-margin-top:84px}
- .pentest-report-aside{height:auto;min-height:0;overflow:visible;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 30px;border-left:0;border-top:1px solid var(--line);padding:24px var(--pentest-report-gutter) 48px}
-}
-@container pentest-report-pane (max-width:900px){
- .pentest-document-layout{grid-template-columns:1fr;gap:22px}
- .pentest-document-outline{position:static;display:flex;flex-direction:row;flex-wrap:nowrap;gap:2px;border-left:0;border-bottom:1px solid var(--line);padding-bottom:7px;max-height:none;overflow-x:auto;overflow-y:hidden;scrollbar-width:thin}
- .pentest-document-outline>span{flex:none;width:auto;padding:7px 12px 7px 0;white-space:nowrap}
- .pentest-document-outline button,.pentest-document-outline a{flex:none;width:auto;border-left:0;border-bottom:2px solid transparent;margin:0;padding:7px 9px;white-space:nowrap}
- .pentest-document-outline button:hover,.pentest-document-outline button.active,.pentest-document-outline a:hover,.pentest-document-outline a:focus-visible{border-bottom-color:var(--accent)}
- .pentest-document-content{max-width:840px}
- .pentest-reader{padding:34px 32px 80px}
- .pentest-document-section,.pentest-reader-section,.pentest-reader-finding{scroll-margin-top:120px}
-}
-@container pentest-report-pane (max-width:760px){
- .pentest-document:not(.pentest-document-loading){--pentest-report-gutter:24px}
- .pentest-document-head{flex-direction:column}
- .pentest-document-head h1{font-size:25px}
- .pentest-document-meta{grid-template-columns:repeat(2,minmax(0,1fr))}
- .pentest-document-meta div:nth-child(2){border-right:0}
- .pentest-document-meta div:nth-child(3){grid-column:1/-1;border-top:1px solid var(--line);padding:12px 0 0;margin-top:12px}
- .pentest-report-aside{display:block}
- .pentest-reader{padding:30px var(--pentest-report-gutter) 72px}
+.pentest-reader>.pentest-doc-header{margin-bottom:32px}
+.pentest-reader-section{margin-bottom:40px;scroll-margin-top:20px}
+.pentest-reader-section h2{font-size:19px}
+.pentest-reader-section p{margin:0 0 12px;white-space:pre-wrap}
+.pentest-reader-section p:last-child{margin-bottom:0}
+.pentest-reader-lead p{font-size:15.5px;line-height:1.85}
+.pentest-reader .pentest-report-steps li>p{font-size:15px;line-height:1.8}
+.pentest-reader .pentest-reader-evidence{font-size:15px;line-height:1.8}
+.pentest-reader-images h3{margin-top:20px}
+.pentest-reader-meta{display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;margin-bottom:24px;color:var(--muted);font-size:13px}
+
+@container pentest-report-pane (max-width:1000px){
+ .pentest-document-layout{grid-template-columns:minmax(0,1fr);gap:8px}
+ .pentest-report-aside{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:0 32px;padding-top:24px;border-top:1px solid var(--line)}
+ .pentest-report-aside>.pentest-aside-section{padding-top:0;border-top:0}
+ .pentest-aside-toc{display:none}
}
-@container pentest-report-pane (max-width:620px){
- .pentest-document:not(.pentest-document-loading){--pentest-report-gutter:18px}
- .pentest-report-edition-bar{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-rows:auto auto;gap:8px 10px;min-height:0;padding:9px var(--pentest-report-gutter)}
- .pentest-report-edition-primary{display:contents}
- .pentest-report-edition-switch{grid-column:1/-1;grid-row:1;justify-self:start}
- .pentest-report-version-control{grid-column:1;grid-row:2;justify-self:start}
- .pentest-report-version-control select{max-width:min(180px,45vw)}
- .pentest-report-export-menu{grid-column:2;grid-row:2;justify-self:end}
- .pentest-report-edition-hint{grid-column:1/-1;color:var(--muted)}
- .pentest-document-section,.pentest-reader-section,.pentest-reader-finding{scroll-margin-top:160px}
- .pentest-document-head h1{font-size:23px}
- .pentest-document-meta{grid-template-columns:1fr}
- .pentest-document-meta div,.pentest-document-meta div:nth-child(2),.pentest-document-meta div:nth-child(3){grid-column:1;border-right:0;border-top:0;border-bottom:1px solid var(--line);margin:0;padding:10px 0}
- .pentest-document-meta div:last-child{border-bottom:0}
- .pentest-report-definition{grid-template-columns:1fr;gap:3px}
- .pentest-report-definition dd{margin-bottom:9px}
- .pentest-reader{padding:24px var(--pentest-report-gutter) 56px}
- .pentest-reader-header h1{font-size:26px}
- .pentest-reader-meta{grid-template-columns:1fr;gap:12px}
- .pentest-reader-table,.pentest-reader-table tbody,.pentest-reader-table tr,.pentest-reader-table td{display:block;width:100%}
- .pentest-reader-table thead{display:none}
- .pentest-reader-table tr{padding:12px 0;border-bottom:1px solid var(--line)}
- .pentest-reader-table td{padding:3px 0;border:0}
- .pentest-reader-cell-label{display:block;color:var(--muted);font-size:11px;font-weight:650}
- .pentest-reader-table tbody tr:last-child{border-bottom:0}
- .pentest-reader-section .pentest-reader-evidence{padding:13px 14px 13px 34px}
+@container pentest-report-pane (max-width:640px){
+ .pentest-document{--pentest-gutter:16px}
+ .pentest-report-edition-bar{flex-wrap:wrap;gap:8px;padding-block:8px}
+ .pentest-report-edition-primary{flex:1 1 100%}
+ .pentest-report-version-control{flex:1}
+ .pentest-report-version-control select{width:100%;max-width:none}
+ .pentest-report-edition-switch button{padding:0 9px}
+ .pentest-page{padding-top:16px}
+ .pentest-severity-summary>div{padding:8px 10px}
+ .pentest-severity-summary dd{font-size:17px}
+ .pentest-finding-row--head{display:none!important}
+ .pentest-finding-row,.pentest-finding-table.has-evidence .pentest-finding-row{grid-template-columns:minmax(0,1fr) auto}
+ .pentest-finding-index,.pentest-finding-evidence{display:none}
+ .pentest-finding-table>.pentest-finding-row--head+.pentest-finding-row{border-top:0}
+ .pentest-pager{grid-template-columns:1fr}
+ .pentest-pager-link--next{grid-column:1}
+ .pentest-report-definition{grid-template-columns:1fr;gap:2px}
+ .pentest-report-definition dd{margin-bottom:8px}
}
+
/* Print / Save-as-PDF (#26): unlock the full ancestor chain that stays
height-clamped with overflow:hidden on screen (html→body→workspace-frame→
- shell→convo), hide chrome, force readable light-ink contrast on evidence. */
+ shell→convo), hide chrome and force light ink. */
@media print{
@page{margin:12mm}
/* Print rendering starts mount animations at their first frame (opacity:0).
@@ -386,28 +356,19 @@
.pentest-workbench .main{display:block!important;height:auto!important;min-height:0!important;overflow:visible!important;opacity:1!important}
/* The screen's inline-size query container cannot fragment across pages. */
.pentest-workbench .convo,.pentest-workbench .convo-mainpane{container-type:normal!important;contain:none!important}
- .pentest-workbench .pentest-document{--text:#111;--bright:#111;--muted:#444;--dim:#555;--accent:#244480;--blue:#244480;--green:#17642b;--panel:#fff;--panel2:#fff;--line:#ccc;--line2:#aaa}
+ .pentest-workbench .pentest-document{--bg:#fff;--text:#111;--bright:#111;--muted:#444;--dim:#555;--accent:#244480;--blue:#244480;--green:#17642b;--panel:#fff;--panel2:#f6f6f6;--panel3:#f2f2f2;--line:#ccc;--line2:#aaa;--ink-wash:transparent}
html,body{height:auto!important;max-height:none!important;overflow:visible!important;background:#fff!important;color:#111!important}
.workspace-frame,.workspace-frame-content,.workspace-frame-content>.shell,.shell,.shell.pentest-workbench,.rail-body{display:block!important;width:100%!important;height:auto!important;max-height:none!important;min-height:0!important;overflow:visible!important;background:#fff!important}
.workspace-nav,.workspace-activity-rail,.workspace-rail-control{display:none!important}
- .pentest-workbench .convo-top,.pentest-workbench .thread-rail,.pentest-workbench .rail,.pentest-workbench .pentest-report-aside,.pentest-workbench .convo-runtime-peer,.pentest-workbench .inspector-shell,.pentest-workbench .composer2,.pentest-workbench .composer{display:none!important}
+ .pentest-workbench .convo-top,.pentest-workbench .thread-rail,.pentest-workbench .rail,.pentest-workbench .convo-runtime-peer,.pentest-workbench .inspector-shell,.pentest-workbench .composer2,.pentest-workbench .composer{display:none!important}
.pentest-workbench .convo,.pentest-workbench .convo-body,.pentest-workbench .convo-mainpane,.pentest-workbench .convo-scroll{display:block!important;width:100%!important;max-width:none!important;height:auto!important;max-height:none!important;min-height:0!important;overflow:visible!important;position:static!important}
.pentest-workbench .pentest-document,.pentest-workbench .pentest-document-reading{display:block!important;height:auto!important;max-height:none!important;overflow:visible!important;background:#fff!important;color:#111!important;padding:0!important}
- .pentest-workbench .pentest-report-edition-bar,.pentest-workbench .pentest-reader-nav{display:none!important}
- .pentest-workbench .pentest-reader{max-width:none!important;padding:0!important;color:#111!important}
- .pentest-workbench .pentest-document-outline{display:none!important}
+ .pentest-workbench :is(.pentest-report-edition-bar,.pentest-document-outline,.pentest-report-aside,.pentest-no-print){display:none!important}
+ .pentest-workbench .pentest-page{max-width:none!important;padding:0!important}
+ .pentest-workbench .pentest-document-layout,.pentest-workbench .pentest-document-content{display:block!important;margin-top:12px}
.pentest-workbench .pentest-print-full{display:block!important}
.pentest-workbench .pentest-reader-print-entry{break-before:page;page-break-before:always}
- .pentest-workbench .pentest-document-layout{display:block!important}
- .pentest-workbench .pentest-document-content{max-width:none!important}
- .pentest-workbench .pentest-document-section{break-inside:auto;page-break-inside:auto}
- .pentest-workbench .pentest-document-section-label,.pentest-workbench h2,.pentest-workbench h3{break-after:avoid;page-break-after:avoid}
- .pentest-workbench .pentest-report-steps li,.pentest-workbench .pentest-report-bullets li{break-inside:avoid;page-break-inside:avoid}
- .pentest-workbench .pentest-document-section p,.pentest-workbench .pentest-document-section h1,.pentest-workbench .pentest-document-section h2,.pentest-workbench .pentest-document-section h3,.pentest-workbench .pentest-document-section li,.pentest-workbench .pentest-document-section dd,.pentest-workbench .pentest-document-section strong,.pentest-workbench .pentest-document-head h1,.pentest-workbench .pentest-report-version{color:#111!important}
- .pentest-workbench .pentest-report-proof-body,.pentest-workbench .pentest-report-inference,.pentest-workbench .pentest-report-proof details,.pentest-workbench .pentest-report-finding-row{background:#fff!important;color:#111!important;border-color:#ccc!important}
- .pentest-workbench .pentest-report-proof-body>p,.pentest-workbench .pentest-report-proof-body>p.pentest-report-proof-significance,.pentest-workbench .pentest-report-finding-row em,.pentest-workbench .pentest-report-proof pre{-webkit-line-clamp:unset!important;display:block!important;max-height:none!important;overflow:visible!important;color:#111!important}
- .pentest-workbench .pentest-report-proof-body>div>span,.pentest-workbench .pentest-report-section-lead,.pentest-workbench .pentest-document-section-label,.pentest-workbench .pentest-report-definition dt{color:#333!important}
- .pentest-workbench .pentest-report-proof,.pentest-workbench .pentest-report-evidence-group,.pentest-workbench .pentest-report-reproduction-group,.pentest-workbench .pentest-report-impact-row,.pentest-workbench .pentest-report-advice-row{break-inside:avoid;page-break-inside:avoid}
- .pentest-workbench .pentest-report-citations button{color:#111!important;border-color:#aaa!important;background:#fff!important}
- .pentest-workbench .pentest-report-primary-action,.pentest-workbench .pentest-report-secondary-action{display:none!important}
+ .pentest-workbench .pentest-document-section{margin-bottom:24px;break-inside:auto;page-break-inside:auto}
+ .pentest-workbench :is(.pentest-section-title,.pentest-doc-kicker,h2,h3){break-after:avoid;page-break-after:avoid}
+ .pentest-workbench :is(.pentest-report-steps li,.pentest-report-bullets li,.pentest-report-proof,.pentest-finding-row,.pentest-severity-summary){break-inside:avoid;page-break-inside:avoid}
}
diff --git a/apps/web/ui/app/styles/settings-agents.css b/apps/web/ui/app/styles/settings-agents.css
deleted file mode 100644
index e4e2bb3..0000000
--- a/apps/web/ui/app/styles/settings-agents.css
+++ /dev/null
@@ -1,681 +0,0 @@
-/* Agents registry: engines are stable top-level products; credentials live inside them. */
-.agent-registry {
- width: min(1180px, 100%);
- max-width: 100%;
- min-width: 0;
- min-height: 640px;
- margin: 0 auto;
- padding: 18px 24px 56px;
- color: var(--text);
-}
-.agent-registry h2,
-.agent-registry h3,
-.agent-registry h4,
-.agent-registry p,
-.agent-registry dl,
-.agent-registry dd { margin: 0; }
-.agent-registry button,
-.agent-registry input,
-.agent-registry select,
-.agent-registry textarea { font: inherit; }
-.agent-registry textarea { resize: none; }
-.agent-registry-toolbar,
-.agent-registry-toolbar > div,
-.agent-registry-default,
-.agent-registry-default > div,
-.agent-registry-default-controls,
-.agent-registry-section-head,
-.agent-registry-detail-head,
-.agent-registry-detail-actions,
-.agent-registry-detail-identity,
-.agent-registry-detail-identity > div > div,
-.agent-registry-editor-head,
-.agent-registry-panel-head,
-.agent-registry-editor-actions,
-.agent-registry-inline-form,
-.agent-registry-runtime-note,
-.agent-registry-system-login > div:first-child { display: flex; align-items: center; }
-.agent-registry-toolbar { min-height: 38px; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
-.agent-registry-toolbar > p { color: var(--muted); font-size: 12.5px; }
-.agent-registry-toolbar > div { gap: 10px; }
-.agent-registry-timestamp { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font: 11.5px/1 var(--font-mono); }
-.agent-registry-button,
-.agent-registry-manage,
-.agent-registry-back,
-.agent-registry-icon-button,
-.agent-registry-credential-list > div:first-child button {
- min-height: 36px;
- border: 1px solid var(--line2);
- border-radius: 8px;
- background: var(--panel);
- color: var(--text);
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 7px;
- padding: 0 12px;
- font-size: 12.5px;
- font-weight: 620;
- cursor: pointer;
-}
-.agent-registry-button:hover,
-.agent-registry-manage:hover,
-.agent-registry-icon-button:hover { background: var(--panel3); color: var(--bright); }
-.agent-registry-button:disabled { opacity: .52; cursor: wait; }
-.agent-registry-button.is-primary { border-color: color-mix(in srgb, var(--blue) 70%, var(--line)); background: var(--blue); color: #fff; }
-.agent-registry-button.is-danger { margin-left: auto; color: var(--red); }
-.agent-registry-feedback {
- min-height: 40px;
- margin-bottom: 14px;
- padding: 8px 10px;
- display: flex;
- align-items: center;
- gap: 8px;
- border: 1px solid currentColor;
- border-radius: 8px;
- font-size: 12.5px;
-}
-.agent-registry-feedback.is-ok { color: var(--green); background: color-mix(in srgb, var(--green) 7%, var(--panel)); }
-.agent-registry-feedback.is-bad { color: var(--red); background: color-mix(in srgb, var(--red) 7%, var(--panel)); }
-.agent-registry-feedback > span { flex: 1; }
-.agent-registry-feedback button { border: 0; padding: 5px; color: inherit; background: transparent; cursor: pointer; }
-.agent-registry-last-test {
- width: 100%;
- height: auto;
- min-height: 0;
- margin-top: 16px;
- padding: 10px 2px 4px;
- display: flex;
- align-items: flex-start;
- justify-content: flex-start;
- gap: 10px;
- border: 0;
- border-top: 1px solid var(--line);
- border-radius: 0;
- color: var(--text);
- background: transparent;
- text-align: left;
- white-space: normal;
- cursor: pointer;
-}
-.agent-registry-last-test:hover { background: color-mix(in srgb, var(--panel3) 55%, transparent); }
-.agent-registry-last-test-copy {
- min-width: 0;
- flex: 1;
- display: grid;
- gap: 4px;
- align-content: start;
-}
-.agent-registry-last-test-copy strong {
- color: var(--bright);
- font-size: 12px;
- font-weight: 620;
- line-height: 1.35;
- white-space: normal;
-}
-.agent-registry-last-test-copy small {
- color: var(--muted);
- font-size: 11px;
- line-height: 1.45;
- overflow-wrap: anywhere;
- white-space: normal;
-}
-.agent-registry-last-test > svg:first-child {
- flex: none;
- align-self: flex-start;
- width: 15px;
- height: 15px;
- margin-top: 1px;
-}
-.agent-registry-last-test > svg:last-child {
- flex: none;
- align-self: flex-start;
- width: 14px;
- height: 14px;
- margin-top: 2px;
- color: var(--dim);
-}
-.agent-registry-last-test.is-ok > svg:first-child { color: var(--green); }
-.agent-registry-last-test.is-bad > svg:first-child { color: var(--red); }
-.agent-registry-last-test.is-ok,
-.agent-registry-last-test.is-bad {
- color: var(--text);
- border-top-color: var(--line);
- background: transparent;
-}
-.agent-registry-last-test.is-idle {
- margin-top: 16px;
- padding: 10px 2px 0;
- display: block;
- border: 0;
- border-top: 1px solid var(--line);
- color: var(--muted);
- background: transparent;
- cursor: default;
- font-size: 11.5px;
- line-height: 1.5;
-}
-.agent-registry-default {
- min-height: 82px;
- justify-content: space-between;
- gap: 24px;
- padding: 16px 18px;
- border: 1px solid var(--line);
- background: var(--panel2);
-}
-.agent-registry-default > div:first-child { gap: 11px; }
-.agent-registry-section-icon {
- width: 32px;
- height: 32px;
- display: grid;
- place-items: center;
- border: 1px solid color-mix(in srgb, var(--amber) 45%, var(--line));
- border-radius: 8px;
- color: var(--amber);
- background: var(--panel);
-}
-.agent-registry-default h2,
-.agent-registry-section-head h2 { color: var(--bright); font-size: 14px; line-height: 1.3; }
-.agent-registry-default p,
-.agent-registry-section-head p,
-.agent-registry-panel-head p,
-.agent-registry-editor-head p { margin-top: 4px; color: var(--muted); font-size: 11.5px; line-height: 1.5; }
-.agent-registry-default-controls { justify-content: flex-end; gap: 9px; min-width: min(580px, 55%); }
-.agent-registry-default-controls .provider-default-model-picker { flex: 1; min-width: 280px; }
-.agent-registry-saved { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; color: var(--muted); font-size: 11px; }
-.agent-registry-saved.is-active { color: var(--green); }
-.agent-registry-icon-button { width: 36px; padding: 0; }
-.agent-registry-overview { margin-top: 24px; }
-.agent-registry-section-head { justify-content: space-between; gap: 24px; margin-bottom: 12px; }
-.agent-registry-section-head > div:first-child { max-width: 620px; }
-.agent-registry-section-head dl { display: flex; align-items: stretch; border: 1px solid var(--line); background: var(--panel); }
-.agent-registry-section-head dl > div { min-width: 76px; padding: 8px 11px; text-align: center; }
-.agent-registry-section-head dl > div + div { border-left: 1px solid var(--line); }
-.agent-registry-section-head dt { color: var(--muted); font-size: 10px; }
-.agent-registry-section-head dd { margin-top: 2px; color: var(--bright); font: 650 14px/1.2 var(--font-mono); }
-.agent-registry-list { min-height: 560px; border: 1px solid var(--line); background: var(--panel); }
-.agent-registry-list-head,
-.agent-registry-row {
- display: grid;
- grid-template-columns: minmax(210px, 1.2fr) minmax(150px, .9fr) minmax(155px, .85fr) 90px 104px 44px 70px;
- align-items: center;
- column-gap: 10px;
-}
-.agent-registry-list-head { min-height: 34px; padding: 0 16px; border-bottom: 1px solid var(--line); color: var(--muted); background: var(--panel2); font-size: 10.5px; font-weight: 650; letter-spacing: .03em; }
-.agent-registry-row { min-height: 78px; padding: 12px 16px; }
-.agent-registry-row + .agent-registry-row { border-top: 1px solid var(--line); }
-.agent-registry-row:hover { background: color-mix(in srgb, var(--panel2) 74%, transparent); }
-.agent-registry-row.is-locked { position: relative; }
-.agent-registry-row.is-locked:focus-visible { outline: 2px solid color-mix(in srgb, var(--blue) 72%, transparent); outline-offset: -2px; }
-.agent-registry-identity { min-width: 0; display: flex; align-items: center; gap: 11px; }
-.agent-registry-logo { width: 40px; height: 40px; flex: none; display: grid; place-items: center; border: 1px solid var(--line2); border-radius: 9px; background: var(--panel2); }
-.agent-registry-logo.is-large { width: 50px; height: 50px; border-radius: 11px; }
-.agent-registry-identity h3 { margin-bottom: 5px; color: var(--bright); font-size: 13.5px; line-height: 1.2; }
-.agent-registry-status { min-width: 0; display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 10.5px; line-height: 1.35; }
-.agent-registry-status > span { width: 6px; height: 6px; flex: none; border-radius: 50%; background: currentColor; }
-.agent-registry-status.is-ok { color: var(--green); }
-.agent-registry-status.is-warn { color: var(--amber); }
-.agent-registry-status.is-bad { color: var(--red); }
-.agent-registry-status.is-disabled { color: var(--muted); }
-.agent-registry-runtime { min-width: 0; display: grid; gap: 4px; }
-.agent-registry-runtime strong { color: var(--text); font: 11.5px/1.3 var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.agent-registry-runtime span { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
-.agent-registry-version { min-width: 0; display: grid; gap: 4px; }
-.agent-registry-version strong { color: var(--text); font: 11.5px/1.3 var(--font-mono); overflow-wrap: anywhere; }
-.agent-registry-version span { color: var(--muted); font-size: 10px; line-height: 1.3; overflow-wrap: anywhere; }
-.agent-registry-version span.is-current { color: var(--green); }
-.agent-registry-version span.is-update_available { color: var(--amber); }
-.agent-registry-credential-count { display: grid; gap: 3px; }
-.agent-registry-credential-count strong { color: var(--bright); font: 650 16px/1 var(--font-mono); }
-.agent-registry-credential-count strong span { color: var(--muted); font-size: 11px; font-weight: 500; }
-.agent-registry-credential-count small,
-.agent-registry-resource-counts { color: var(--muted); font-size: 10px; }
-.agent-registry-resource-counts { display: grid; gap: 4px; }
-.agent-registry-resource-counts strong { color: var(--text); font-family: var(--font-mono); }
-.agent-registry-row-actions { min-width: 0; grid-column: 6 / 8; display: grid; grid-template-columns: 44px 70px; align-items: center; gap: 10px; }
-.agent-registry-manage { min-height: 32px; padding: 0 9px; }
-.agent-registry-manage[aria-disabled="true"] { position: relative; opacity: .52; cursor: not-allowed; }
-.agent-registry-row.is-locked::after,
-.agent-registry-manage[data-disabled-reason]::after {
- content: attr(data-disabled-reason);
- position: absolute;
- right: 0;
- bottom: calc(100% + 8px);
- z-index: 30;
- width: min(430px, calc(100vw - 48px));
- padding: 9px 11px;
- border: 1px solid var(--line2);
- border-radius: 8px;
- background: var(--panel3);
- color: var(--text);
- box-shadow: 0 10px 28px rgb(0 0 0 / 28%);
- font-size: 11.5px;
- font-weight: 500;
- line-height: 1.55;
- text-align: left;
- white-space: normal;
- opacity: 0;
- pointer-events: none;
- transform: translateY(3px);
- transition: opacity var(--control-feedback-dur) var(--ease-out), transform var(--control-press-dur) var(--ease-out);
-}
-.agent-registry-row.is-locked:hover:not(:has(.agent-registry-row-actions:hover))::after,
-.agent-registry-row.is-locked:focus-visible::after,
-.agent-registry-manage[data-disabled-reason]:hover::after,
-.agent-registry-manage[data-disabled-reason]:focus-visible::after { opacity: 1; transform: translateY(0); }
-.agent-registry-loading { height: 525px; display: flex; align-items: center; justify-content: center; gap: 9px; color: var(--muted); font-size: 12.5px; }
-.agent-registry-back { min-height: 32px; margin-bottom: 12px; padding: 0 8px; border-color: transparent; background: transparent; color: var(--muted); }
-.agent-registry-back svg { transform: rotate(180deg); }
-.agent-registry-detail {
- min-width: 0;
- max-width: 100%;
-}
-.agent-registry-detail-head { justify-content: space-between; gap: 20px; padding-bottom: 18px; }
-.agent-registry-detail-identity { gap: 13px; }
-.agent-registry-detail-identity > div { display: grid; gap: 7px; }
-.agent-registry-detail-identity > div > div { gap: 9px; }
-.agent-registry-detail-identity h2 { color: var(--bright); font-size: 20px; }
-.agent-registry-detail-identity code,
-.agent-registry-panel-head code { padding: 3px 6px; border: 1px solid var(--line); border-radius: 5px; color: var(--muted); background: var(--panel2); font-size: 10.5px; }
-.agent-registry-detail-actions { gap: 10px; }
-.agent-registry-detail-actions > span { margin-left: 6px; color: var(--muted); font-size: 11px; }
-.agent-registry-detail-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--line); background: var(--panel2); }
-.agent-registry-detail-stats > div { min-width: 0; padding: 11px 14px; }
-.agent-registry-detail-stats > div + div { border-left: 1px solid var(--line); }
-.agent-registry-detail-stats dt { color: var(--muted); font-size: 10px; }
-.agent-registry-detail-stats dd { margin-top: 4px; color: var(--bright); font-size: 12px; font-weight: 620; overflow-wrap: anywhere; }
-.agent-registry-tabs {
- margin-top: 18px;
- min-width: 0;
-}
-.agent-registry-tabs [aria-label="Scroll tabs left"],
-.agent-registry-tabs [aria-label="Scroll tabs right"] {
- display: none !important;
-}
-.agent-registry-credential-layout { min-height: 480px; display: grid; grid-template-columns: 240px minmax(0, 1fr); border: 1px solid var(--line); border-top: 0; background: var(--panel); }
-.agent-registry-credential-list { border-right: 1px solid var(--line); background: var(--panel2); }
-.agent-registry-credential-list > div:first-child { min-height: 52px; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
-.agent-registry-credential-list h3 { color: var(--bright); font-size: 12px; }
-.agent-registry-credential-list > div:first-child button { min-height: 30px; padding: 0 8px; font-size: 11px; }
-.agent-registry-credential-list > button {
- width: 100%;
- min-height: 62px;
- padding: 10px 12px;
- display: grid;
- grid-template-columns: 8px minmax(0, 1fr) 16px;
- align-items: center;
- gap: 9px;
- justify-content: stretch;
- border: 0;
- border-radius: 0;
- border-bottom: 1px solid var(--line);
- color: var(--text);
- background: transparent;
- box-shadow: none;
- text-align: left;
- cursor: pointer;
-}
-.agent-registry-credential-list > button:hover,
-.agent-registry-credential-list > button[data-hovered="true"] {
- background: var(--panel3);
- box-shadow: none;
-}
-.agent-registry-credential-list > button.is-selected,
-.agent-registry-credential-list > button[data-selected="true"] {
- background: var(--panel);
- box-shadow: none;
-}
-.agent-registry-credential-list > button.is-selected strong,
-.agent-registry-credential-list > button[data-selected="true"] strong { font-weight: 700; }
-.agent-registry-credential-list > button > span:nth-child(2) { min-width: 0; display: grid; gap: 4px; }
-.agent-registry-credential-list strong { overflow: hidden; color: var(--bright); font-size: 11.5px; font-weight: 620; text-overflow: ellipsis; white-space: nowrap; }
-.agent-registry-credential-list small { color: var(--muted); font-size: 10px; }
-.agent-registry-credential-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--amber); }
-.agent-registry-credential-dot.is-ready { background: var(--green); }
-.agent-registry-credential-dot.is-bad { background: var(--red); }
-.agent-registry-empty-small { padding: 34px 18px; display: grid; justify-items: center; gap: 5px; color: var(--muted); text-align: center; }
-.agent-registry-empty-small p { color: var(--text); font-size: 11.5px; font-weight: 620; }
-.agent-registry-empty-small span { font-size: 10.5px; line-height: 1.5; }
-.agent-registry-editor { min-width: 0; padding: 22px; }
-.agent-registry-editor-head { justify-content: space-between; gap: 18px; padding-bottom: 18px; border-bottom: 1px solid var(--line); }
-.agent-registry-editor-head h3,
-.agent-registry-panel-head h3 { color: var(--bright); font-size: 14px; }
-.agent-registry-form-grid { margin-top: 18px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px 18px; }
-.agent-registry-form-grid label,
-.agent-registry-inline-form label,
-.agent-registry-dialog-form label { min-width: 0; display: grid; align-content: start; gap: 7px; color: var(--muted); font-size: 11px; font-weight: 600; }
-.agent-registry-form-grid label.is-wide { grid-column: 1 / -1; }
-.agent-registry-endpoint-scope-note {
- grid-column: 1 / -1;
- margin: 0;
- padding: 10px 12px;
- display: flex;
- align-items: flex-start;
- gap: 8px;
- border: 1px solid color-mix(in srgb, var(--blue) 28%, var(--line));
- border-radius: 8px;
- color: var(--muted);
- background: color-mix(in srgb, var(--blue) 7%, var(--panel2));
- font-size: 11px;
- font-weight: 500;
- line-height: 1.6;
-}
-.agent-registry-endpoint-scope-note svg { flex: 0 0 auto; margin-top: 2px; color: var(--blue); }
-.agent-registry-endpoint-scope-note strong { margin-right: 6px; color: var(--text); }
-.agent-registry-form-grid .agent-registry-models-field {
- grid-column: 1 / -1;
- display: grid;
- align-content: start;
- gap: 7px;
- color: var(--muted);
- font-size: 11px;
- font-weight: 600;
-}
-.agent-registry-models-field-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
-}
-.agent-registry-models-field-head .agent-registry-button {
- min-height: 28px;
- padding: 0 9px;
- font-size: 11.5px;
-}
-.agent-registry-models-disclosure {
- border: 0;
- border-top: 1px solid var(--line);
- border-bottom: 1px solid var(--line);
- border-radius: 0;
- background: transparent;
- font-weight: 500;
-}
-.agent-registry-models-disclosure summary {
- min-height: 36px;
- padding: 8px 2px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- color: var(--text);
- cursor: pointer;
- list-style: none;
-}
-.agent-registry-models-disclosure summary::-webkit-details-marker { display: none; }
-.agent-registry-models-disclosure summary:hover { background: color-mix(in srgb, var(--panel3) 45%, transparent); }
-.agent-registry-models-disclosure summary:focus-visible {
- outline: 2px solid var(--blue);
- outline-offset: 2px;
- box-shadow: none;
-}
-.agent-registry-models-disclosure summary svg { transition: transform var(--acc-chevron) var(--ease-out); }
-.agent-registry-models-disclosure[open] summary svg { transform: rotate(180deg); }
-.agent-registry-models-disclosure ul {
- max-height: 190px;
- margin: 0;
- padding: 2px 0 6px;
- overflow-y: auto;
- border-top: 1px solid var(--line);
- list-style: none;
-}
-.agent-registry-models-disclosure li {
- min-height: 28px;
- padding: 5px 2px;
- display: flex;
- align-items: center;
-}
-.agent-registry-models-disclosure li + li { border-top: 1px solid color-mix(in srgb, var(--line) 55%, transparent); }
-.agent-registry-models-disclosure code { color: var(--text); font: 11.5px/1.45 var(--font-mono); overflow-wrap: anywhere; }
-.agent-registry-models-disclosure > p { margin: 0; padding: 8px 2px; border-top: 1px solid var(--line); color: var(--muted); font-size: 10.5px; }
-.agent-registry-models-field-status {
- min-height: 16px;
- display: flex;
- align-items: center;
- gap: 5px;
- color: var(--muted);
- font-size: 10.5px;
- font-weight: 500;
- line-height: 1.45;
-}
-.agent-registry-models-field-status.is-loading { color: var(--blue); }
-.agent-registry-models-field-status.is-ok { color: var(--green); }
-.agent-registry-models-field-status.is-bad { color: var(--red); }
-.agent-registry-models-field-status.is-stale,
-.agent-registry-models-field-status.is-failed { color: var(--amber); }
-.agent-registry-form-grid input,
-.agent-registry-form-grid select,
-.agent-registry-form-grid textarea,
-.agent-registry-inline-form input,
-.agent-registry-editor-actions select,
-.agent-registry-dialog-form input,
-.agent-registry-dialog-form select,
-.agent-registry-dialog-form textarea {
- width: 100%;
- min-width: 0;
- min-height: 38px;
- padding: 8px 10px;
- border: 1px solid var(--line2);
- border-radius: 7px;
- outline: 0;
- color: var(--text);
- background: var(--panel2);
- font-size: 12px;
-}
-.agent-registry-form-grid textarea,
-.agent-registry-dialog-form textarea { min-height: 86px; line-height: 1.5; font-family: var(--font-mono); }
-.agent-registry-form-grid input:focus,
-.agent-registry-form-grid select:focus,
-.agent-registry-form-grid textarea:focus,
-.agent-registry-inline-form input:focus,
-.agent-registry-editor-actions select:focus,
-.agent-registry-dialog-form input:focus,
-.agent-registry-dialog-form select:focus,
-.agent-registry-dialog-form textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 2px color-mix(in srgb, var(--blue) 16%, transparent); }
-.agent-registry-secret > div { display: flex; }
-.agent-registry-secret > div input { border-radius: 7px 0 0 7px; }
-.agent-registry-secret > div button { width: 38px; flex: none; border: 1px solid var(--line2); border-left: 0; border-radius: 0 7px 7px 0; color: var(--muted); background: var(--panel2); cursor: pointer; }
-@media (prefers-reduced-motion: reduce) {
- .agent-registry-models-disclosure summary svg { transition-duration: 0ms; }
-}
-.agent-registry-system-login { margin-top: 18px; padding: 15px; border: 1px solid color-mix(in srgb, var(--green) 46%, var(--line)); background: color-mix(in srgb, var(--green) 6%, var(--panel)); }
-.agent-registry-system-login > div:first-child { align-items: flex-start; gap: 9px; color: var(--green); }
-.agent-registry-system-login strong { color: var(--bright); font-size: 12.5px; }
-.agent-registry-system-login p { margin-top: 4px; color: var(--muted); font-size: 11px; line-height: 1.5; }
-.agent-registry-inline-form { align-items: end; gap: 9px; margin-top: 14px; }
-.agent-registry-system-models { margin-top: 18px; display: grid; gap: 10px; }
-.agent-registry-system-models h4 { color: var(--bright); font-size: 12px; }
-.agent-registry-system-models p { color: var(--muted); font-size: 11px; line-height: 1.5; }
-.agent-registry-system-models-empty { margin: 0; color: var(--muted); font-size: 11.5px; }
-.agent-registry-model-chip {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 0;
- border: 0;
- background: transparent;
- cursor: pointer;
-}
-.agent-registry-model-chip code {
- padding: 5px 7px;
- border: 1px solid var(--line);
- border-radius: 5px;
- color: var(--text);
- background: var(--panel2);
- font-size: 10.5px;
-}
-.agent-registry-model-chip small { color: var(--green); font-size: 10px; }
-.agent-registry-model-chip.is-selected code {
- border-color: color-mix(in srgb, var(--blue) 55%, var(--line));
- color: var(--bright);
- background: color-mix(in srgb, var(--blue) 10%, var(--panel));
-}
-.agent-registry-model-chip.is-verified code {
- border-color: color-mix(in srgb, var(--green) 40%, var(--line));
-}
-.agent-registry-inline-form label { width: min(280px, 100%); }
-.agent-registry-editor-actions {
- justify-content: flex-end;
- flex-wrap: wrap;
- gap: 8px;
- margin-top: 14px;
- padding-top: 12px;
- border-top: 1px solid var(--line);
-}
-.agent-registry-last-test + .agent-registry-editor-actions,
-.agent-registry-last-test.is-idle + .agent-registry-editor-actions {
- margin-top: 0;
- padding-top: 10px;
-}
-.agent-registry-editor-actions select { width: auto; min-width: 145px; margin-right: auto; }
-.agent-registry-empty { min-height: 360px; display: grid; place-content: center; justify-items: center; gap: 8px; color: var(--muted); text-align: center; }
-.agent-registry-empty h3 { color: var(--bright); font-size: 14px; }
-.agent-registry-empty p { max-width: 440px; font-size: 11.5px; line-height: 1.5; }
-.agent-registry-empty .agent-registry-button { margin-top: 8px; }
-.agent-registry-panel { min-height: 480px; padding: 22px; border: 1px solid var(--line); border-top: 0; background: var(--panel); }
-.agent-registry-panel-head { justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
-.agent-registry-runtime-note { align-items: flex-start; gap: 9px; margin-top: 18px; padding: 12px; border: 1px solid var(--line); color: var(--green); background: var(--panel2); }
-.agent-registry-runtime-note strong { color: var(--bright); font-size: 11.5px; }
-.agent-registry-runtime-note p { margin-top: 3px; color: var(--muted); font-size: 10.5px; line-height: 1.5; }
-.agent-registry-check { min-height: 38px; display: flex !important; align-items: center; grid-template-columns: none !important; gap: 8px !important; }
-.agent-registry-check input { width: 16px; min-height: 16px; accent-color: var(--blue); }
-.agent-registry-capability-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 26px; margin-top: 20px; }
-.agent-registry-capability-grid h4 { margin-bottom: 10px; color: var(--bright); font-size: 11.5px; }
-.agent-registry-chips { display: flex; flex-wrap: wrap; gap: 7px; }
-.agent-registry-chips code { padding: 5px 7px; border: 1px solid var(--line); border-radius: 5px; color: var(--text); background: var(--panel2); font-size: 10.5px; }
-.agent-registry-chips p,
-.agent-registry-capability-list > p { color: var(--muted); font-size: 11px; }
-.agent-registry-capability-list { border: 1px solid var(--line); }
-.agent-registry-capability-list > div { min-height: 38px; padding: 0 10px; display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; align-items: center; gap: 7px; font-size: 11px; }
-.agent-registry-capability-list > div + div { border-top: 1px solid var(--line); }
-.agent-registry-capability-list > div svg { color: var(--green); }
-.agent-registry-capability-list small { color: var(--muted); }
-.agent-registry-capability-list > p { padding: 14px; }
-.agent-registry-dialog-form { display: grid; gap: 13px; }
-.agent-registry-dialog-error { padding: 9px; border: 1px solid var(--red); color: var(--red); background: color-mix(in srgb, var(--red) 7%, var(--panel)); font-size: 11.5px; }
-.agent-registry-dialog-note { color: var(--muted); font-size: 12px; line-height: 1.6; }
-.agent-registry :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
-
-@media (max-width: 1160px) {
- .agent-registry-list-head,
- .agent-registry-row { grid-template-columns: minmax(140px, 1.1fr) minmax(90px, .8fr) minmax(108px, .9fr) 52px 38px 66px; column-gap: 6px; }
- .agent-registry-list-head > span:nth-child(5),
- .agent-registry-resource-counts { display: none; }
- .agent-registry-row-actions { grid-column: 5 / 7; grid-template-columns: 38px 66px; gap: 6px; }
- .agent-registry-credential-count small { display: none; }
- .agent-registry-manage { padding-inline: 7px; gap: 4px; font-size: 12px; white-space: nowrap; }
-}
-
-@media (max-width: 760px) {
- .agent-registry { padding: 14px 14px 42px; }
- .agent-registry-toolbar { align-items: flex-start; }
- .agent-registry-toolbar > p { max-width: 54%; }
- .agent-registry-timestamp { display: none; }
- .agent-registry-button,
- .agent-registry-manage,
- .agent-registry-icon-button { min-height: 44px; }
- .agent-registry-default { align-items: stretch; flex-direction: column; gap: 14px; }
- .agent-registry-default-controls { min-width: 0; justify-content: stretch; }
- .agent-registry-default-controls .provider-default-model-picker { min-width: 0; }
- .agent-registry-saved { display: none; }
- .agent-registry-section-head { align-items: stretch; flex-direction: column; }
- .agent-registry-section-head dl { overflow-x: auto; }
- .agent-registry-section-head dl > div { flex: 1; min-width: 72px; }
- .agent-registry-list { min-height: 620px; }
- .agent-registry-list-head { display: none; }
- .agent-registry-row { min-height: 0; padding: 15px; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; }
- .agent-registry-identity { grid-column: 1 / -1; }
- .agent-registry-runtime { grid-column: 1 / 2; grid-row: 2; padding-left: 51px; }
- .agent-registry-version { grid-column: 2 / 3; grid-row: 2; justify-items: end; text-align: right; }
- .agent-registry-credential-count { grid-column: 1 / 2; grid-row: 3; padding-left: 51px; }
- .agent-registry-row-actions { grid-column: 2 / 3; grid-row: 3; display: flex; justify-content: flex-end; gap: 10px; }
- .agent-registry-loading { height: 585px; }
- .agent-registry-detail-head { align-items: flex-start; flex-direction: column; }
- .agent-registry-detail-actions { width: 100%; }
- .agent-registry-detail-actions .agent-registry-button { margin-right: auto; }
- .agent-registry-detail-stats { grid-template-columns: repeat(2, 1fr); }
- .agent-registry-detail-stats > div + div { border-left: 0; }
- .agent-registry-detail-stats > div:nth-child(even) { border-left: 1px solid var(--line); }
- .agent-registry-detail-stats > div:nth-child(n+3) { border-top: 1px solid var(--line); }
- .agent-registry-credential-layout { grid-template-columns: 1fr; }
- .agent-registry-credential-list { border-right: 0; border-bottom: 1px solid var(--line); }
- .agent-registry-credential-list > button { min-height: 58px; }
- .agent-registry-editor,
- .agent-registry-panel { padding: 17px 14px; }
- .agent-registry-form-grid,
- .agent-registry-capability-grid { grid-template-columns: 1fr; }
- .agent-registry-form-grid label.is-wide { grid-column: auto; }
- .agent-registry-inline-form { align-items: stretch; flex-direction: column; }
- .agent-registry-inline-form label { width: 100%; }
- .agent-registry-editor-actions { align-items: stretch; flex-direction: column; }
- .agent-registry-editor-actions select { width: 100%; min-height: 44px; margin: 0; }
- .agent-registry-editor-actions .agent-registry-button.is-danger { margin-left: 0; }
-}
-
-/* Status pulses and control micro-motion — durations from transitions.dev tokens. */
-@keyframes t-status-pulse {
- 0%, 100% { opacity: .55; box-shadow: 0 0 0 0 color-mix(in srgb, var(--blue) 35%, transparent); }
- 50% { opacity: 1; box-shadow: 0 0 0 4px color-mix(in srgb, var(--blue) 0%, transparent); }
-}
-@keyframes t-caret-pulse {
- 0%, 100% { opacity: .35; }
- 50% { opacity: 1; }
-}
-.ai-message-actions,
-.ai-loading-state {
- animation: t-texts-reveal-in var(--duration-quick) var(--ease-out) both;
-}
-.stream-caret.is-streaming {
- animation: t-caret-pulse 850ms var(--ease-in-out) infinite;
-}
-.conversation-turn-process-spinner {
- width: 7px;
- height: 7px;
- border: 0;
- background: var(--blue);
- box-shadow: none;
- animation: t-status-pulse 1.2s var(--ease-in-out) infinite;
-}
-.ai-tool-chips button[data-status="running"] {
- background: color-mix(in srgb, var(--blue) 6%, transparent);
-}
-.ai-tool-chips button[data-status="cancelled"] {
- background: color-mix(in srgb, var(--amber, #c48a2a) 8%, transparent);
-}
-:where(
- .conversation-model-pick,
- .conversation-model-star,
- .conversation-agent-rail button,
- .conversation-endpoint-rail > button,
- .conversation-permission-menu > button,
- .conversation-title-menu [data-slot="menu-item"],
- .ai-approval-card button,
- .ai-conv-user-input button
-) {
- transition:
- color var(--duration-quick) ease,
- background-color var(--duration-quick) ease,
- border-color var(--duration-quick) ease,
- opacity var(--duration-quick) ease,
- scale var(--duration-micro) ease;
-}
-:where(
- .conversation-model-pick,
- .conversation-model-star,
- .conversation-agent-rail button,
- .conversation-endpoint-rail > button,
- .conversation-permission-menu > button,
- .conversation-title-menu [data-slot="menu-item"],
- .ai-approval-card button,
- .ai-conv-user-input button
-):active:not(:disabled) {
- scale: .985;
-}
-
-@media (prefers-reduced-motion: reduce) {
- .ai-message-actions,
- .ai-loading-state,
- .stream-caret.is-streaming,
- .conversation-turn-process-spinner {
- animation: none !important;
- }
-}
diff --git a/apps/web/ui/app/styles/settings-conversation.css b/apps/web/ui/app/styles/settings-conversation.css
index 8b29112..0c52052 100644
--- a/apps/web/ui/app/styles/settings-conversation.css
+++ b/apps/web/ui/app/styles/settings-conversation.css
@@ -21,8 +21,8 @@
--dsw-alias-button-floating-hover: var(--panel3);
--dsw-specific-sidebar-fill: var(--rail);
--dsw-specific-input-major: var(--panel);
- --dsh-chat-content-width: 748px;
- --dsh-composer-card-max-width: 780px;
+ --dsh-chat-content-width: 768px;
+ --dsh-composer-card-max-width: 752px;
--dsh-composer-side-clearance: 16px;
width: 100%;
height: 100%;
@@ -683,7 +683,8 @@ html, body {
/* ── C39 conversation reading prefs ─────────────────────────────────────── */
.ai-conv-shell {
- --conv-fs-body: 13.5px;
+ --conv-fs-body: 15px;
+ --cx-msg-fs: var(--conv-fs-body);
}
.ai-conv-shell .ai-message-body,
.ai-conv-shell .ai-stream-body,
diff --git a/apps/web/ui/app/styles/settings-hub.css b/apps/web/ui/app/styles/settings-hub.css
index da26e8b..9c5ece4 100644
--- a/apps/web/ui/app/styles/settings-hub.css
+++ b/apps/web/ui/app/styles/settings-hub.css
@@ -484,18 +484,6 @@
display: flex;
flex-direction: column;
}
-.settings-hub[data-page="agents"] .settings-hub-body.is-flush {
- overflow-x: hidden;
- overflow-y: auto;
- overscroll-behavior-y: contain;
- scrollbar-gutter: stable;
-}
-.settings-hub[data-page="agents"] .provider-manager.agent-registry {
- width: 100%;
- max-width: 100%;
- min-width: 0;
- padding: 18px 36px 56px;
-}
.settings-hub-body.is-flush > * {
flex: 1 1 auto;
min-height: 0;
@@ -523,51 +511,6 @@
.settings-hub .operations-settings-toolbar { width: 100%; margin: 0 0 4px; }
.settings-hub .wappearance-page { background: transparent; padding: 8px 0 36px; }
-.settings-data-page { width: min(100%, 940px); margin: 0 auto; padding-top: 10px; }
-.settings-data-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 22px; }
-.settings-data-search { flex: 1; min-width: 0; height: 40px; display: flex; align-items: center; gap: 9px;
- padding: 0 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--muted); }
-.settings-data-search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 13%, transparent); }
-.settings-data-search input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; color: var(--text); font: inherit; font-size: 13px; }
-.settings-data-search input::placeholder { color: var(--dim); }
-.settings-data-primary, .settings-data-secondary { display: inline-flex; align-items: center; justify-content: center; gap: 7px;
- min-height: 34px; padding: 0 12px; border-radius: 8px; font: inherit; font-size: 12px; font-weight: 600;
- text-decoration: none; white-space: nowrap; cursor: pointer; }
-.settings-data-primary { border: 1px solid var(--accent); background: var(--accent); color: var(--on-accent, #fff); }
-.settings-data-primary:hover:not(:disabled) { filter: brightness(1.07); }
-.settings-data-secondary { border: 1px solid var(--line); background: var(--panel); color: var(--text); }
-.settings-data-secondary:hover:not(:disabled) { background: var(--panel2); }
-.settings-data-primary:disabled, .settings-data-secondary:disabled { opacity: .55; cursor: wait; }
-.settings-data-list-head { display: flex; align-items: center; gap: 8px; padding: 0 2px 12px;
- color: var(--bright); font-size: 13px; font-weight: 650; }
-.settings-data-list-head span:last-child { display: inline-grid; min-width: 21px; min-height: 21px; place-items: center;
- padding: 0 5px; border-radius: 7px; background: var(--panel2); color: var(--muted); font-size: 11px; }
-.settings-data-feedback:empty { display: none; }
-.settings-data-feedback p { margin: 0 0 12px; color: var(--green); font-size: 12px; }
-.settings-data-feedback .is-error { color: var(--red); }
-.settings-data-empty { padding: 34px 18px; border: 1px dashed var(--line); border-radius: 12px;
- color: var(--muted); background: var(--panel); text-align: center; font-size: 13px; }
-.settings-archive-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
-.settings-archive-row { display: flex; align-items: center; justify-content: space-between; gap: 22px;
- min-width: 0; padding: 14px 16px; border: 1px solid var(--line); border-radius: 11px; background: var(--panel); }
-.settings-archive-info { min-width: 0; display: grid; gap: 5px; }
-.settings-archive-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--bright); font-size: 13px; }
-.settings-archive-info span { color: var(--dim); font-size: 11px; }
-.settings-archive-info p { overflow: hidden; margin: 1px 0 0; color: var(--muted); font-size: 12px;
- text-overflow: ellipsis; white-space: nowrap; }
-.settings-archive-actions { flex: none; display: flex; gap: 8px; }
-.settings-import-card { display: flex; align-items: flex-start; gap: 16px; padding: 22px;
- border: 1px solid var(--line); border-radius: 12px; background: var(--panel); }
-.settings-import-icon { flex: none; width: 40px; height: 40px; display: grid; place-items: center;
- border-radius: 10px; background: color-mix(in srgb, var(--accent) 10%, var(--panel)); color: var(--accent); }
-.settings-import-card h2 { margin: 0 0 8px; color: var(--bright); font-size: 15px; }
-.settings-import-card p { max-width: 600px; margin: 0 0 18px; color: var(--muted); font-size: 13px; line-height: 1.6; }
-@media (max-width: 640px) {
- .settings-data-toolbar, .settings-archive-row { align-items: stretch; flex-direction: column; }
- .settings-archive-actions { flex-wrap: wrap; }
- .settings-archive-actions > * { flex: 1; }
-}
-
:is(.settings-hub, .single-task-workers-body) .wsettings-page,
:is(.settings-hub, .single-task-workers-body) .wsettings-skeleton {
width: 100%;
@@ -796,9 +739,6 @@
.settings-hub-pagehead,
.settings-hub-body { padding-inline: 16px; }
.settings-hub-body.is-flush { padding: 0; }
- .settings-hub[data-page="agents"] .provider-manager.agent-registry {
- padding-inline: 16px;
- }
.settings-hub-pagehead { padding-top: 16px; }
.settings-hub-pagehead h1 { font-size: 22px; }
}
diff --git a/apps/web/ui/app/styles/settings-unified-agent-extensions.css b/apps/web/ui/app/styles/settings-unified-agent-extensions.css
new file mode 100644
index 0000000..5cef0b5
--- /dev/null
+++ b/apps/web/ui/app/styles/settings-unified-agent-extensions.css
@@ -0,0 +1,116 @@
+/* Agent extensions inside the unified settings shell. */
+.cx-settings-agent-ext { container-type: inline-size; }
+.cx-settings-agent-ext .agent-extensions { max-width: none; padding: 0; color: var(--cx-fg); font-family: var(--cx-font-sans); }
+
+/* Segmented controls: scenes, sources, type filter. */
+.cx-settings-agent-ext :is(.ae-mode-bar, .ae-source, .ae-filters-types) { display: inline-flex; align-items: center; gap: 2px; width: fit-content; max-width: 100%; margin: 0; padding: 3px; border: 0; border-radius: 10px; background: var(--cx-hover); overflow-x: auto; }
+.cx-settings-agent-ext .ae-mode-bar { margin-bottom: 24px; }
+.cx-settings-agent-ext :is(.ae-mode-bar, .ae-source, .ae-filters-types) button { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: var(--cx-fg-2); font: inherit; font-size: 13px; font-weight: 500; white-space: nowrap; cursor: pointer; transition: background-color .15s, color .15s; }
+.cx-settings-agent-ext .ae-mode-bar button:first-child { padding-left: 12px; }
+.cx-settings-agent-ext :is(.ae-mode-bar, .ae-source, .ae-filters-types) button:hover { color: var(--cx-fg); }
+.cx-settings-agent-ext :is(.ae-mode-bar button.active, .ae-source button.active, .ae-filters-types button[aria-pressed="true"]) { border: 0; background: var(--cx-elevated); color: var(--cx-fg); font-weight: 500; box-shadow: var(--cx-shadow-xs); }
+.cx-settings-agent-ext .ae-mode-bar button span,
+.cx-settings-agent-ext .ae-mode-bar button.active span { min-width: 0; padding: 0; border-radius: 0; background: transparent; color: var(--cx-fg-3); font-size: 12px; font-weight: 400; font-variant-numeric: tabular-nums; }
+.cx-settings-agent-ext :is(.ae-mode-bar, .ae-source, .ae-filters-types, .ae-dialog-tabs) button:focus-visible { outline: 2px solid var(--cx-focus); outline-offset: 1px; }
+
+/* Scene header, matching a settings section title. */
+.cx-settings-agent-ext .ae-intro { align-items: flex-end; gap: 16px; padding: 0 4px 12px; }
+.cx-settings-agent-ext .ae-eyebrow { display: none; }
+.cx-settings-agent-ext .ae-intro h2 { margin: 0; color: var(--cx-fg); font-size: 14px; font-weight: 500; line-height: 1.45; letter-spacing: 0; }
+.cx-settings-agent-ext .ae-intro p { max-width: none; margin: 2px 0 0; color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.cx-settings-agent-ext .ae-intro-count { display: inline-flex; flex-direction: row; align-items: center; gap: 4px; height: 20px; padding: 0 8px; border-radius: 999px; background: var(--cx-hover); color: var(--cx-fg-2); font-size: 12px; font-weight: 500; white-space: nowrap; }
+.cx-settings-agent-ext .ae-intro-count strong { color: var(--cx-fg-2); font-size: 12px; font-weight: 500; line-height: 1; font-variant-numeric: tabular-nums; }
+
+/* Toolbar, run picker and filters. */
+.cx-settings-agent-ext .ae-toolbar { gap: 12px; margin-bottom: 12px; }
+.cx-settings-agent-ext :is(.ae-add, .ae-run-picker button, .ae-empty button) { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; min-height: 0; padding: 0 12px; border-radius: 8px; font: inherit; font-size: 13px; font-weight: 500; white-space: nowrap; cursor: pointer; transition: background-color .15s; }
+.cx-settings-agent-ext .ae-add { border: 0; background: var(--cx-accent); color: var(--cx-accent-fg); }
+.cx-settings-agent-ext .ae-add:hover { filter: brightness(1.08); }
+.cx-settings-agent-ext :is(.ae-run-picker button, .ae-empty button) { border: 1px solid var(--cx-border); background: var(--cx-elevated); color: var(--cx-fg); }
+.cx-settings-agent-ext :is(.ae-run-picker button, .ae-empty button):hover { background: var(--cx-hover); }
+.cx-settings-agent-ext :is(.ae-add, .ae-run-picker button, .ae-empty button, .ae-row-action > button):focus-visible { outline: 2px solid var(--cx-focus); outline-offset: 2px; }
+.cx-settings-agent-ext .ae-run-picker { gap: 8px 12px; margin: 0 0 12px; color: var(--cx-fg-3); font-size: 13px; }
+.cx-settings-agent-ext .ae-run-picker label { color: var(--cx-fg-2); font-weight: 500; }
+.cx-settings-agent-ext .ae-run-picker small { color: var(--cx-fg-3); font-size: 12px; }
+.cx-settings-agent-ext .ae-filters { gap: 8px; margin-bottom: 16px; }
+.cx-settings-agent-ext .ae-search { gap: 8px; min-height: 0; height: 34px; padding: 0 12px; border: 1px solid var(--cx-border); border-radius: 10px; background: var(--cx-bg); color: var(--cx-fg-3); transition: border-color .15s, box-shadow .15s; }
+.cx-settings-agent-ext .ae-search input { color: var(--cx-fg); font-size: 13px; }
+.cx-settings-agent-ext .ae-search input::placeholder { color: var(--cx-fg-3); }
+.cx-settings-agent-ext :is(.ae-filters select, .ae-run-picker select) { min-height: 0; height: 34px; padding: 0 10px; border: 1px solid var(--cx-border); border-radius: 10px; background: var(--cx-bg); color: var(--cx-fg); font: inherit; font-size: 13px; outline: 0; transition: border-color .15s, box-shadow .15s; }
+.cx-settings-agent-ext :is(.ae-search:focus-within, .ae-filters select:focus, .ae-run-picker select:focus) { border-color: var(--cx-border-strong); box-shadow: 0 0 0 3px var(--cx-accent-soft); }
+.cx-settings-agent-ext .ae-filters-types button { height: 28px; padding: 0 10px; }
+
+/* Messages. */
+.cx-settings-agent-ext .ae-message { margin: 0 0 12px; padding: 0 4px; border: 0; border-radius: 0; background: transparent; color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.cx-settings-agent-ext .ae-message.ae-error { background: transparent; color: var(--cx-danger); }
+
+/* Installed list and native list as settings cards. */
+.cx-settings-agent-ext :is(.ae-list, .ae-native) { overflow: hidden; border: 1px solid var(--cx-border); border-radius: 16px; background: var(--cx-elevated); box-shadow: var(--cx-shadow-xs); }
+.cx-settings-agent-ext :is(.ae-list-head, .ae-row) { grid-template-columns: minmax(0, 1.6fr) minmax(96px, .7fr) minmax(112px, .8fr) 96px; gap: 16px; }
+.cx-settings-agent-ext .ae-list-head { min-height: 38px; padding: 0 20px; border-bottom: 1px solid var(--cx-border-subtle); background: transparent; color: var(--cx-fg-3); font-size: 12px; font-weight: 500; letter-spacing: 0; }
+.cx-settings-agent-ext .ae-row { min-height: 0; padding: 14px 20px; border-top: 0; transition: background-color .15s; }
+.cx-settings-agent-ext .ae-row + .ae-row { border-top: 1px solid var(--cx-border-subtle); }
+.cx-settings-agent-ext .ae-row:hover { background: var(--cx-hover); }
+.cx-settings-agent-ext .ae-row-builtin { background: transparent; }
+.cx-settings-agent-ext :is(.ae-row-icon, .ae-row-builtin .ae-row-icon) { width: 36px; height: 36px; border-radius: 10px; background: var(--cx-hover); color: var(--cx-fg-2); }
+.cx-settings-agent-ext :is(.ae-row-name button, .ae-row-name strong) { color: var(--cx-fg); font: inherit; font-size: 14px; font-weight: 500; line-height: 1.45; }
+.cx-settings-agent-ext .ae-row-name button:hover { color: var(--cx-fg); }
+.cx-settings-agent-ext .ae-row-name p { margin: 2px 0 2px; color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.cx-settings-agent-ext :is(.ae-row-name small, .ae-row-engine small) { color: var(--cx-fg-3); font-size: 12px; line-height: 1.5; }
+.cx-settings-agent-ext .ae-row-components { gap: 4px; }
+.cx-settings-agent-ext .ae-row-components span { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: 999px; background: var(--cx-hover); color: var(--cx-fg-2); font-size: 12px; font-weight: 500; }
+.cx-settings-agent-ext .ae-row-engine { font-size: 13px; }
+.cx-settings-agent-ext .ae-row-engine > span { display: inline-flex; align-items: center; height: 20px; margin-bottom: 4px; padding: 0 8px; border-radius: 999px; font-size: 12px; font-weight: 500; }
+.cx-settings-agent-ext .ae-row-engine > span.available { background: var(--cx-success-soft); color: var(--cx-success); }
+.cx-settings-agent-ext .ae-row-engine > span.unavailable { background: var(--cx-hover); color: var(--cx-fg-2); }
+.cx-settings-agent-ext .ae-row-action > button { height: 28px; padding: 0 8px; border-radius: 8px; color: var(--cx-fg-2); font: inherit; font-size: 13px; }
+.cx-settings-agent-ext .ae-row-action > button:hover { background: var(--cx-active); color: var(--cx-fg); }
+.cx-settings-agent-ext .ae-row-diagnostic { color: var(--cx-danger); font-size: 12px; }
+.cx-settings-agent-ext .ae-empty { gap: 6px; padding: 40px 20px; color: var(--cx-fg-3); font-size: 13px; }
+.cx-settings-agent-ext .ae-empty > svg { color: var(--cx-fg-3); }
+.cx-settings-agent-ext .ae-empty strong { color: var(--cx-fg); font-size: 14px; font-weight: 500; }
+.cx-settings-agent-ext .ae-empty p { line-height: 1.5; }
+.cx-settings-agent-ext .ae-empty button { margin-top: 8px; }
+.cx-settings-agent-ext .ae-native > p { padding: 14px 20px; border-bottom: 1px solid var(--cx-border-subtle); background: transparent; color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.cx-settings-agent-ext .ae-native-row { gap: 2px 12px; padding: 14px 20px; border-top: 0; font-size: 14px; transition: background-color .15s; }
+.cx-settings-agent-ext .ae-native-row + .ae-native-row { border-top: 1px solid var(--cx-border-subtle); }
+.cx-settings-agent-ext .ae-native-row:hover { background: var(--cx-hover); }
+.cx-settings-agent-ext .ae-native-row strong { color: var(--cx-fg); font-weight: 500; }
+.cx-settings-agent-ext .ae-native-row span { color: var(--cx-fg-3); font-size: 12px; }
+.cx-settings-agent-ext .ae-native-row p { color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.cx-settings-agent-ext .ae-boundary { gap: 8px; margin: 12px 0 0; padding: 0 4px; color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.cx-settings-agent-ext .ae-boundary svg { margin-top: 3px; }
+
+@container (max-width: 680px) {
+ .cx-settings-agent-ext :is(.ae-list-head, .ae-row) { grid-template-columns: minmax(0, 1fr) 112px 96px; gap: 12px; }
+ .cx-settings-agent-ext :is(.ae-list-head span:nth-child(2), .ae-row-components) { display: none; }
+}
+@container (max-width: 520px) {
+ .cx-settings-agent-ext .ae-list-head { display: none; }
+ .cx-settings-agent-ext .ae-row { grid-template-columns: minmax(0, 1fr) 96px; }
+ .cx-settings-agent-ext .ae-row-engine { grid-column: 1; }
+ .cx-settings-agent-ext .ae-row-action { grid-column: 2; grid-row: 1 / 3; }
+ .cx-settings-agent-ext .ae-filters-types { width: 100%; }
+ .cx-settings-agent-ext .ae-filters-types button { flex: 1; justify-content: center; }
+}
+
+/* Install and detail dialogs (portalled outside the page wrapper; these classes are unique to this component). */
+.ae-dialog-content { gap: 16px; color: var(--cx-fg); font-family: var(--cx-font-sans); font-size: 13px; }
+.ae-dialog-content > div > p, .ae-dialog-content > p { color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.ae-dialog-label { margin-bottom: 8px; color: var(--cx-fg); font-size: 13px; font-weight: 500; }
+.ae-dialog-content .ae-dialog-tabs { display: inline-flex; gap: 2px; width: fit-content; padding: 3px; border: 0; border-radius: 10px; background: var(--cx-hover); }
+.ae-dialog-content .ae-dialog-tabs button { height: 28px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: var(--cx-fg-2); font: inherit; font-size: 13px; font-weight: 500; }
+.ae-dialog-content .ae-dialog-tabs button[aria-pressed="true"] { border: 0; background: var(--cx-elevated); color: var(--cx-fg); font-weight: 500; box-shadow: var(--cx-shadow-xs); }
+.ae-dialog-content select { height: 34px; min-height: 0; padding: 0 10px; border: 1px solid var(--cx-border); border-radius: 10px; background: var(--cx-bg); color: var(--cx-fg); font: inherit; font-size: 13px; }
+.ae-dialog-content .ae-mode-choices { gap: 0; overflow: hidden; border: 1px solid var(--cx-border); border-radius: 12px; }
+.ae-dialog-content .ae-mode-choices label { gap: 10px; padding: 12px 16px; border: 0; border-radius: 0; transition: background-color .15s; }
+.ae-dialog-content .ae-mode-choices label + label { border-top: 1px solid var(--cx-border-subtle); }
+.ae-dialog-content .ae-mode-choices label:hover { background: var(--cx-hover); }
+.ae-dialog-content .ae-mode-choices input { accent-color: var(--cx-accent); }
+.ae-dialog-content .ae-mode-choices strong { color: var(--cx-fg); font-size: 14px; font-weight: 500; }
+.ae-dialog-content .ae-mode-choices small { color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.ae-dialog-content .ae-dialog-error { color: var(--cx-danger) !important; }
+.ae-dialog-content pre { border: 1px solid var(--cx-border); border-radius: 12px; background: var(--cx-sunken); color: var(--cx-fg-2); font-family: var(--cx-font-mono); font-size: 12px; }
+.ae-dialog-content .ae-compatibility > div { border-bottom-color: var(--cx-border-subtle); font-size: 13px; }
+.ae-dialog-content .ae-compatibility strong { font-weight: 500; }
+.ae-dialog-content .ae-compatibility span { color: var(--cx-fg-3); }
diff --git a/apps/web/ui/app/styles/settings-unified-capabilities.css b/apps/web/ui/app/styles/settings-unified-capabilities.css
new file mode 100644
index 0000000..1660a2c
--- /dev/null
+++ b/apps/web/ui/app/styles/settings-unified-capabilities.css
@@ -0,0 +1,200 @@
+/* Capabilities (CapabilityManagement) inside the unified settings shell. */
+.cx-settings-capabilities { container-type: inline-size; }
+.cx-settings-capabilities .capability-center { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; max-width: none; min-height: 0; padding: 0; gap: 16px 12px; color: var(--cx-fg); font-family: var(--cx-font-sans); }
+.cx-settings-capabilities .capability-center > * { order: 4; grid-column: 1 / -1; min-width: 0; }
+/* Refresh action shares the row with the tab control instead of sitting alone above the stats. */
+.cx-settings-capabilities .capability-center > .cap-feedback { order: 0; }
+.cx-settings-capabilities .capability-center > .cap-stats-deck { order: 1; }
+.cx-settings-capabilities .capability-center > .cap-tab-bar { order: 2; grid-column: 1; }
+.cx-settings-capabilities .capability-center > .cap-topbar { order: 3; grid-column: 2; align-self: center; }
+.cx-settings-capabilities .capability-center :is(code, kbd) { font-family: var(--cx-font-mono); font-size: 12px; color: var(--cx-fg-2); }
+
+/* Toolbar */
+.cx-settings-capabilities .cap-topbar.is-actions-only { margin: 8px 0 0; }
+
+/* Buttons */
+.cx-settings-capabilities .cap-btn,
+.cx-settings-capabilities .cap-btn.sm { height: 32px; min-height: 32px; padding: 0 12px; gap: 6px; border: 1px solid var(--cx-border); border-radius: 8px; background: var(--cx-elevated); color: var(--cx-fg); font-size: 13px; font-weight: 500; box-shadow: none; }
+.cx-settings-capabilities .cap-btn:hover:not(:disabled) { border-color: var(--cx-border); background: var(--cx-hover); color: var(--cx-fg); }
+.cx-settings-capabilities .cap-btn.primary { border-color: transparent; background: var(--cx-accent); color: var(--cx-accent-fg); }
+.cx-settings-capabilities .cap-btn.primary:hover:not(:disabled) { border-color: transparent; background: var(--cx-accent); color: var(--cx-accent-fg); filter: brightness(1.06); }
+.cx-settings-capabilities .cap-btn.danger { border-color: var(--cx-border); background: var(--cx-elevated); color: var(--cx-danger); }
+.cx-settings-capabilities .cap-btn.danger:hover:not(:disabled) { border-color: var(--cx-border); background: var(--cx-danger-soft); color: var(--cx-danger); }
+.cx-settings-capabilities .capability-center button:disabled,
+.cx-settings-capabilities .capability-center button[data-disabled] { opacity: .5; }
+.cx-settings-capabilities .cap-btn:focus-visible,
+.cx-settings-capabilities :is(.cap-tab-btn, .cap-filter-pill, .cap-thread-card):focus-visible { outline: 2px solid var(--cx-focus); outline-offset: 1px; }
+
+/* Chips */
+.cx-settings-capabilities :is(.cap-badge, .cap-state, .cap-injection-chip, .cap-extension-provide-pill) { display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 0 8px; border: 0; border-radius: 999px; background: var(--cx-hover); color: var(--cx-fg-2); font-family: var(--cx-font-sans); font-size: 12px; font-weight: 500; line-height: 1; white-space: nowrap; flex: none; }
+.cx-settings-capabilities .cap-state i { width: 6px; height: 6px; }
+.cx-settings-capabilities .cap-badge.primary,
+.cx-settings-capabilities .cap-state.info { background: var(--cx-hover); color: var(--cx-fg-2); }
+.cx-settings-capabilities :is(.cap-badge.success, .cap-state.ok, .cap-injection-chip.supported) { background: var(--cx-success-soft); color: var(--cx-success); }
+.cx-settings-capabilities .cap-state.bad { background: var(--cx-danger-soft); color: var(--cx-danger); }
+.cx-settings-capabilities .cap-state.warn { background: var(--cx-warning-soft); color: var(--cx-warning); }
+.cx-settings-capabilities .cap-extension-provide-pill { height: auto; min-height: 20px; padding: 3px 8px; line-height: 1.3; white-space: normal; }
+
+/* Feedback */
+.cx-settings-capabilities .cap-feedback { padding: 12px 16px; border: 1px solid var(--cx-border); border-radius: 12px; background: var(--cx-elevated); color: var(--cx-fg); font-size: 13px; }
+.cx-settings-capabilities .cap-feedback.ok { border-color: transparent; background: var(--cx-success-soft); color: var(--cx-success); }
+.cx-settings-capabilities .cap-feedback.bad { border-color: transparent; background: var(--cx-danger-soft); color: var(--cx-danger); }
+
+/* Stat tiles */
+.cx-settings-capabilities .cap-stats-deck { grid-template-columns: repeat(auto-fit, minmax(176px, 1fr)); gap: 12px; }
+.cx-settings-capabilities .cap-stat-card { display: grid; grid-template-columns: minmax(0, 1fr); align-content: start; row-gap: 4px; padding: 16px 20px; border: 1px solid var(--cx-border); border-radius: 16px; background: var(--cx-elevated); box-shadow: var(--cx-shadow-xs); }
+.cx-settings-capabilities .cap-stat-card:hover { border-color: var(--cx-border); }
+.cx-settings-capabilities .cap-stat-head { display: contents; }
+.cx-settings-capabilities .cap-stat-head > :first-child { grid-row: 1; margin-bottom: 4px; }
+.cx-settings-capabilities .cap-stat-head > :last-child:not(:first-child) { grid-row: 4; justify-self: start; margin-top: 8px; }
+.cx-settings-capabilities .cap-stat-value { grid-row: 2; min-width: 0; white-space: nowrap; }
+.cx-settings-capabilities .cap-stat-sub { grid-row: 3; }
+.cx-settings-capabilities .cap-stat-label { min-width: 0; gap: 6px; color: var(--cx-fg-3); font-size: 12px; font-weight: 400; white-space: nowrap; }
+.cx-settings-capabilities .cap-stat-label svg { flex: none; color: var(--cx-fg-3); }
+.cx-settings-capabilities .cap-stat-value { gap: 4px; color: var(--cx-fg); font-family: var(--cx-font-sans); font-size: 20px; font-weight: 600; line-height: 1.3; font-variant-numeric: tabular-nums; }
+.cx-settings-capabilities .cap-stat-unit { color: var(--cx-fg-3); font-size: 12px; font-weight: 400; }
+.cx-settings-capabilities .cap-stat-sub { color: var(--cx-fg-3); font-size: 12px; line-height: 1.5; }
+
+/* Tabs as a segmented control */
+.cx-settings-capabilities .cap-tab-bar { justify-self: start; width: fit-content; max-width: 100%; gap: 2px; margin-top: 8px; padding: 3px; border: 0; border-radius: 10px; background: var(--cx-hover); scrollbar-width: none; }
+.cx-settings-capabilities .cap-tab-bar::-webkit-scrollbar { display: none; }
+.cx-settings-capabilities .cap-tab-btn { height: 30px; min-height: 30px; padding: 0 12px; gap: 6px; border: 0; border-radius: 8px; background: transparent; color: var(--cx-fg-2); font-size: 13px; font-weight: 500; box-shadow: none; flex: none; }
+.cx-settings-capabilities .cap-tab-btn:hover { background: transparent; color: var(--cx-fg); }
+.cx-settings-capabilities .cap-tab-btn.active { background: var(--cx-elevated); color: var(--cx-fg); box-shadow: var(--cx-shadow-xs); }
+.cx-settings-capabilities .cap-tab-btn svg { color: var(--cx-fg-3); }
+.cx-settings-capabilities .cap-tab-btn.active svg { color: var(--cx-fg-2); }
+.cx-settings-capabilities .cap-tab-count,
+.cx-settings-capabilities .cap-tab-btn.active .cap-tab-count { padding: 0; border-radius: 0; background: transparent; color: var(--cx-fg-3); font-family: var(--cx-font-sans); font-size: 12px; font-weight: 400; font-variant-numeric: tabular-nums; }
+
+/* Thread list / detail */
+.cx-settings-capabilities .cap-threads-layout { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); gap: 16px; }
+.cx-settings-capabilities :is(.cap-thread-picker, .cap-binding-editor) { border: 1px solid var(--cx-border); border-radius: 16px; background: var(--cx-elevated); box-shadow: var(--cx-shadow-xs); }
+.cx-settings-capabilities .cap-thread-picker { gap: 12px; padding: 16px 12px 8px; }
+.cx-settings-capabilities .cap-thread-picker-head { padding: 0 8px; }
+.cx-settings-capabilities .cap-thread-picker-head > span:first-child { color: var(--cx-fg); font-size: 14px; font-weight: 500; }
+.cx-settings-capabilities .cap-thread-picker > .cap-search-input-wrap { margin: 0 4px; }
+.cx-settings-capabilities .cap-search-input-wrap svg { left: 11px; color: var(--cx-fg-3); }
+.cx-settings-capabilities .cap-search-input { height: 34px; min-height: 34px; padding: 0 12px 0 32px; border: 1px solid var(--cx-border); border-radius: 10px; background: var(--cx-bg); color: var(--cx-fg); font-family: var(--cx-font-sans); font-size: 13px; transition: border-color .15s, box-shadow .15s; }
+.cx-settings-capabilities .cap-search-input::placeholder { color: var(--cx-fg-3); }
+.cx-settings-capabilities .cap-search-input:focus { border-color: var(--cx-border-strong); box-shadow: 0 0 0 3px var(--cx-accent-soft); }
+.cx-settings-capabilities .cap-thread-list { gap: 2px; max-height: 620px; padding: 0 0 4px; }
+.cx-settings-capabilities .cap-thread-card { flex: none; height: auto; min-height: 0; gap: 3px; padding: 10px 12px; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--cx-fg); }
+.cx-settings-capabilities .cap-thread-card:hover { border-color: transparent; background: var(--cx-hover); }
+.cx-settings-capabilities .cap-thread-card.active { border-color: var(--cx-border-strong); background: var(--cx-selected); }
+.cx-settings-capabilities .cap-thread-card-title { color: var(--cx-fg); font-size: 14px; font-weight: 500; line-height: 1.4; }
+.cx-settings-capabilities .cap-thread-card-top .cap-badge { height: 18px; padding: 0 7px; font-size: 11px; background: transparent; color: var(--cx-fg-3); padding-right: 0; }
+.cx-settings-capabilities :is(.cap-thread-card-sub, .cap-thread-card-meta) { color: var(--cx-fg-3); font-family: var(--cx-font-sans); font-size: 12px; line-height: 1.45; font-variant-numeric: tabular-nums; }
+.cx-settings-capabilities .cap-thread-card-sub > span:first-child { font-family: var(--cx-font-mono); color: var(--cx-fg-2); }
+
+.cx-settings-capabilities .cap-binding-editor { gap: 16px; padding: 20px; }
+.cx-settings-capabilities .cap-binding-editor-head { gap: 12px 16px; padding-bottom: 16px; border-bottom: 1px solid var(--cx-border-subtle); }
+.cx-settings-capabilities .cap-binding-editor-info { flex: 1 1 280px; gap: 6px; }
+.cx-settings-capabilities .cap-binding-editor-title { color: var(--cx-fg); font-size: 15px; font-weight: 600; line-height: 1.4; }
+.cx-settings-capabilities .cap-binding-editor-title > span:first-child { min-width: 0; overflow-wrap: anywhere; }
+.cx-settings-capabilities .cap-binding-editor-ids { min-width: 0; color: var(--cx-fg-3); font-family: var(--cx-font-mono); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
+.cx-settings-capabilities .cap-binding-editor-actions { gap: 8px; }
+
+.cx-settings-capabilities .cap-tools-toolbar { gap: 12px; }
+.cx-settings-capabilities .cap-tools-toolbar > div:last-child { flex-wrap: wrap; }
+.cx-settings-capabilities .cap-tools-toolbar .cap-search-input-wrap { width: 200px !important; }
+.cx-settings-capabilities .cap-target-filters { gap: 2px; padding: 3px; border-radius: 10px; background: var(--cx-hover); }
+.cx-settings-capabilities .cap-filter-pill { height: 28px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: var(--cx-fg-2); font-size: 13px; font-weight: 500; }
+.cx-settings-capabilities .cap-filter-pill:hover { color: var(--cx-fg); }
+.cx-settings-capabilities .cap-filter-pill.active { background: var(--cx-elevated); color: var(--cx-fg); font-weight: 500; box-shadow: var(--cx-shadow-xs); }
+
+.cx-settings-capabilities .cap-tools-grid { gap: 10px; max-height: 520px; }
+.cx-settings-capabilities .cap-tool-card { gap: 10px; padding: 12px 14px; border: 1px solid var(--cx-border-subtle); border-radius: 12px; background: var(--cx-elevated); }
+.cx-settings-capabilities .cap-tool-card:hover { border-color: var(--cx-border); background: var(--cx-hover); }
+.cx-settings-capabilities .cap-tool-card.selected { border-color: var(--cx-border-strong); background: var(--cx-bg-subtle); }
+.cx-settings-capabilities .cap-tool-card-body { gap: 4px; }
+.cx-settings-capabilities .cap-tool-card .checkbox:not([data-selected="true"]) .checkbox__control { box-shadow: inset 0 0 0 1px var(--cx-border-strong); }
+.cx-settings-capabilities .cap-tool-card-head { align-items: flex-start; }
+.cx-settings-capabilities .cap-tool-card-name { color: var(--cx-fg); font-family: var(--cx-font-mono); font-size: 12px; font-weight: 500; line-height: 20px; }
+.cx-settings-capabilities .cap-tool-card-desc { color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.cx-settings-capabilities .cap-tool-card-footer { gap: 4px 10px; margin-top: 4px; color: var(--cx-fg-3); font-size: 12px; line-height: 1.5; }
+
+.cx-settings-capabilities .cap-binding-footer { gap: 12px; padding-top: 16px; border-top: 1px solid var(--cx-border-subtle); }
+.cx-settings-capabilities .cap-dirty-notice { color: var(--cx-fg-3); font-size: 13px; }
+.cx-settings-capabilities .cap-dirty-notice.changed { color: var(--cx-warning); font-weight: 500; }
+
+/* Disclosures and grant rows */
+.cx-settings-capabilities .cap-disclosure { border: 1px solid var(--cx-border-subtle); border-radius: 12px; background: var(--cx-elevated); }
+.cx-settings-capabilities .cap-disclosure summary { min-height: 44px; padding: 10px 16px; color: var(--cx-fg); font-size: 13px; font-weight: 500; }
+.cx-settings-capabilities .cap-disclosure summary:hover { background: var(--cx-hover); }
+.cx-settings-capabilities .cap-disclosure summary svg { color: var(--cx-fg-3); }
+.cx-settings-capabilities .cap-disclosure-body { gap: 0; padding: 0; border-top: 1px solid var(--cx-border-subtle); }
+.cx-settings-capabilities .cap-disclosure-body > * { border-radius: 0 !important; }
+.cx-settings-capabilities .cap-disclosure-body > * + * { border-top: 1px solid var(--cx-border-subtle) !important; }
+.cx-settings-capabilities .cap-disclosure-body > p { padding: 12px 16px !important; color: var(--cx-fg-3) !important; font-size: 13px !important; }
+.cx-settings-capabilities .cap-grant-item { gap: 12px; padding: 12px 16px; border: 0; border-radius: 0; background: transparent; font-size: 12px; }
+.cx-settings-capabilities .cap-grant-item-main { gap: 4px; }
+.cx-settings-capabilities .cap-grant-item-title { flex-wrap: wrap; gap: 6px 8px; color: var(--cx-fg); font-family: var(--cx-font-sans); font-size: 13px; font-weight: 500; }
+.cx-settings-capabilities .cap-grant-item-title code { overflow-wrap: anywhere; }
+.cx-settings-capabilities .cap-grant-item-title > span[style] { color: var(--cx-fg-3) !important; font-size: 12px !important; font-weight: 400; }
+.cx-settings-capabilities .cap-grant-item-sub { color: var(--cx-fg-3); font-size: 12px; line-height: 1.5; }
+
+/* Services, adapters and extensions */
+.cx-settings-capabilities .cap-services-container { gap: 28px; }
+.cx-settings-capabilities .cap-services-block { gap: 12px; }
+.cx-settings-capabilities .cap-services-block > div:first-child { padding-inline: 4px; }
+.cx-settings-capabilities .cap-block-title { gap: 8px; color: var(--cx-fg); font-size: 14px; font-weight: 500; }
+.cx-settings-capabilities .cap-block-title svg { color: var(--cx-fg-3); }
+.cx-settings-capabilities .cap-block-desc { margin-top: 2px; color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.cx-settings-capabilities :is(.cap-services-grid, .cap-adapters-grid, .cap-extensions-grid) { gap: 12px; }
+.cx-settings-capabilities :is(.cap-service-card, .cap-adapter-card, .cap-extension-card) { gap: 12px; padding: 16px 20px; border: 1px solid var(--cx-border); border-radius: 16px; background: var(--cx-elevated); box-shadow: var(--cx-shadow-xs); }
+.cx-settings-capabilities .cap-adapter-card:hover { border-color: var(--cx-border); }
+.cx-settings-capabilities :is(.cap-service-card-brand, .cap-adapter-brand) { min-width: 0; gap: 10px; }
+.cx-settings-capabilities :is(.cap-service-card-icon, .cap-adapter-brand-logo) { width: 32px; height: 32px; border: 0; border-radius: 10px; background: var(--cx-hover); color: var(--cx-fg-2); }
+.cx-settings-capabilities :is(.cap-service-card-title, .cap-adapter-title, .cap-extension-id) { color: var(--cx-fg); font-size: 14px; font-weight: 500; line-height: 1.4; }
+.cx-settings-capabilities .cap-extension-id { overflow-wrap: anywhere; }
+.cx-settings-capabilities :is(.cap-service-card-id, .cap-adapter-id, .cap-extension-sub, .cap-recent-injection-desc) { color: var(--cx-fg-3); font-family: var(--cx-font-mono); font-size: 12px; line-height: 1.5; }
+.cx-settings-capabilities :is(.cap-service-card-props, .cap-adapter-props) { gap: 6px 12px; padding: 12px 14px; border: 0; border-radius: 12px; background: var(--cx-bg-subtle); font-size: 13px; line-height: 1.5; }
+.cx-settings-capabilities .cap-adapter-props { grid-template-columns: 76px minmax(0, 1fr); }
+.cx-settings-capabilities .cap-service-card-props { grid-template-columns: 84px minmax(0, 1fr); }
+.cx-settings-capabilities :is(.cap-service-card-props, .cap-adapter-props) dt { color: var(--cx-fg-3); font-size: 12px; line-height: 20px; }
+.cx-settings-capabilities :is(.cap-service-card-props, .cap-adapter-props) dd { color: var(--cx-fg); font-family: var(--cx-font-sans); font-size: 13px; line-height: 20px; }
+.cx-settings-capabilities .cap-service-card-props dd > code { display: block; overflow: hidden; text-overflow: ellipsis; }
+.cx-settings-capabilities .cap-service-card-actions { flex-wrap: wrap; padding-top: 12px; border-top: 1px solid var(--cx-border-subtle); }
+.cx-settings-capabilities .cap-service-lifecycle-note { color: var(--cx-fg-3); font-size: 12px; }
+.cx-settings-capabilities .cap-injection-matrix { gap: 6px; }
+.cx-settings-capabilities .cap-recent-injection { gap: 4px; padding: 12px 14px; border: 1px solid var(--cx-border-subtle); border-radius: 12px; font-size: 12px; }
+.cx-settings-capabilities .cap-recent-injection-head { color: var(--cx-fg-3); font-size: 12px; }
+.cx-settings-capabilities .cap-recent-injection [style] { color: var(--cx-fg-3) !important; font-size: 12px !important; }
+.cx-settings-capabilities .cap-recent-injection > div[style] { color: var(--cx-fg) !important; font-size: 13px !important; font-weight: 500 !important; }
+.cx-settings-capabilities .cap-adapter-card .cap-disclosure-body > div[style] { padding: 10px 14px !important; border: 0 !important; background: transparent !important; font-size: 12px !important; }
+.cx-settings-capabilities .cap-adapter-card .cap-disclosure-body code[style] { color: var(--cx-fg-2) !important; font-size: 12px !important; }
+.cx-settings-capabilities .cap-adapter-card .cap-disclosure-body span[style] { color: var(--cx-fg-3) !important; font-size: 12px !important; }
+.cx-settings-capabilities .cap-extension-card > div[style] > span[style] { color: var(--cx-fg-3) !important; font-size: 12px !important; font-weight: 400 !important; }
+.cx-settings-capabilities .cap-extension-card > div[style*="var(--red)"] { padding: 8px 12px !important; border-radius: 10px !important; background: var(--cx-danger-soft) !important; color: var(--cx-danger) !important; font-size: 12px !important; }
+.cx-settings-capabilities .cap-extension-secrets { gap: 6px; color: var(--cx-fg-3); font-size: 12px; }
+.cx-settings-capabilities .cap-extension-secrets > code { padding: 2px 4px 2px 10px !important; border: 0 !important; border-radius: 999px !important; background: var(--cx-hover) !important; }
+.cx-settings-capabilities :is(.cap-extension-provides-list, .cap-extension-secrets) > span[style] { color: var(--cx-fg-3) !important; font-size: 12px !important; }
+.cx-settings-capabilities .cap-mode-select { min-width: 150px; }
+.cx-settings-capabilities .cap-mode-select button { height: 34px; min-height: 34px; border: 1px solid var(--cx-border); border-radius: 10px; background: var(--cx-bg); color: var(--cx-fg); font-size: 13px; box-shadow: none; }
+.cx-settings-capabilities .cap-mode-select button:focus-visible { border-color: var(--cx-border-strong); box-shadow: 0 0 0 3px var(--cx-accent-soft); outline: 0; }
+
+/* Empty and loading states */
+.cx-settings-capabilities .cap-empty-state { gap: 8px; border: 1px solid var(--cx-border); border-radius: 16px; background: var(--cx-elevated); color: var(--cx-fg-3); }
+.cx-settings-capabilities .cap-tools-grid .cap-empty-state,
+.cx-settings-capabilities .cap-thread-list .cap-empty-state { border: 0; background: transparent; }
+.cx-settings-capabilities .cap-empty-state strong { color: var(--cx-fg); font-size: 14px; font-weight: 500; }
+.cx-settings-capabilities .cap-empty-state p { color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.cx-settings-capabilities .cap-loading-view { color: var(--cx-fg-3); font-size: 13px; }
+.cx-settings-capabilities .cap-loading-view strong { font-weight: 500; }
+.cx-settings-capabilities .cap-loading-spinner { color: var(--cx-fg-3); }
+
+@container (max-width: 820px) {
+ .cx-settings-capabilities .cap-tab-bar { flex-wrap: wrap; }
+ .cx-settings-capabilities .cap-threads-layout { grid-template-columns: minmax(0, 1fr); }
+ .cx-settings-capabilities .cap-thread-list { max-height: 320px; }
+ .cx-settings-capabilities :is(.cap-services-grid, .cap-extensions-grid) { grid-template-columns: minmax(0, 1fr); }
+ .cx-settings-capabilities .cap-adapters-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+@container (max-width: 620px) {
+ .cx-settings-capabilities .cap-tools-grid { grid-template-columns: minmax(0, 1fr); }
+}
+@container (max-width: 540px) {
+ .cx-settings-capabilities .cap-adapters-grid { grid-template-columns: minmax(0, 1fr); }
+ .cx-settings-capabilities .capability-center > .cap-tab-bar { grid-column: 1 / -1; }
+ .cx-settings-capabilities .capability-center > .cap-topbar { grid-column: 1 / -1; justify-self: start; }
+}
diff --git a/apps/web/ui/app/styles/settings-unified-data.css b/apps/web/ui/app/styles/settings-unified-data.css
new file mode 100644
index 0000000..caf1458
--- /dev/null
+++ b/apps/web/ui/app/styles/settings-unified-data.css
@@ -0,0 +1,31 @@
+/* Archives and history import pages inside the unified settings shell. */
+.cx-settings-data { color: var(--cx-fg); font-family: var(--cx-font-sans); }
+.cx-settings-data .cx-data-toolbar { display: flex; align-items: center; gap: 8px; }
+.cx-settings-data .cx-data-search { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; border: 1px solid var(--cx-border); border-radius: 10px; background: var(--cx-bg); color: var(--cx-fg-3); transition: border-color .15s, box-shadow .15s; }
+.cx-settings-data .cx-data-search:focus-within { border-color: var(--cx-border-strong); box-shadow: 0 0 0 3px var(--cx-accent-soft); }
+.cx-settings-data .cx-data-search input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; color: var(--cx-fg); font: inherit; font-size: 13px; }
+.cx-settings-data .cx-data-search input::placeholder { color: var(--cx-fg-3); }
+.cx-settings-data .cx-data-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; padding: 0 12px; border: 1px solid var(--cx-border); border-radius: 8px; background: var(--cx-elevated); color: var(--cx-fg); font: inherit; font-size: 13px; font-weight: 500; white-space: nowrap; text-decoration: none; cursor: pointer; transition: background-color .15s; }
+.cx-settings-data .cx-data-btn:hover:not(:disabled) { background: var(--cx-hover); }
+.cx-settings-data .cx-data-btn.is-primary { border-color: transparent; background: var(--cx-accent); color: var(--cx-accent-fg); }
+.cx-settings-data .cx-data-btn.is-primary:hover:not(:disabled) { background: var(--cx-accent); filter: brightness(1.08); }
+.cx-settings-data .cx-data-btn:disabled { opacity: .5; cursor: not-allowed; }
+.cx-settings-data .cx-data-btn:focus-visible { outline: 2px solid var(--cx-focus); outline-offset: 2px; }
+.cx-settings-data .cx-data-count { color: var(--cx-fg-3); font-weight: 400; font-variant-numeric: tabular-nums; }
+.cx-settings-data .cx-data-feedback:empty { display: none; }
+.cx-settings-data .cx-data-feedback p { margin: 0; padding: 0 4px; color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.cx-settings-data .cx-data-feedback .is-error { color: var(--cx-danger); }
+.cx-settings-data .cx-archive-list { margin: 0; padding: 0; list-style: none; }
+.cx-settings-data .cx-archive-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 20px; transition: background-color .15s; }
+.cx-settings-data .cx-archive-row + .cx-archive-row { border-top: 1px solid var(--cx-border-subtle); }
+.cx-settings-data .cx-archive-row:hover { background: var(--cx-hover); }
+.cx-settings-data .cx-archive-info { min-width: 0; display: grid; gap: 2px; }
+.cx-settings-data .cx-archive-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--cx-fg); font-size: 14px; font-weight: 500; line-height: 1.45; }
+.cx-settings-data .cx-archive-info span { color: var(--cx-fg-3); font-size: 12px; line-height: 1.5; font-variant-numeric: tabular-nums; }
+.cx-settings-data .cx-archive-info p { overflow: hidden; margin: 2px 0 0; color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; text-overflow: ellipsis; white-space: nowrap; }
+.cx-settings-data .cx-archive-actions { flex: none; display: flex; gap: 8px; }
+.cx-settings-data .cx-import-icon { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; background: var(--cx-hover); color: var(--cx-fg-2); }
+@media (max-width: 640px) {
+ .cx-settings-data .cx-archive-row { flex-direction: column; align-items: stretch; gap: 10px; }
+ .cx-settings-data .cx-archive-actions > * { flex: 1; }
+}
diff --git a/apps/web/ui/app/styles/settings-unified-extensions.css b/apps/web/ui/app/styles/settings-unified-extensions.css
new file mode 100644
index 0000000..41cb3f7
--- /dev/null
+++ b/apps/web/ui/app/styles/settings-unified-extensions.css
@@ -0,0 +1,130 @@
+/* Platform extensions (ExtensionSettings) inside the unified settings shell. */
+.cx-settings-extensions { width: 100%; min-width: 0; color: var(--cx-fg); font-family: var(--cx-font-sans); }
+.cx-settings-extensions .extension-settings { width: 100%; max-width: none; margin: 0; padding: 0; }
+.cx-settings-extensions .cx-ext-section > div:first-child { flex-wrap: wrap; row-gap: 8px; }
+
+.cx-settings-extensions .cx-ext-card { display: grid; gap: 0; padding: 0; }
+.cx-settings-extensions .cx-ext-card > .extension-source-form,
+.cx-settings-extensions .cx-ext-card > .extension-catalog-row { padding: 16px 20px; }
+.cx-settings-extensions .cx-ext-card > .cx-ext-preview { margin: 0 20px 16px; }
+.cx-settings-extensions .cx-ext-footer { padding: 12px 20px; border-top: 1px solid var(--cx-border-subtle); }
+.cx-settings-extensions .cx-ext-list-row { padding: 12px 20px; border-top: 1px solid var(--cx-border-subtle); }
+.cx-settings-extensions .cx-ext-list-row:hover { background: var(--cx-hover); }
+.cx-settings-extensions .cx-ext-list-row > span { font-size: 13px; }
+.cx-settings-extensions .cx-ext-empty { padding: 32px 20px; color: var(--cx-fg-3); font-size: 13px; text-align: center; }
+
+.cx-settings-extensions .cx-ext-code { font-family: var(--cx-font-mono); font-size: 12px; color: var(--cx-fg-2); overflow-wrap: anywhere; }
+.cx-settings-extensions h2 .cx-ext-code { margin-left: 4px; font-weight: 400; }
+
+.cx-settings-extensions .cx-ext-note { padding: 10px 14px; border-radius: 12px; font-size: 13px; line-height: 1.5; overflow-wrap: anywhere; }
+.cx-settings-extensions .cx-ext-note[data-tone="danger"] { color: var(--cx-danger); background: var(--cx-danger-soft); }
+.cx-settings-extensions .cx-ext-note[data-tone="success"] { color: var(--cx-success); background: var(--cx-success-soft); }
+
+/* Fields */
+.cx-settings-extensions .cx-ext-fields { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; }
+.cx-settings-extensions .cx-ext-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; margin: 0; }
+.cx-settings-extensions .cx-ext-field-grow { flex: 1 1 260px; }
+.cx-settings-extensions .cx-ext-field-kind { flex: 0 0 180px; }
+.cx-settings-extensions .cx-ext-field > :is(span, label) { color: var(--cx-fg-2); font-size: 13px; font-weight: 500; line-height: 1.4; margin: 0; }
+.cx-settings-extensions :is(.cx-ext-field, .cx-ext-detail) input:not([type="checkbox"]):not([type="radio"]),
+.cx-settings-extensions .cx-ext-field .select__trigger,
+.cx-settings-extensions .cx-ext-field [role="combobox"] {
+ width: 100%; height: 34px; min-height: 34px; padding: 0 12px;
+ border: 1px solid var(--cx-border); border-radius: 10px; background: var(--cx-bg);
+ color: var(--cx-fg); font-family: var(--cx-font-sans); font-size: 13px; box-shadow: none; outline: none;
+}
+.cx-settings-extensions .cx-ext-field .select { width: 100%; }
+.cx-settings-extensions :is(.cx-ext-field, .cx-ext-detail) input::placeholder { color: var(--cx-fg-3); }
+.cx-settings-extensions :is(.cx-ext-field, .cx-ext-detail) input:not([type="checkbox"]):not([type="radio"]):focus,
+.cx-settings-extensions .cx-ext-field .select__trigger:focus-visible,
+.cx-settings-extensions .cx-ext-field [role="combobox"]:focus-visible { border-color: var(--cx-border-strong); box-shadow: 0 0 0 3px var(--cx-accent-soft); }
+.cx-settings-extensions .cx-ext-field .select__trigger:hover { background: var(--cx-bg); }
+
+/* Buttons */
+.cx-settings-extensions .cx-ext-btn {
+ height: 32px; min-width: 0; padding: 0 12px; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
+ border: 1px solid var(--cx-border); border-radius: 8px; background: var(--cx-elevated); box-shadow: none;
+ color: var(--cx-fg); font-family: var(--cx-font-sans); font-size: 13px; font-weight: 500; white-space: nowrap; cursor: pointer;
+ transition: background-color .12s ease;
+}
+.cx-settings-extensions .cx-ext-btn:hover:not(:disabled, [data-disabled="true"]) { background: var(--cx-hover); }
+.cx-settings-extensions .cx-ext-btn:focus-visible { outline: 2px solid var(--cx-focus); outline-offset: 1px; }
+.cx-settings-extensions .cx-ext-btn.cx-ext-btn-primary { border-color: transparent; background: var(--cx-accent); color: var(--cx-accent-fg); }
+.cx-settings-extensions .cx-ext-btn.cx-ext-btn-primary:hover:not(:disabled, [data-disabled="true"]) { background: var(--cx-accent); opacity: .9; }
+.cx-settings-extensions .cx-ext-btn.cx-ext-btn-danger { color: var(--cx-danger); }
+.cx-settings-extensions .cx-ext-btn.cx-ext-btn-danger:hover:not(:disabled, [data-disabled="true"]) { background: var(--cx-danger-soft); }
+.cx-settings-extensions .cx-ext-btn:is(:disabled, [data-disabled="true"]) { opacity: .5; cursor: not-allowed; }
+
+/* Chips */
+.cx-settings-extensions .cx-ext-chip { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border: 0; border-radius: 999px; background: var(--cx-hover); color: var(--cx-fg-2); font-size: 12px; font-weight: 500; line-height: 1; white-space: nowrap; }
+.cx-settings-extensions .cx-ext-chip[data-tone="success"] { background: var(--cx-success-soft); color: var(--cx-success); }
+.cx-settings-extensions .cx-ext-chip[data-tone="warning"] { background: var(--cx-warning-soft); color: var(--cx-warning); }
+.cx-settings-extensions .cx-ext-chip[data-tone="danger"] { background: var(--cx-danger-soft); color: var(--cx-danger); }
+
+/* Installed plugin rows */
+.cx-settings-extensions .cx-ext-plugin { display: grid; gap: 8px; padding: 16px 20px; min-width: 0; }
+.cx-settings-extensions .cx-ext-plugin + .cx-ext-plugin { border-top: 1px solid var(--cx-border-subtle); }
+.cx-settings-extensions .cx-ext-plugin[data-selected] { background: var(--cx-bg-subtle); }
+.cx-settings-extensions .cx-ext-plugin-head { row-gap: 6px; }
+.cx-settings-extensions .cx-ext-plugin-name { margin-right: 4px; color: var(--cx-fg); font-size: 14px; font-weight: 500; line-height: 1.4; overflow-wrap: anywhere; }
+.cx-settings-extensions .cx-ext-meta > span { min-width: 0; overflow-wrap: anywhere; }
+.cx-settings-extensions .cx-ext-plugin-actions { padding-top: 4px; }
+
+.cx-settings-extensions .cx-ext-upgrade,
+.cx-settings-extensions .cx-ext-preview { display: grid; gap: 10px; padding: 14px 16px; border: 1px solid var(--cx-border-subtle); border-radius: 12px; background: var(--cx-bg-subtle); min-width: 0; }
+.cx-settings-extensions .cx-ext-upgrade > .extension-source-form { padding: 0; }
+.cx-settings-extensions .cx-ext-upgrade .cx-ext-field :is(input, .select__trigger, [role="combobox"]) { background: var(--cx-elevated); }
+.cx-settings-extensions .extension-preview-grid { font-size: 13px; color: var(--cx-fg); }
+.cx-settings-extensions .extension-preview-grid dt { color: var(--cx-fg-3); font-size: 12px; }
+.cx-settings-extensions .action-dialog-option { min-height: 0; padding: 10px 12px; border: 1px solid var(--cx-border-subtle); border-radius: 10px; background: var(--cx-elevated); color: var(--cx-fg); font-size: 13px; }
+
+/* Detail panel */
+.cx-settings-extensions .cx-ext-detail { display: grid; gap: 16px; padding: 16px 20px; min-width: 0; }
+.cx-settings-extensions .cx-ext-detail > div { min-width: 0; }
+.cx-settings-extensions .cx-ext-log { margin: 0; padding: 12px 14px; max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-all; border: 1px solid var(--cx-border-subtle); border-radius: 12px; background: var(--cx-bg-subtle); line-height: 1.55; }
+
+@media (max-width: 640px) {
+ .cx-settings-extensions .cx-ext-fields { display: flex !important; flex-direction: column; align-items: stretch !important; gap: 12px !important; }
+ .cx-settings-extensions .cx-ext-field { width: 100% !important; flex: 1 1 auto; }
+}
+
+.cx-settings-extensions .cx-ext-field .select__trigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.cx-settings-extensions .cx-ext-detail-label { color: var(--cx-fg-2); font-size: 13px; font-weight: 500; line-height: 1.4; }
+
+/* Declarative UI contributions (components/ui-contributions). */
+.cx-uic-muted { margin: 0; color: var(--cx-fg-3); font-size: 12px; line-height: 1.5; }
+.cx-uic-label { color: var(--cx-fg-3); font-size: 12px; }
+.cx-uic-chip { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: 999px; background: var(--cx-hover); color: var(--cx-fg-2); font-size: 12px; font-weight: 500; line-height: 1; white-space: nowrap; }
+.cx-uic-chip[data-tone="success"] { background: var(--cx-success-soft); color: var(--cx-success); }
+.cx-uic-chip[data-tone="warning"] { background: var(--cx-warning-soft); color: var(--cx-warning); }
+.cx-uic-chip[data-tone="danger"] { background: var(--cx-danger-soft); color: var(--cx-danger); }
+.cx-uic-nav { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.cx-uic-nav-link { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; border: 1px solid var(--cx-border); border-radius: 8px; color: var(--cx-fg); font-size: 13px; text-decoration: none; transition: background-color .12s ease; }
+.cx-uic-nav-link:hover { background: var(--cx-hover); }
+.cx-uic-nav-link code { color: var(--cx-fg-3); font-family: var(--cx-font-mono); font-size: 12px; }
+.cx-uic-block { display: grid; gap: 10px; min-width: 0; padding: 14px 16px; border: 1px solid var(--cx-border-subtle); border-radius: 12px; background: var(--cx-bg-subtle); }
+.cx-uic-block-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; min-width: 0; }
+.cx-uic-block-head strong { color: var(--cx-fg); font-size: 14px; font-weight: 500; }
+.cx-uic-block-head code, .cx-uic-strong-code { color: var(--cx-fg-3); font-family: var(--cx-font-mono); font-size: 12px; overflow-wrap: anywhere; }
+.cx-uic-strong-code { color: var(--cx-fg); }
+.cx-uic-pre { margin: 0; padding: 12px 14px; max-height: 320px; overflow: auto; border: 1px solid var(--cx-border-subtle); border-radius: 10px; background: var(--cx-elevated); color: var(--cx-fg-2); font-family: var(--cx-font-mono); font-size: 12px; line-height: 1.55; white-space: pre-wrap; word-break: break-all; }
+.cx-uic-frame { width: 100%; min-height: 120px; border: 1px solid var(--cx-border-subtle); border-radius: 10px; background: var(--cx-elevated); }
+.cx-uic-board { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; }
+.cx-uic-board-col { display: grid; flex: 1 1 200px; align-content: start; gap: 8px; min-width: 0; padding: 12px; border: 1px solid var(--cx-border-subtle); border-radius: 12px; background: var(--cx-bg-subtle); }
+.cx-uic-board-card { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--cx-border-subtle); border-radius: 10px; background: var(--cx-elevated); }
+.cx-uic-form { display: grid; gap: 12px; min-width: 0; }
+.cx-uic-fields { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); gap: 12px; }
+.cx-uic-field { display: grid; align-content: start; gap: 6px; min-width: 0; }
+.cx-uic-field.is-check { align-content: end; min-height: 34px; color: var(--cx-fg); font-size: 13px; }
+.cx-uic-field-label { color: var(--cx-fg-2); font-size: 13px; font-weight: 500; line-height: 1.4; overflow-wrap: anywhere; }
+.cx-uic-field :is(input[data-slot="input"], input.input, .select__trigger) { width: 100%; height: 34px; min-height: 34px; padding: 0 12px; border: 1px solid var(--cx-border); border-radius: 10px; background: var(--cx-elevated); color: var(--cx-fg); font-family: var(--cx-font-sans); font-size: 13px; box-shadow: none; outline: 0; }
+.cx-uic-field .select { width: 100%; }
+.cx-uic-field .select__trigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.cx-uic-field input::placeholder { color: var(--cx-fg-4); }
+.cx-uic-field :is(input[data-slot="input"], input.input, .select__trigger):is(:focus, :focus-visible, [data-focus-visible="true"]) { border-color: var(--cx-border-strong); box-shadow: 0 0 0 3px var(--cx-accent-soft); }
+.cx-uic-actions { display: flex; justify-content: flex-end; }
+.cx-uic-submit.button { height: 32px; min-height: 32px; padding: 0 12px; border-radius: 8px; --button-bg: var(--cx-accent); --button-bg-hover: color-mix(in srgb, var(--cx-accent) 88%, var(--cx-fg)); --button-bg-pressed: var(--button-bg-hover); --button-fg: var(--cx-accent-fg); font-size: 13px; font-weight: 500; }
+.cx-uic-submit.button:is(:disabled, [data-disabled="true"]) { opacity: .5; }
+.cx-settings-extensions .cx-ext-preview .checkbox { width: 100%; }
+.cx-settings-extensions .cx-ext-preview .action-dialog-option { width: 100%; display: flex; align-items: center; gap: 10px; }
+.cx-settings-extensions .cx-ext-preview .checkbox[data-selected="true"] .action-dialog-option { border-color: var(--cx-border-strong); background: var(--cx-selected); }
diff --git a/apps/web/ui/app/styles/settings-unified-operations.css b/apps/web/ui/app/styles/settings-unified-operations.css
new file mode 100644
index 0000000..28b4a39
--- /dev/null
+++ b/apps/web/ui/app/styles/settings-unified-operations.css
@@ -0,0 +1,450 @@
+/* Operations (OperationsSettings) inside the unified settings shell. */
+.cx-settings-operations { container-type: inline-size; }
+.cx-settings-operations .operations-settings {
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ gap: 28px;
+ color: var(--cx-fg);
+ font-family: var(--cx-font-sans);
+}
+.cx-settings-operations .operations-settings :where(button, input, select, textarea) { font-family: var(--cx-font-sans); }
+
+/* Buttons */
+.cx-settings-operations .operations-settings button {
+ min-height: 32px;
+ height: 32px;
+ padding: 0 12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ border: 1px solid var(--cx-border);
+ border-radius: 8px;
+ background: var(--cx-elevated);
+ color: var(--cx-fg);
+ font-size: 13px;
+ font-weight: 500;
+ box-shadow: none;
+ transition: background-color .15s ease;
+}
+.cx-settings-operations .operations-settings button:hover:not(:disabled) { background: var(--cx-hover); }
+.cx-settings-operations .operations-settings button:focus-visible { outline: 2px solid var(--cx-focus); outline-offset: 2px; }
+.cx-settings-operations .operations-settings button:disabled,
+.cx-settings-operations .operations-settings button[data-disabled="true"] { opacity: .5; }
+.cx-settings-operations .operations-settings button.primary {
+ border-color: transparent;
+ background: var(--cx-accent);
+ color: var(--cx-accent-fg);
+}
+.cx-settings-operations .operations-settings button.primary:hover:not(:disabled) { background: var(--cx-accent); opacity: .9; }
+.cx-settings-operations .operations-settings button.danger {
+ border-color: var(--cx-border);
+ background: var(--cx-elevated);
+ color: var(--cx-danger);
+}
+.cx-settings-operations .operations-settings button.danger:hover:not(:disabled) { background: var(--cx-danger-soft); }
+
+/* Toolbar */
+.cx-settings-operations .operations-settings-toolbar { width: 100%; margin: -8px 0 -12px; flex-wrap: wrap; }
+.cx-settings-operations .operations-updated { color: var(--cx-fg-3); font-size: 12px; font-variant-numeric: tabular-nums; }
+
+.cx-settings-operations .operations-loading { min-height: 30vh; color: var(--cx-fg-3); }
+.cx-settings-operations .operations-loading strong { font-size: 13px; font-weight: 400; }
+
+.cx-settings-operations .operations-feedback {
+ padding: 10px 14px;
+ align-items: center;
+ border: 1px solid var(--cx-border);
+ border-radius: 12px;
+ background: var(--cx-elevated);
+ color: var(--cx-fg-2);
+ font-size: 13px;
+ line-height: 1.5;
+}
+.cx-settings-operations .operations-feedback.error { border-color: transparent; background: var(--cx-danger-soft); color: var(--cx-danger); }
+.cx-settings-operations .operations-feedback.success { border-color: transparent; background: var(--cx-success-soft); color: var(--cx-success); }
+
+/* Status strip */
+.cx-settings-operations .operations-summary {
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ border: 1px solid var(--cx-border);
+ border-radius: 16px;
+ background: var(--cx-elevated);
+ box-shadow: var(--cx-shadow-xs);
+}
+.cx-settings-operations .operations-summary > div {
+ padding: 14px 18px;
+ gap: 4px;
+ align-content: start;
+ border: 0;
+ border-right: 1px solid var(--cx-border-subtle);
+}
+.cx-settings-operations .operations-summary > div:last-child { border-right: 0; }
+.cx-settings-operations .operations-summary span { color: var(--cx-fg-3); font-size: 12px; }
+.cx-settings-operations .operations-summary strong {
+ color: var(--cx-fg);
+ font: 600 20px/1.3 var(--cx-font-sans);
+ font-variant-numeric: tabular-nums;
+}
+.cx-settings-operations .operations-summary strong:is(.ok, .warn, .bad, .muted) { color: var(--cx-fg); }
+.cx-settings-operations .operations-summary > div:nth-child(-n+2) strong.ok { color: var(--cx-success); }
+.cx-settings-operations .operations-summary > div:nth-child(-n+2) strong.warn { color: var(--cx-warning); }
+.cx-settings-operations .operations-summary > div:nth-child(-n+2) strong.bad { color: var(--cx-danger); }
+.cx-settings-operations .operations-summary small { color: var(--cx-fg-3); font-size: 12px; line-height: 1.45; }
+
+/* Instance key/value card */
+.cx-settings-operations .operations-instance {
+ margin-top: -16px;
+ gap: 0;
+ grid-template-columns: minmax(0, 1.2fr) minmax(0, .6fr) minmax(0, .7fr) minmax(0, 1.5fr);
+ border: 1px solid var(--cx-border);
+ border-radius: 16px;
+ background: var(--cx-elevated);
+ box-shadow: var(--cx-shadow-xs);
+ overflow: hidden;
+}
+.cx-settings-operations .operations-instance > div {
+ padding: 12px 18px;
+ gap: 4px;
+ border: 0;
+ border-right: 1px solid var(--cx-border-subtle);
+ border-radius: 0;
+ background: transparent;
+}
+.cx-settings-operations .operations-instance > div:last-child { border-right: 0; }
+.cx-settings-operations .operations-instance span { color: var(--cx-fg-3); font-size: 12px; }
+.cx-settings-operations .operations-instance strong {
+ color: var(--cx-fg-2);
+ font: 400 12px/1.5 var(--cx-font-mono);
+ white-space: nowrap;
+}
+
+/* Sections: title above card */
+.cx-settings-operations .operations-split { gap: 20px; align-items: stretch; }
+.cx-settings-operations .operations-section {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ overflow: visible;
+}
+.cx-settings-operations .operations-section > header {
+ min-height: 0;
+ padding: 0 4px;
+ display: block;
+ border: 0;
+ background: transparent;
+}
+.cx-settings-operations .operations-section > header > div { display: block; }
+.cx-settings-operations .operations-section h2 { color: var(--cx-fg); font-size: 14px; font-weight: 500; line-height: 20px; }
+.cx-settings-operations .operations-section > header p {
+ margin-top: 2px;
+ max-width: none;
+ color: var(--cx-fg-3);
+ font-size: 13px;
+ line-height: 1.5;
+ text-align: left;
+}
+.cx-settings-operations .operations-section-body {
+ flex: 1 1 auto;
+ min-width: 0;
+ border: 1px solid var(--cx-border);
+ border-radius: 16px;
+ background: var(--cx-elevated);
+ box-shadow: var(--cx-shadow-xs);
+ overflow: hidden;
+}
+
+/* Empty states */
+.cx-settings-operations .operations-empty { min-height: 150px; padding: 24px 20px; gap: 4px; }
+.cx-settings-operations .operations-empty.compact { min-height: 112px; }
+.cx-settings-operations .operations-empty strong { color: var(--cx-fg); font-size: 14px; font-weight: 500; }
+.cx-settings-operations :is(.operations-empty span, .operations-empty-copy) { color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.cx-settings-operations .operations-empty-copy { padding: 0; }
+.cx-settings-operations .operations-muted { color: var(--cx-fg-3); font-size: 12px; }
+
+/* Chips */
+.cx-settings-operations .operations-pill {
+ height: 20px;
+ padding: 0 8px;
+ gap: 0;
+ border-radius: 999px;
+ background: var(--cx-hover);
+ color: var(--cx-fg-2);
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 20px;
+}
+.cx-settings-operations .operations-pill i { display: none; }
+.cx-settings-operations .operations-pill.ok { background: var(--cx-success-soft); color: var(--cx-success); }
+.cx-settings-operations .operations-pill.warn { background: var(--cx-warning-soft); color: var(--cx-warning); }
+.cx-settings-operations .operations-pill.bad { background: var(--cx-danger-soft); color: var(--cx-danger); }
+.cx-settings-operations .operations-pill.muted { background: var(--cx-hover); color: var(--cx-fg-2); }
+
+/* Alerts as list rows */
+.cx-settings-operations .operations-alert-list { padding: 0; gap: 0; }
+.cx-settings-operations .operations-alert,
+.cx-settings-operations .operations-alert:is(.warning, .critical) {
+ padding: 12px 20px;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+.cx-settings-operations .operations-alert + .operations-alert { border-top: 1px solid var(--cx-border-subtle); }
+.cx-settings-operations .operations-alert:hover { background: var(--cx-hover); }
+.cx-settings-operations .operations-alert > .operations-pill { grid-column: 2; grid-row: 1; margin-top: 1px; }
+.cx-settings-operations .operations-alert > div { grid-column: 1; grid-row: 1; min-width: 0; }
+.cx-settings-operations .operations-alert strong {
+ color: var(--cx-fg);
+ font: 500 13px/1.5 var(--cx-font-mono);
+}
+.cx-settings-operations .operations-alert div > span { display: block; margin-top: 2px; color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+
+/* Receipt query */
+.cx-settings-operations .operations-receipt-form {
+ padding: 16px 20px;
+ gap: 8px;
+ border-bottom: 1px solid var(--cx-border-subtle);
+}
+.cx-settings-operations .operations-receipt-form label { gap: 6px; }
+.cx-settings-operations .operations-receipt-form label > span { color: var(--cx-fg-2); font-size: 13px; font-weight: 500; }
+.cx-settings-operations .operations-receipt-form input {
+ min-height: 34px;
+ height: 34px;
+ padding: 0 10px;
+ border: 1px solid var(--cx-border);
+ border-radius: 10px;
+ background: var(--cx-bg);
+ color: var(--cx-fg);
+ font-size: 13px;
+ outline: none;
+ transition: border-color .15s ease, box-shadow .15s ease;
+}
+.cx-settings-operations .operations-receipt-form input::placeholder { color: var(--cx-fg-3); }
+.cx-settings-operations .operations-receipt-form input:focus {
+ border-color: var(--cx-border-strong);
+ box-shadow: 0 0 0 3px var(--cx-accent-soft);
+}
+.cx-settings-operations .operations-receipt-form button { height: 34px; min-height: 34px; }
+.cx-settings-operations .operations-section-body > .operations-empty.compact:last-child { min-height: 120px; }
+.cx-settings-operations .operations-receipt-result { padding: 16px 20px; gap: 14px; }
+.cx-settings-operations .operations-receipt-head strong { color: var(--cx-fg); font: 500 13px/1.4 var(--cx-font-mono); }
+.cx-settings-operations .operations-receipt-head > span { color: var(--cx-fg-3); font-size: 12px; }
+
+/* Key/value lists */
+.cx-settings-operations :is(.operations-fields, .operations-stats) { gap: 0; }
+.cx-settings-operations :is(.operations-fields, .operations-stats) > div {
+ padding: 6px 0;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: baseline;
+ gap: 12px;
+}
+.cx-settings-operations :is(.operations-fields, .operations-stats) > div + div { border-top: 1px solid var(--cx-border-subtle); }
+.cx-settings-operations :is(.operations-fields, .operations-stats) dt {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--cx-fg-2);
+ font: 12px/1.5 var(--cx-font-mono);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.cx-settings-operations :is(.operations-fields, .operations-stats) dd {
+ color: var(--cx-fg);
+ font: 500 13px/1.5 var(--cx-font-sans);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+.cx-settings-operations .operations-fields > div { grid-template-columns: minmax(110px, 160px) minmax(0, 1fr); }
+.cx-settings-operations .operations-fields dd { text-align: left; color: var(--cx-fg-2); font: 12px/1.5 var(--cx-font-mono); }
+
+.cx-settings-operations :is(.operations-sublist h3, .operations-metric-grid h3, .operations-recovery-steps h3) {
+ color: var(--cx-fg);
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 20px;
+}
+.cx-settings-operations .operations-sublist { gap: 8px; }
+.cx-settings-operations .operations-sublist h3 { font-size: 13px; }
+.cx-settings-operations .operations-sublist article {
+ padding: 10px 12px;
+ border: 1px solid var(--cx-border-subtle);
+ border-radius: 12px;
+ background: var(--cx-bg-subtle);
+}
+.cx-settings-operations .operations-sublist article strong { color: var(--cx-fg); font-size: 13px; font-weight: 500; }
+.cx-settings-operations .operations-sublist article > span { color: var(--cx-fg-3); font: 12px/1.5 var(--cx-font-mono); }
+
+/* Metrics: columns inside one card */
+.cx-settings-operations .operations-metric-grid { padding: 0; gap: 0; }
+.cx-settings-operations .operations-metric-grid > article {
+ padding: 16px 20px;
+ gap: 8px;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ border-left: 1px solid var(--cx-border-subtle);
+}
+.cx-settings-operations .operations-metric-grid > article:first-child { border-left: 0; }
+.cx-settings-operations .operations-metric-grid h3 { margin-bottom: 2px; }
+.cx-settings-operations .operations-metric-grid h4 {
+ margin: 8px 0 0;
+ color: var(--cx-fg-3);
+ font: 500 12px/1.5 var(--cx-font-mono);
+}
+.cx-settings-operations .operations-chips { gap: 6px; }
+.cx-settings-operations .operations-chips span {
+ height: 20px;
+ padding: 0 8px;
+ gap: 6px;
+ border: 0;
+ border-radius: 999px;
+ background: var(--cx-hover);
+ color: var(--cx-fg-2);
+ font-size: 12px;
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+}
+.cx-settings-operations .operations-chips code { color: var(--cx-fg-3); font: 400 12px/1 var(--cx-font-mono); }
+
+/* Recovery */
+.cx-settings-operations .operations-metric-grid.recovery { border-bottom: 1px solid var(--cx-border-subtle); }
+.cx-settings-operations .operations-recovery-steps { padding: 16px 20px; gap: 8px; }
+.cx-settings-operations .operations-recovery-head { gap: 10px; }
+.cx-settings-operations .operations-recovery-head h3 { font-family: var(--cx-font-mono); font-size: 12px; color: var(--cx-fg-2); }
+.cx-settings-operations .operations-recovery-head > span { color: var(--cx-fg-3); font-size: 12px; font-variant-numeric: tabular-nums; }
+.cx-settings-operations .operations-recovery-steps > article {
+ padding: 10px 12px;
+ gap: 4px;
+ border: 1px solid var(--cx-border-subtle);
+ border-radius: 12px;
+ background: var(--cx-bg-subtle);
+}
+.cx-settings-operations .operations-recovery-steps article strong { color: var(--cx-fg); font: 500 12px/1.5 var(--cx-font-mono); }
+.cx-settings-operations .operations-recovery-steps article em {
+ height: 20px;
+ padding: 0 8px;
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+ background: var(--cx-hover);
+ color: var(--cx-fg-2);
+ font-size: 12px;
+ font-weight: 500;
+}
+.cx-settings-operations .operations-recovery-steps article p { color: var(--cx-fg-2); font-size: 13px; line-height: 1.5; }
+.cx-settings-operations .operations-recovery-steps article small { color: var(--cx-fg-3); font-size: 12px; line-height: 1.5; }
+
+/* Outbox table */
+.cx-settings-operations .operations-table-wrap { max-height: 480px; overscroll-behavior: contain; }
+.cx-settings-operations .operations-table-wrap:focus-visible { outline: 2px solid var(--cx-focus); outline-offset: -2px; }
+.cx-settings-operations .operations-settings table { min-width: 900px; }
+.cx-settings-operations .operations-settings :is(th, td) {
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--cx-border-subtle);
+ color: var(--cx-fg-2);
+ font-size: 13px;
+ line-height: 1.5;
+}
+.cx-settings-operations .operations-settings :is(th, td):first-child { padding-left: 20px; }
+.cx-settings-operations .operations-settings :is(th, td):last-child { padding-right: 20px; }
+.cx-settings-operations .operations-settings td:not(:last-child) { white-space: nowrap; }
+.cx-settings-operations .operations-settings td:last-child { min-width: 180px; }
+.cx-settings-operations .operations-settings tbody tr:last-child td { border-bottom: 0; }
+.cx-settings-operations .operations-settings tbody tr:hover td { background: var(--cx-hover); }
+.cx-settings-operations .operations-settings th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: var(--cx-elevated);
+ color: var(--cx-fg-3);
+ font-size: 12px;
+ font-weight: 500;
+ white-space: nowrap;
+}
+.cx-settings-operations .operations-settings td code { color: var(--cx-fg-2); font: 12px/1.5 var(--cx-font-mono); white-space: nowrap; }
+
+/* Modules */
+.cx-settings-operations .operations-module-list { padding: 0; gap: 0; }
+.cx-settings-operations .operations-module-row {
+ min-height: 0;
+ padding: 14px 20px;
+ gap: 16px;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+.cx-settings-operations .operations-module-row + .operations-module-row { border-top: 1px solid var(--cx-border-subtle); }
+.cx-settings-operations .operations-module-row:hover { background: var(--cx-hover); }
+.cx-settings-operations .operations-module-row > div { gap: 2px; }
+.cx-settings-operations .operations-module-row strong { color: var(--cx-fg); font-size: 14px; font-weight: 500; }
+.cx-settings-operations .operations-module-row small { color: var(--cx-fg-3); font: 12px/1.5 var(--cx-font-mono); }
+.cx-settings-operations .operations-module-row em { color: var(--cx-danger); font-size: 12px; line-height: 1.5; }
+.cx-settings-operations .operations-module-state {
+ height: 20px;
+ padding: 0 8px;
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+ background: var(--cx-success-soft);
+ color: var(--cx-success);
+ font-size: 12px;
+ font-weight: 500;
+}
+.cx-settings-operations .operations-module-state.registered { background: var(--cx-warning-soft); color: var(--cx-warning); }
+.cx-settings-operations .operations-module-state.disabled { background: var(--cx-hover); color: var(--cx-fg-2); }
+.cx-settings-operations .operations-module-state.unavailable { background: var(--cx-danger-soft); color: var(--cx-danger); }
+
+/* Maintenance */
+.cx-settings-operations .operations-candidate-list { padding: 0; gap: 0; }
+.cx-settings-operations .operations-candidate-list li {
+ padding: 12px 20px;
+ gap: 12px;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+.cx-settings-operations .operations-candidate-list li + li { border-top: 1px solid var(--cx-border-subtle); }
+.cx-settings-operations .operations-candidate-list strong { color: var(--cx-fg); font-size: 13px; font-weight: 500; }
+.cx-settings-operations .operations-candidate-list code { color: var(--cx-fg-2); font: 12px/1.5 var(--cx-font-mono); }
+.cx-settings-operations .operations-candidate-list span { color: var(--cx-fg-3); font-size: 12px; font-variant-numeric: tabular-nums; }
+.cx-settings-operations .operations-maintenance footer {
+ padding: 12px 20px;
+ display: flex;
+ justify-content: flex-end;
+ border-top: 1px solid var(--cx-border-subtle);
+}
+
+@container (max-width: 1000px) {
+ .cx-settings-operations .operations-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .cx-settings-operations .operations-summary > div:nth-child(3n) { border-right: 0; }
+ .cx-settings-operations .operations-summary > div:nth-child(n+4) { border-top: 1px solid var(--cx-border-subtle); }
+ .cx-settings-operations .operations-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .cx-settings-operations .operations-metric-grid > article:nth-child(odd) { border-left: 0; }
+ .cx-settings-operations .operations-metric-grid > article:nth-child(n+3) { border-top: 1px solid var(--cx-border-subtle); }
+ .cx-settings-operations .operations-instance { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .cx-settings-operations .operations-instance > div:nth-child(2n) { border-right: 0; }
+ .cx-settings-operations .operations-instance > div:nth-child(n+3) { border-top: 1px solid var(--cx-border-subtle); }
+}
+@container (max-width: 720px) {
+ .cx-settings-operations .operations-split,
+ .cx-settings-operations .operations-metric-grid.recovery { grid-template-columns: 1fr; }
+ .cx-settings-operations .operations-metric-grid.recovery > article { border-left: 0; }
+ .cx-settings-operations .operations-metric-grid.recovery > article + article { border-top: 1px solid var(--cx-border-subtle); }
+}
+@container (max-width: 560px) {
+ .cx-settings-operations .operations-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .cx-settings-operations .operations-summary > div { border-right: 1px solid var(--cx-border-subtle); border-top: 0; }
+ .cx-settings-operations .operations-summary > div:nth-child(2n) { border-right: 0; }
+ .cx-settings-operations .operations-summary > div:nth-child(n+3) { border-top: 1px solid var(--cx-border-subtle); }
+ .cx-settings-operations .operations-metric-grid { grid-template-columns: 1fr; }
+ .cx-settings-operations .operations-metric-grid > article { border-left: 0; }
+ .cx-settings-operations .operations-metric-grid > article + article { border-top: 1px solid var(--cx-border-subtle); }
+ .cx-settings-operations .operations-instance { grid-template-columns: 1fr; }
+ .cx-settings-operations .operations-instance > div { border-right: 0; }
+ .cx-settings-operations .operations-instance > div + div { border-top: 1px solid var(--cx-border-subtle); }
+}
diff --git a/apps/web/ui/app/styles/settings-unified.css b/apps/web/ui/app/styles/settings-unified.css
new file mode 100644
index 0000000..969c481
--- /dev/null
+++ b/apps/web/ui/app/styles/settings-unified.css
@@ -0,0 +1,65 @@
+/* ─────────────────────────────────────────────────────────
+ * Unified settings (web + desktop): shell, category column, cards, and
+ * restyles for shared managers so every settings page reads the same.
+ * ───────────────────────────────────────────────────────── */
+.cx-settings-card { overflow: hidden; border: 1px solid var(--cx-border); border-radius: 16px; background: var(--cx-elevated); box-shadow: var(--cx-shadow-xs); }
+/* Settings: a quiet category column and one scrolling page, like the chat sidebar + canvas. */
+.cx-settings-shell { display: grid; grid-template-columns: 272px minmax(0, 1fr); height: 100%; min-height: 0; overflow: hidden; background: var(--cx-bg); }
+.cx-settings-nav { display: flex; flex-direction: column; min-height: 0; background: var(--cx-sidebar, var(--cx-bg)); box-shadow: inset -1px 0 0 var(--cx-border-subtle); }
+.cx-settings-content { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
+.cx-settings-search { display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 12px; border-radius: 999px; background: var(--cx-hover); color: var(--cx-fg-3); transition: background-color .15s, box-shadow .15s; }
+.cx-settings-search:focus-within { background: var(--cx-bg); box-shadow: 0 0 0 1px var(--cx-border-strong); }
+.cx-settings-search input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; color: var(--cx-fg); font: inherit; font-size: 14px; }
+.cx-settings-search input::placeholder { color: var(--cx-fg-3); }
+.cx-settings-search input::-webkit-search-cancel-button { display: none; }
+@media (max-width: 960px) { .cx-settings-shell { grid-template-columns: 224px minmax(0, 1fr); } }
+@media (max-width: 680px) {
+ .cx-settings-shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
+ .cx-settings-nav { box-shadow: inset 0 -1px 0 var(--cx-border-subtle); max-height: 46vh; }
+}
+.cx-hue-slider .cx-slider::-webkit-slider-runnable-track { height: 8px; background: linear-gradient(to right, hsl(0 80% 55%), hsl(60 80% 50%), hsl(120 65% 45%), hsl(180 70% 45%), hsl(240 75% 60%), hsl(300 70% 55%), hsl(360 80% 55%)); }
+.cx-hue-slider .cx-slider::-moz-range-track { height: 8px; background: linear-gradient(to right, hsl(0 80% 55%), hsl(60 80% 50%), hsl(120 65% 45%), hsl(180 70% 45%), hsl(240 75% 60%), hsl(300 70% 55%), hsl(360 80% 55%)); }
+.cx-hue-slider .cx-slider::-webkit-slider-thumb { margin-top: -4px; background: #fff; box-shadow: 0 0 0 1px rgb(0 0 0 / .18), 0 1px 3px rgb(0 0 0 / .25); }
+
+.cx-settings-time { height: 32px; padding: 0 10px; border: 1px solid var(--cx-border); border-radius: 8px; background: var(--cx-bg); color: var(--cx-fg); font: inherit; font-size: 13px; font-variant-numeric: tabular-nums; }
+.cx-settings-time:disabled { opacity: .5; }
+.cx-settings-time:focus-visible { outline: 2px solid var(--cx-focus); outline-offset: 1px; }
+
+.cx-settings-web { height: 100dvh; }
+
+/* Dialogs opened from settings pages render in a portal, so they carry their own scope class. */
+.cx-settings-dialog.modal__dialog { width: min(480px, calc(100vw - 32px)); max-width: calc(100vw - 32px); padding: 0; gap: 0; overflow: hidden; border: 1px solid var(--cx-border); border-radius: 16px; background: var(--cx-elevated); box-shadow: var(--cx-shadow-pop); color: var(--cx-fg); font-family: var(--cx-font-sans); }
+.cx-settings-dialog.modal__dialog--lg { width: min(560px, calc(100vw - 32px)); }
+.cx-settings-dialog .modal__header { gap: 4px; padding: 20px 20px 12px; }
+.cx-settings-dialog .modal__heading { color: var(--cx-fg); font-size: 16px; font-weight: 600; line-height: 24px; overflow-wrap: anywhere; }
+.cx-settings-dialog .modal__header small { color: var(--cx-fg-3); font-size: 13px; line-height: 1.5; }
+.cx-settings-dialog .modal__body { margin: 0; padding: 4px 20px 18px; color: var(--cx-fg-2); font-size: 13px; line-height: 1.6; }
+.cx-settings-dialog .modal__body > p + p { margin-top: 8px; }
+.cx-settings-dialog .modal__footer { gap: 8px; padding: 12px 20px; border-top: 1px solid var(--cx-border-subtle); background: var(--cx-bg-subtle); }
+.cx-settings-dialog .modal__footer .button { height: 32px; min-height: 32px; padding: 0 12px; border-radius: 8px; font-size: 13px; font-weight: 500; }
+.cx-settings-dialog .modal__footer .button--ghost { --button-bg: var(--cx-elevated); --button-bg-hover: var(--cx-hover); --button-bg-pressed: var(--cx-active); --button-fg: var(--cx-fg); box-shadow: inset 0 0 0 1px var(--cx-border); }
+.cx-settings-dialog .modal__footer .button--primary { --button-bg: var(--cx-accent); --button-bg-hover: color-mix(in srgb, var(--cx-accent) 88%, var(--cx-fg)); --button-bg-pressed: var(--button-bg-hover); --button-fg: var(--cx-accent-fg); }
+.cx-settings-dialog .modal__footer .button--danger { --button-bg: var(--cx-danger); --button-bg-hover: color-mix(in srgb, var(--cx-danger) 86%, black); --button-bg-pressed: var(--button-bg-hover); --button-fg: #fff; }
+.cx-settings-dialog .modal__footer .button:is(:disabled, [data-disabled="true"]) { opacity: .5; }
+.cx-settings-dialog .cx-settings-dialog-error { margin: 0; padding: 10px 12px; border: 0; border-radius: 10px; background: var(--cx-danger-soft); color: var(--cx-danger); font-size: 13px; line-height: 1.5; }
+.cx-settings-dialog .action-dialog-options { display: grid; gap: 8px; }
+.cx-settings-dialog .action-dialog-summary { display: grid; gap: 2px; padding: 12px 14px; border: 1px solid var(--cx-border-subtle); border-radius: 12px; background: var(--cx-bg-subtle); }
+.cx-settings-dialog .action-dialog-summary strong { color: var(--cx-fg); font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }
+.cx-settings-dialog .action-dialog-summary span { color: var(--cx-fg-3); font-family: var(--cx-font-sans); font-size: 12px; overflow-wrap: anywhere; }
+.cx-settings-dialog .action-dialog-option { min-height: 0; padding: 12px 14px; border: 1px solid var(--cx-border); border-radius: 12px; background: var(--cx-elevated); color: var(--cx-fg); }
+.cx-settings-dialog .action-dialog-option:is(:has(input:checked), [data-selected="true"]) { border-color: var(--cx-border-strong); background: var(--cx-selected); }
+.cx-settings-dialog .action-dialog-option strong { font-size: 13px; font-weight: 500; }
+.cx-settings-dialog .action-dialog-option small { color: var(--cx-fg-3); font-size: 12px; line-height: 1.5; }
+.cx-settings-dialog .action-dialog-option .checkbox__content > span:has(> strong) { display: grid; gap: 2px; }
+.cx-settings-dialog .action-dialog-option .checkbox__content { align-items: flex-start; gap: 10px; }
+:is(.cx-settings-dialog, .cx-settings-shell) .checkbox:not([data-selected="true"]) .checkbox__control { box-shadow: inset 0 0 0 1px var(--cx-border-strong); }
+
+.cx-settings-flash { animation: cx-settings-flash 1.8s ease-out; border-radius: 12px; }
+@keyframes cx-settings-flash {
+ 0%, 35% { background-color: var(--cx-accent-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent); }
+ 100% { background-color: transparent; box-shadow: inset 0 0 0 1px transparent; }
+}
+html[data-motion="reduce"] .cx-settings-flash { animation: none; background-color: var(--cx-accent-soft); }
+@media (prefers-reduced-motion: reduce) { .cx-settings-flash { animation: none; background-color: var(--cx-accent-soft); } }
+.cx-settings-shortcut-capture { min-width: 132px; height: 30px; padding: 0 10px; border: 1px dashed var(--cx-border-strong); border-radius: 8px; background: var(--cx-bg); color: var(--cx-fg-3); font: inherit; font-size: 12.5px; }
+.cx-settings-shortcut-capture:focus-visible { outline: 2px solid var(--cx-focus); outline-offset: 1px; }
diff --git a/apps/web/ui/app/styles/task-worker-settings.css b/apps/web/ui/app/styles/task-worker-settings.css
index 5347414..e06641b 100644
--- a/apps/web/ui/app/styles/task-worker-settings.css
+++ b/apps/web/ui/app/styles/task-worker-settings.css
@@ -66,8 +66,6 @@
.single-task-workers .wsettings-top-actions button { padding-inline: 16px; }
.single-task-workers .wsettings-content { background: var(--bg); }
.single-task-workers .wsettings-credentials { height: 100%; min-height: 0; overflow-y: auto; }
-.single-task-workers .wsettings-credentials .agent-registry { width: min(1180px, 100%); min-height: 0; }
-.single-task-workers .wsettings-credentials .agent-registry-overview { margin-top: 4px; }
.single-task-workers .wsettings-orchestration {
display: grid; height: 100%; min-height: 0;
grid-template-columns: minmax(300px, 344px) minmax(0, 1fr);
@@ -216,8 +214,10 @@
.single-task-workers .wsettings-nav-title { padding: 0; }
.single-task-workers .wsettings-nav-title strong { white-space: nowrap; font-size: 13px; }
.single-task-workers .single-task-workers-mark { display: none; }
- .single-task-workers .wsettings-nav nav { flex-direction: row; flex: 1; overflow-x: auto; gap: 4px; }
- .single-task-workers .wsettings-nav nav button { width: auto; min-width: max-content; min-height: 38px; padding-inline: 10px; gap: 7px; }
+ /* A fixed grid keeps all six sections visible; the scrolling strip clipped the last tabs with no affordance. */
+ .single-task-workers .wsettings-nav nav { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); flex: 1; min-width: 0; overflow: visible; gap: 4px; }
+ .single-task-workers .wsettings-nav nav button { width: auto; min-width: 0; min-height: 38px; padding-inline: 6px; gap: 7px; }
+ .single-task-workers .wsettings-nav nav button > span:not(.wsettings-nav-count) { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.single-task-workers .wsettings-nav nav button:last-child { margin: 0; }
.single-task-workers .wsettings-nav-count { display: none; }
.single-task-workers .wsettings-nav-foot { margin: 0; padding: 0; border: 0; flex: none; }
@@ -226,9 +226,13 @@
.single-task-workers .wsettings-main { min-height: 0; }
.single-task-workers .wruntime-summary-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
+/* Section tabs drop icons where the grid columns are too narrow for icon + label. Icon sets an inline display. */
+@media (min-width: 721px) and (max-width: 960px), (max-width: 620px) {
+ .single-task-workers .wsettings-nav nav button svg { display: none !important; }
+}
@media (max-width: 720px) {
.single-task-workers .wsettings-nav { flex-wrap: wrap; gap: 8px; padding: 12px 14px 8px; }
- .single-task-workers .wsettings-nav nav { flex-basis: 100%; order: 2; padding-bottom: 2px; }
+ .single-task-workers .wsettings-nav nav { flex-basis: 100%; order: 2; padding-bottom: 2px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.single-task-workers .wsettings-nav-foot { margin-left: auto; }
.single-task-workers .wsettings-nav-foot a { min-height: 28px; }
.single-task-workers .wsettings-nav nav button { min-height: 42px; }
diff --git a/apps/web/ui/app/styles/workers-inspector.css b/apps/web/ui/app/styles/workers-inspector.css
index c08f60a..c759cdd 100644
--- a/apps/web/ui/app/styles/workers-inspector.css
+++ b/apps/web/ui/app/styles/workers-inspector.css
@@ -353,6 +353,8 @@
.wroster-check-all:disabled { opacity: .56; cursor: not-allowed; transform: none; }
.wroster-check-all.running svg { animation: railspin .75s linear infinite; }
.wroster-addbar { display: flex; align-items: center; justify-content: flex-end; gap: 6px; white-space: nowrap; }
+.wroster-descriptor-error { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
+.wroster-descriptor-error > .wruntime-note { flex: 1; min-width: 0; }
.wroster-addbar > span { margin-right: 2px; color: var(--dim); font-size: var(--ws-fs-micro); }
.wroster-primary-engines { display: flex; align-items: center; gap: 5px; }
.wroster-primary-engines button {
diff --git a/apps/web/ui/build-info.cjs b/apps/web/ui/build-info.cjs
new file mode 100644
index 0000000..a6eebc7
--- /dev/null
+++ b/apps/web/ui/build-info.cjs
@@ -0,0 +1,17 @@
+// One source fingerprint for the Web build and the locally bundled desktop UI.
+const fs = require('node:fs');
+const path = require('node:path');
+const { createHash } = require('node:crypto');
+function uiBuildId() {
+ const hash = createHash('sha256');
+ function visit(directory) {
+ for (const item of fs.readdirSync(directory, {withFileTypes: true}).sort((a, b) => a.name.localeCompare(b.name))) {
+ const filename = path.join(directory, item.name);
+ if (item.isDirectory()) visit(filename);
+ else if (/\.(tsx?|css|json)$/.test(item.name)) { hash.update(path.relative(__dirname, filename)); hash.update(fs.readFileSync(filename)); }
+ }
+ }
+ for (const directory of ['app', 'components', 'lib', 'styles']) visit(path.join(__dirname, directory));
+ return `${require('./package.json').version}+${hash.digest('hex').slice(0, 12)}`;
+}
+module.exports = { uiBuildId };
diff --git a/apps/web/ui/components/AgentExtensionsSettings.tsx b/apps/web/ui/components/AgentExtensionsSettings.tsx
index a5e72f5..7f53629 100644
--- a/apps/web/ui/components/AgentExtensionsSettings.tsx
+++ b/apps/web/ui/components/AgentExtensionsSettings.tsx
@@ -1,7 +1,9 @@
"use client";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useSearchParams } from "next/navigation";
+import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
+import type { SettingsNavigation } from "./settings/SettingsHost";
+import { NativePathPicker } from "./NativePathPicker";
+import { conversationStorageScope, subscribeConversationStorageScope } from "@/lib/conversationStorageScope";
import { Icon } from "@/components/Icon";
import { Button, Dialog, Input, Label, Switch, TextArea } from "@/components/chat/ui";
import { apiFetch, useRunList } from "@/lib/useRun";
@@ -12,7 +14,7 @@ type Filter = "all" | "skill" | "mcp" | "plugin";
type Compatibility = { status: string; components: string[]; unavailable?: string[]; reasons?: string[] };
type Package = {
id: string; name: string; description: string; version: string; enabled: boolean;
- modes: Mode[]; skills: { name: string; description: string }[];
+ modes: Mode[]; allowed_modes?: Mode[]; skills: { name: string; description: string }[];
mcp_servers: string[]; native_components: string[]; digest: string;
diagnostics: string[]; can_rollback: boolean; native_hooks_enabled?: boolean;
hook_commands?: string[]; hook_definitions?: unknown[];
@@ -21,9 +23,11 @@ type Package = {
};
type Catalog = {
engine: string; packages: Package[]; control_enabled: boolean;
+ bundled_plugins?: { id: string; name: string; modes: Mode[] }[];
builtin_skills: { id: string; modes: Mode[]; enabled: boolean; scope: string }[];
native_skills: { id: string; name: string; description: string; source: string }[];
native_mcp: { name: string; enabled: boolean; source: string; transport: string }[];
+ host_discovery_enabled?: boolean;
runtime_scope: string;
runtime_mcp: { package_id: string; server: string; status: "connected" | "failed" | "disconnected" | "not_checked"; observed_at?: string }[];
};
@@ -35,27 +39,48 @@ const MODES: { id: Mode; label: string; description: string }[] = [
];
const ENGINES: Record = {
claude: "Claude", codex: "Codex", cursor: "Cursor", pi: "Pi",
- omp: "OMP", kimi: "Kimi", grok: "Grok", opencode: "OpenCode",
+ omp: "OMP", kimi: "Kimi", grok: "Grok", opencode: "OpenCode", devin: "Devin",
};
-async function request(path: string, method = "GET", body?: unknown, base = "/api/agent-extensions") {
- const response = await apiFetch(`${base}${path}`, {
- method,
- headers: { "Content-Type": "application/json" },
- body: body === undefined ? undefined : JSON.stringify(body),
- });
- const data = await response.json();
+class ExtensionRequestError extends Error {
+ constructor(readonly code: string, message: string, readonly raw: string, readonly status: number) {
+ super(`${message}\n${code} · HTTP ${status}\n${raw}`); this.name = "ExtensionRequestError";
+ }
+}
+async function request(path: string, method = "GET", body?: unknown, base = "/api/agent-extensions", signal?: AbortSignal) {
+ const response = await apiFetch(`${base}${path}`, {method, signal, headers: {"Content-Type": "application/json"}, body: body === undefined ? undefined : JSON.stringify(body)});
+ const raw = await response.text();
+ let data;
+ try { data = JSON.parse(raw); } catch { throw new ExtensionRequestError("extensions.response_invalid", "响应不是有效 JSON", raw, response.status); }
if (!response.ok) {
- const detail = data.detail;
- throw new Error(typeof detail === "string" ? detail : detail?.message || "操作失败,请稍后重试");
+ const detail = data?.error || data?.detail;
+ throw new ExtensionRequestError(detail?.code || "extensions.http_error", typeof detail === "string" ? detail : detail?.message || "扩展操作失败", raw, response.status);
}
return data;
}
+function validatedCatalog(value: unknown, engine: string): Catalog {
+ const object = (v: unknown): v is Record => Boolean(v && typeof v === "object" && !Array.isArray(v));
+ const strings = (v: unknown): v is string[] => Array.isArray(v) && v.every(item => typeof item === "string");
+ if (!object(value) || value.engine !== engine || typeof value.control_enabled !== "boolean"
+ || !Array.isArray(value.packages) || !Array.isArray(value.native_skills) || !Array.isArray(value.native_mcp)
+ || !Array.isArray(value.builtin_skills) || !Array.isArray(value.runtime_mcp) || typeof value.runtime_scope !== "string"
+ || value.packages.some(item => !object(item) || !["id", "name", "description", "version", "digest"].every(key => typeof item[key] === "string")
+ || !item.id || typeof item.enabled !== "boolean" || typeof item.can_rollback !== "boolean" || !strings(item.modes)
+ || !strings(item.mcp_servers) || !strings(item.native_components) || !strings(item.diagnostics) || !Array.isArray(item.skills)
+ || item.skills.some(skill => !object(skill) || typeof skill.name !== "string" || typeof skill.description !== "string"))
+ || value.native_skills.some(item => !object(item) || !["id", "name", "description", "source"].every(key => typeof item[key] === "string"))
+ || value.native_mcp.some(item => !object(item) || !["name", "source", "transport"].every(key => typeof item[key] === "string") || typeof item.enabled !== "boolean")) {
+ throw new ExtensionRequestError("extensions.catalog_invalid", "扩展目录响应不符合契约", JSON.stringify(value), 200);
+ }
+ const catalog = value as unknown as Catalog;
+ if (new Set(catalog.packages.map(item => item.id)).size !== catalog.packages.length) throw new ExtensionRequestError("extensions.catalog_invalid", "扩展目录有重复 ID", JSON.stringify(value), 200);
+ return catalog;
+}
-function ModeChoices({ value, onChange }: { value: Mode[]; onChange: (next: Mode[]) => void }) {
+function ModeChoices({ value, onChange, allowed }: { value: Mode[]; onChange: (next: Mode[]) => void; allowed?: Mode[] }) {
return
{MODES.map((mode) =>
- {
+ {
const next = event.target.checked ? [...value, mode.id] : value.filter((item) => item !== mode.id);
onChange(next);
}} />
@@ -64,21 +89,29 @@ function ModeChoices({ value, onChange }: { value: Mode[]; onChange: (next: Mode
;
}
-export function AgentExtensionsSettings() {
- const params = useSearchParams();
- const requestedMode = params.get("mode");
- const [mode, setMode] = useState(requestedMode === "ctf" || requestedMode === "pentest" ? requestedMode : "chat");
- const [runId, setRunId] = useState(params.get("run") || "");
+export function AgentExtensionsSettings({navigation}: {navigation: SettingsNavigation}) {
+ const params = navigation.searchParams;
+ const scope = useSyncExternalStore(subscribeConversationStorageScope, conversationStorageScope, () => "");
+ const mode: Mode = params.get("mode") === "ctf" ? "ctf" : params.get("mode") === "pentest" ? "pentest" : "chat";
+ const runId = params.get("run") || "";
const runs = useRunList(15000);
- const [sourceTab, setSourceTab] = useState(params.get("source") === "native" ? "native" : "managed");
- const [engine, setEngine] = useState(ENGINES[params.get("engine") || ""] ? params.get("engine")! : "codex");
+ const sourceTab: SourceTab = params.get("source") === "native" ? "native" : "managed";
+ const engine = ENGINES[params.get("engine") || ""] ? params.get("engine")! : "codex";
+ const updateQuery = (patch: Record) => {
+ const next = new URLSearchParams(params);
+ Object.entries(patch).forEach(([key, value]) => { if (value) next.set(key, value); else next.delete(key); });
+ navigation.router.replace(navigation.pathname + (next.size ? `?${next}` : ""));
+ };
+ const setSourceTab = (value: string) => updateQuery({source: value});
+ const setEngine = (value: string) => updateQuery({engine: value});
const [catalog, setCatalog] = useState(null);
const [query, setQuery] = useState("");
- const [filter, setFilter] = useState(
- ["skill", "mcp", "plugin"].includes(params.get("type") || "") ? params.get("type") as Filter : "all");
+ const filter: Filter = ["skill", "mcp", "plugin"].includes(params.get("type") || "") ? params.get("type") as Filter : "all";
+ const setFilter = (value: Filter) => updateQuery({type: value});
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState(false);
+ const [picking, setPicking] = useState(false);
const [installOpen, setInstallOpen] = useState(false);
const [installType, setInstallType] = useState<"package" | "mcp">("package");
const [installModes, setInstallModes] = useState([mode]);
@@ -93,60 +126,41 @@ export function AgentExtensionsSettings() {
const selectedMode = MODES.find((item) => item.id === mode)!;
const selectedRun = runs.find((item) => item.run_id === runId);
const catalogPath = `?engine=${encodeURIComponent(engine)}&mode=${mode}${runId ? `&run_id=${encodeURIComponent(runId)}` : ""}`;
- const currentCatalogPath = useRef(catalogPath);
- const catalogRequestId = useRef(0);
- currentCatalogPath.current = catalogPath;
-
+ const owner = `${scope}:${catalogPath}`;
+ const currentOwner = useRef(owner); currentOwner.current = owner;
+ const controller = useRef(null);
+ const operation = useRef(null);
+ const mounted = useRef(true);
const reload = useCallback(async () => {
- // A mutation may finish after the operator switches Run or engine. Its
- // closure still has the old path, so never let that response replace the
- // catalog for the current selection.
- if (currentCatalogPath.current !== catalogPath) return;
- const requestId = ++catalogRequestId.current;
- const data = await request(catalogPath);
- if (currentCatalogPath.current === catalogPath && catalogRequestId.current === requestId) {
- setCatalog(data);
- setError("");
- }
- }, [catalogPath]);
+ if (!scope || currentOwner.current !== owner || !mounted.current) return null;
+ controller.current?.abort(); const abort = new AbortController(); controller.current = abort;
+ const data = validatedCatalog(await request(catalogPath, "GET", undefined, "/api/agent-extensions", abort.signal), engine);
+ if (abort.signal.aborted || currentOwner.current !== owner || !mounted.current) return null;
+ setCatalog(data); setError(""); return data;
+ }, [scope, owner, catalogPath, engine]);
useEffect(() => {
- setCatalog(null);
- let active = true;
- void reload().catch((cause) => {
- if (active && currentCatalogPath.current === catalogPath) {
- setError(cause instanceof Error ? cause.message : String(cause));
- }
- });
- return () => { active = false; };
- }, [catalogPath, reload]);
-
- const mutate = async (action: () => Promise, message: string) => {
- setBusy(true); setError(""); setNotice("");
+ mounted.current = true; setCatalog(null); setError(""); setNotice(""); setBusy(false); setPicking(false);
+ setInstallOpen(false); setDetailId(""); setRemove(null); operation.current = null;
+ if (scope) void reload().catch(cause => { if (mounted.current && currentOwner.current === owner && !controller.current?.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause)); });
+ return () => { mounted.current = false; controller.current?.abort(); };
+ }, [owner, scope, reload]);
+ const mutate = async (action: () => Promise, message: string, completed?: () => void, confirm?: (data: Catalog) => boolean) => {
+ if (!scope || currentOwner.current !== owner || operation.current || picking) return;
+ const ticket = Symbol(); operation.current = ticket; setBusy(true); setError(""); setNotice(""); let applied = false;
+ const current = () => mounted.current && currentOwner.current === owner && operation.current === ticket && conversationStorageScope() === scope;
try {
- await action();
- await reload();
- setNotice(message);
- window.dispatchEvent(new Event("muteki:chat-plugins-changed"));
+ await action(); applied = true;
+ if (!current()) return;
+ const data = await reload();
+ if (!current() || !data) return;
+ if (confirm && !confirm(data)) throw new Error("目录尚未确认本次变更,请重载核对。");
+ completed?.(); setNotice(message); window.dispatchEvent(new Event("muteki:chat-plugins-changed"));
} catch (cause) {
- setError(cause instanceof Error ? cause.message : String(cause));
- } finally { setBusy(false); }
- };
-
- const setActiveMode = (next: Mode) => {
- setMode(next); setNotice(""); setError("");
- const url = new URL(window.location.href);
- if (next === "chat") url.searchParams.delete("mode"); else url.searchParams.set("mode", next);
- if (next === "chat" || (runId && selectedRun?.mode !== next)) {
- setRunId(""); url.searchParams.delete("run");
- }
- window.history.replaceState(window.history.state ?? {}, "", `${url.pathname}${url.search}`);
- };
- const selectRun = (next: string) => {
- setRunId(next);
- const url = new URL(window.location.href);
- if (next) url.searchParams.set("run", next); else url.searchParams.delete("run");
- window.history.replaceState(window.history.state ?? {}, "", `${url.pathname}${url.search}`);
+ if (current()) setError(`${applied ? "变更已返回,但目录确认失败;请重载核对结果。\n" : ""}${cause instanceof Error ? cause.message : String(cause)}`);
+ } finally { if (operation.current === ticket) { operation.current = null; if (currentOwner.current === owner && mounted.current) setBusy(false); } }
};
+ const setActiveMode = (next: Mode) => updateQuery({mode: next === "chat" ? "" : next, run: next === "chat" || selectedRun?.mode !== next ? "" : runId});
+ const selectRun = (next: string) => updateQuery({run: next});
const togglePackageMode = (item: Package, enabled: boolean) => {
const modes = enabled ? [...item.modes, mode] : item.modes.filter((entry) => entry !== mode);
if (!modes.length) { setError("至少保留一个使用场景;若要全部停用,请在详情中关闭全局启用。"); return; }
@@ -169,8 +183,7 @@ export function AgentExtensionsSettings() {
source: { kind, ...(kind === "git" ? { url: source, ref } : { path: source }) }, modes: installModes,
});
}
- setInstallOpen(false); setSource(""); setRef(""); setName("");
- }, "扩展已安装,所选场景将在下一次 Agent 启动或会话轮次中读取兼容组件。");
+ }, "扩展已安装,所选场景将在下一次 Agent 启动或会话轮次中读取兼容组件。", () => { setInstallOpen(false); setSource(""); setRef(""); setName(""); });
const packages = useMemo(() => (catalog?.packages || []).filter((item) => {
const matches = `${item.name} ${item.description} ${item.skills.map((skill) => skill.name).join(" ")} ${item.mcp_servers.join(" ")}`
@@ -191,6 +204,7 @@ export function AgentExtensionsSettings() {
const nativeMcp = (catalog?.native_mcp || []).filter((item) => filter !== "skill" && filter !== "plugin" && item.name.toLowerCase().includes(query.toLowerCase()));
return
+ {mode === "chat" && catalog ?
void mutate(() => request("/control", "PUT", {enabled}), "平台工具状态已更新。", undefined, data => data.control_enabled === enabled)} /> : null}
{MODES.map((item) => setActiveMode(item.id)}>
@@ -199,7 +213,7 @@ export function AgentExtensionsSettings() {
-
AGENT EXTENSIONS / {mode.toUpperCase()}
+
{selectedMode.label} 能力 {mode === "chat"
? "已启用的 Skill、插件与 MCP 在下一轮聊天读取。"
: `新启动的${selectedMode.label} Worker 读取已分配的 Skill 和兼容插件;MCP 工具按 Run 连接。${runId ? ` 当前查看 ${runId} 的连接观测。` : "打开具体 Run 时可查看连接观测。"}`}
@@ -209,8 +223,11 @@ export function AgentExtensionsSettings() {
setSourceTab("managed")}>Muteki 安装
- setSourceTab("native")}>本机 Agent · 只读
+ setSourceTab("native")}>服务宿主 Agent · 只读
+ {mode === "chat" && catalog?.bundled_plugins?.some((item) => item.id === "muteki-visualize")
+ && !catalog.packages.some((item) => item.id === "muteki-visualize") &&
void mutate(() => request("/bundled/muteki-visualize", "POST"), "可视化插件已独立安装,下一轮聊天可用。")}>安装可视化插件 }
添加扩展
{mode !== "chat" &&
@@ -221,7 +238,7 @@ export function AgentExtensionsSettings() {
{item.name || item.run_id} · {item.run_id}{item.finished ? " · 已结束" : ""} )}
{runId && void reload().catch((cause) => {
- if (currentCatalogPath.current === catalogPath) {
+ if (currentOwner.current === owner) {
setError(cause instanceof Error ? cause.message : String(cause));
}
})}>刷新连接状态 }
@@ -253,7 +270,8 @@ export function AgentExtensionsSettings() {
}
{packages.map((item) => {
const compatible = mode === "chat" ? item.compatibility?.[engine] : item.worker_compatibility?.[engine];
- const available = Boolean(compatible && compatible.status !== "blocked" && compatible.status !== "unavailable");
+ const available = Boolean((!item.allowed_modes || item.allowed_modes.includes(mode))
+ && compatible && compatible.status !== "blocked" && compatible.status !== "unavailable");
const selected = item.modes.includes(mode);
const observed = (catalog?.runtime_mcp || []).filter((row) => row.package_id === item.id);
const mcpState = item.mcp_servers.length === 0 ? "" : mode === "chat" ? "聊天时连接"
@@ -281,30 +299,31 @@ export function AgentExtensionsSettings() {
{catalog && packages.length === 0 && !browserVisible &&
{query || filter !== "all" ? "没有匹配的扩展" : "尚未安装扩展"}
{query || filter !== "all" ? "试试其他名称或类型。" : `添加 Skill、插件或 MCP,并选择在${selectedMode.label}中使用。`}
{!query && filter === "all" &&
添加扩展 }
}
- :
- 这里仅展示 {ENGINES[engine]} 的本机配置。Worker 使用 Muteki 安装并明确启用的包;本机 Agent 的私有目录不会直接挂载进任务容器。
+ :
+ {catalog?.host_discovery_enabled === false ? 当前服务已禁用宿主能力发现;这不表示 Agent 未登录。
: null}
+ 这里仅展示 {ENGINES[engine]} 的服务宿主配置。Worker 使用 Muteki 安装并明确启用的包;本机 Agent 的私有目录不会直接挂载进任务容器。
{[...nativeSkills.map((item) => ({ key: item.id, name: item.name, info: `${item.source} · Skill`, description: item.description })),
...nativeMcp.map((item) => ({ key: item.name, name: item.name, info: `${item.source} · MCP · ${item.enabled ? "已配置" : "已停用"}`, description: "" }))]
.map((item) => {item.name} {item.info} {item.description &&
{item.description}
}
)}
- {!nativeSkills.length && !nativeMcp.length && 没有发现匹配的本机能力
}
+ {!nativeSkills.length && !nativeMcp.length && 没有发现匹配的服务宿主能力
}
}
{mode === "pentest"
? "设置只影响新启动的 Worker。agent-browser 会话协调依赖受管命令入口,不能替代容器网络与文件系统隔离;已投放扩展的内容会在下次 Worker 启动时重新校验。"
: `包保存在 Muteki 私有目录。${mode === "chat" ? "聊天会话在下一轮读取变更。" : "新启动的 Worker 读取变更;运行中的 Worker 保留已投放的 Skill 快照。"}`}
-
{ if (!busy) { setInstallOpen(open); if (!open) setError(""); } }}
+ { if (!busy && !picking) { setInstallOpen(open); if (!open) setError(""); } }}
title="添加 Agent 扩展" description="导入一个包或连接 MCP,并明确选择它作用于哪些场景。" footer={<>
- setInstallOpen(false)} disabled={busy}>取消
- {installType === "mcp" ? "添加 MCP" : "导入扩展"}
+ setInstallOpen(false)} disabled={busy || picking}>取消
+ {installType === "mcp" ? "添加 MCP" : "导入扩展"}
>}>
setInstallType("package")}>Skill / 插件 setInstallType("mcp")}>MCP 服务
{installType === "mcp" ? <>
名称 setName(event.target.value)} placeholder="my-tools" />
{stats.totalAdapters}
-
+
类引擎
@@ -627,7 +616,7 @@ export function CapabilityManagement({ hideIntro = false }: { hideIntro?: boolea
{stats.totalTools}
-
+
个工具
@@ -644,7 +633,7 @@ export function CapabilityManagement({ hideIntro = false }: { hideIntro?: boolea
{stats.totalThreads}
-
+
个会话
@@ -773,7 +762,7 @@ export function CapabilityManagement({ hideIntro = false }: { hideIntro?: boolea
)}
-
+
Principal: {selectedRow.principal_id} · Thread ID: {selectedRow.thread.thread_id}
@@ -846,7 +835,6 @@ export function CapabilityManagement({ hideIntro = false }: { hideIntro?: boolea
setToolSearch(e.target.value)}
placeholder="搜索工具名或描述…"
@@ -1120,7 +1108,7 @@ export function CapabilityManagement({ hideIntro = false }: { hideIntro?: boolea
{Object.keys(skill.engines).length > 0 && (
<>
适配引擎
- {Object.keys(skill.engines).map((e) => ENGINE_NAMES[e] || e).join(", ")}
+ {Object.keys(skill.engines).map((e) => engineDisplayName(descriptors, e)).join(", ")}
>
)}
@@ -1161,12 +1149,12 @@ export function CapabilityManagement({ hideIntro = false }: { hideIntro?: boolea
- {ENGINE_NAMES[adapter.engine] || adapter.engine}
+ {engineDisplayName(descriptors, adapter.engine)}
{adapter.adapter_id}:{adapter.instance_id}
@@ -1461,7 +1449,7 @@ export function CapabilityManagement({ hideIntro = false }: { hideIntro?: boolea
{/* Revoke Whole Binding Group Confirmation Modal */}
-
+
撤销当前 Thread 的整组能力?
{selectedRow ? {selectedRow.thread.title} · Principal: {selectedRow.principal_id} : null}
diff --git a/apps/web/ui/components/CommandPalette.tsx b/apps/web/ui/components/CommandPalette.tsx
index a302c62..ab275fe 100644
--- a/apps/web/ui/components/CommandPalette.tsx
+++ b/apps/web/ui/components/CommandPalette.tsx
@@ -248,13 +248,14 @@ export function CommandPalette(props: PaletteData) {
}, [filtered]);
return (
- {
if (!isOpen) onClose();
}}
+ variant="opaque"
+ isDismissable
>
-
-
-
+
);
}
diff --git a/apps/web/ui/components/Conversation.tsx b/apps/web/ui/components/Conversation.tsx
index ef99a79..2a06088 100644
--- a/apps/web/ui/components/Conversation.tsx
+++ b/apps/web/ui/components/Conversation.tsx
@@ -965,7 +965,7 @@ function Composer({
const [expectedFindings, setExpectedFindings] = useState("10");
const [flagFormat, setFlagFormat] = useState<"brace" | "token" | "custom">("brace");
const [flagWrapper, setFlagWrapper] = useState("");
- const [containerMode, setContainerMode] = useState(workspaceMode === "pentest");
+ const [containerMode, setContainerMode] = useState(false);
const [allowOperatorInput, setAllowOperatorInput] = useState(false);
// P2-v3: when the coordinator runs inside a container, local worker mode is
// rejected server-side — force container mode and lock the toggle.
@@ -1082,7 +1082,7 @@ function Composer({
}
const dispatched = await onDispatch(v, {
webSearch: mode === "pentest" ? true : webSearch,
- mode, collect, containerMode: mode === "pentest" ? true : containerMode,
+ mode, collect, containerMode,
allowOperatorInput: mode === "pentest" ? true : allowOperatorInput,
flagFormat,
flagWrapper: flagFormat === "custom" ? flagWrapper.trim() : undefined,
@@ -1270,7 +1270,7 @@ function Composer({
{webSearch ? t("composer.webOn") : t("composer.webOff")}
}
- {mode === "ctf" &&
{containerMode ? t("composer.containerOn") : t("composer.containerOff")}
- }
+
{t("composer.raceTimeout")}
-
{ setAdvancedTouched((old) => ({ ...old, raceTimeout: true })); setRaceTimeout(v); }}
ariaLabel={t("composer.raceTimeout")} title={t("composer.raceTimeoutTitle")} suffix="s" />
{t("composer.raceTimeoutHint")}
diff --git a/apps/web/ui/components/EngineLogo.tsx b/apps/web/ui/components/EngineLogo.tsx
index 047db89..f96412c 100644
--- a/apps/web/ui/components/EngineLogo.tsx
+++ b/apps/web/ui/components/EngineLogo.tsx
@@ -108,6 +108,15 @@ export function EngineLogo({ engine, size = 18, className, title }: EngineLogoPr
);
}
+ if (normalized === "droid" || normalized === "droid-cli" || normalized === "factory-droid") {
+ return (
+
+ {title ? {title} : null}
+
+
+ );
+ }
+
if (normalized === "dsh" || normalized === "deepseek-harness" || normalized === "deepseek") {
return (
diff --git a/apps/web/ui/components/ExtensionSettings.tsx b/apps/web/ui/components/ExtensionSettings.tsx
index 7b29510..8152163 100644
--- a/apps/web/ui/components/ExtensionSettings.tsx
+++ b/apps/web/ui/components/ExtensionSettings.tsx
@@ -17,6 +17,7 @@ import {
StatusLabel,
} from "@/components/ui-contributions";
import { SchemaForm } from "@/components/ui-contributions/SchemaForm";
+import { SettingsSection } from "@/components/settings/primitives";
/**
* EXT-02:Extension 设置面板(/settings/extensions,任务书 3.5 / 13.1)。
@@ -200,73 +201,28 @@ const ORIGIN_LABELS: Record = {
const page: CSSProperties = {
minHeight: 0,
background: "transparent",
- color: "var(--text)",
- padding: "0 0 48px",
- fontFamily: "var(--font-sans)",
+ color: "var(--cx-fg)",
+ fontFamily: "var(--cx-font-sans)",
};
-const inner: CSSProperties = { maxWidth: 1040, margin: "0 auto", display: "grid", gap: 18 };
-const card: CSSProperties = {
- border: "1px solid var(--line)",
- borderRadius: 12,
- background: "var(--panel)",
- padding: 16,
- display: "grid",
- gap: 10,
-};
-const row: CSSProperties = { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" };
-const muted: CSSProperties = { color: "var(--muted)", fontSize: 12 };
-const mono: CSSProperties = { fontFamily: "var(--font-mono)", fontSize: 11 };
-const btn: CSSProperties = {
- height: 30,
- padding: "0 12px",
- border: "1px solid var(--line2)",
- borderRadius: 8,
- background: "var(--panel2)",
- color: "var(--text)",
- fontSize: 12,
- fontWeight: 650,
- cursor: "pointer",
-};
-const btnPrimary: CSSProperties = {
- ...btn,
- borderColor: "color-mix(in srgb, var(--blue) 50%, var(--line2))",
- background: "color-mix(in srgb, var(--blue) 14%, var(--panel))",
- color: "var(--bright)",
-};
-const btnDanger: CSSProperties = {
- ...btn,
- borderColor: "color-mix(in srgb, var(--red) 45%, var(--line2))",
- color: "var(--red)",
-};
-const input: CSSProperties = {
- height: 30,
- padding: "0 10px",
- border: "1px solid var(--line2)",
- borderRadius: 8,
- background: "var(--panel2)",
- color: "var(--bright)",
- fontSize: 12,
- minWidth: 0,
+const inner: CSSProperties = { display: "grid", gap: 32 };
+const row: CSSProperties = { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" };
+const muted: CSSProperties = { color: "var(--cx-fg-3)", fontSize: 12 };
+const mono: CSSProperties = { fontFamily: "var(--cx-font-mono)", fontSize: 12, color: "var(--cx-fg-2)" };
+const meta: CSSProperties = { ...row, gap: "4px 16px", color: "var(--cx-fg-3)", fontSize: 12, lineHeight: 1.5 };
+const btn = "cx-ext-btn";
+const btnPrimary = "cx-ext-btn cx-ext-btn-primary";
+const btnDanger = "cx-ext-btn cx-ext-btn-danger";
+const input: CSSProperties = { minWidth: 0 };
+
+const TONES: Record = {
+ "var(--green)": "success",
+ "var(--amber)": "warning",
+ "var(--red)": "danger",
};
function badge(color: string, label: string, title?: string) {
return (
-
+
{label}
);
@@ -302,9 +258,9 @@ function PreviewPanel({
}) {
const permissions = value.manifest.permissions;
return (
-
+
-
{value.extension_id}@{value.version}
+
{value.extension_id}@{value.version}
{badge(value.verification.signature === "unsigned" ? "var(--amber)" : "var(--green)", value.verification.signature || "unknown")}
{badge(value.verification.immutable ? "var(--green)" : "var(--amber)", value.verification.immutable ? "来源已固定" : "可变来源")}
{badge("var(--blue)", value.state)}
@@ -320,14 +276,14 @@ function PreviewPanel({
有效期 {new Date(value.expires_at).toLocaleString()}
{permissions ? (
-
+
文件系统:{permissions.filesystem.join(", ") || "无"}
网络:{permissions.network.join(", ") || "无"}
Secret:{permissions.secrets.join(", ") || "无"}
事件写入:{permissions.events_write.join(", ") || "无"}
) : null}
- {value.changes.permission_expansion?.length ?
新增权限:{value.changes.permission_expansion.join(", ")}
: null}
+ {value.changes.permission_expansion?.length ?
新增权限:{value.changes.permission_expansion.join(", ")}
: null}
{value.confirmations_required.map((item) => (
-
-
来源类型
-
onChange({ ...draft, kind: String(key) as SourceDraft["kind"] })}>
-
- {SOURCE_KINDS.map(([kind, label]) => {label} )}
-
+
@@ -453,24 +417,30 @@ export function ExtensionSettings() {
}, [load]);
const loadDetail = useCallback(async (extensionId: string) => {
- const [rDetail, rUi, rProj, rLogs] = await Promise.all([
- apiFetch(`/api/extensions/${encodeURIComponent(extensionId)}/detail`),
- apiFetch(`/api/extensions/${encodeURIComponent(extensionId)}/ui`),
- apiFetch(`/api/extensions/${encodeURIComponent(extensionId)}/projection`),
- apiFetch(`/api/extensions/${encodeURIComponent(extensionId)}/logs/page?limit=50`),
+ const base = `/api/extensions/${encodeURIComponent(extensionId)}`;
+ const [rDetail, rUi, rLogs] = await Promise.all([
+ apiFetch(`${base}/detail`),
+ apiFetch(`${base}/ui`),
+ apiFetch(`${base}/logs/page?limit=50`),
]);
if (rDetail.ok) setDetail(await rDetail.json());
+ let nextContributions: UiContributions = {};
if (rUi.ok) {
const body = await rUi.json();
- setContributions((body.contributions as UiContributions) ?? {});
- } else {
- setContributions({});
+ nextContributions = (body.contributions as UiContributions) ?? {};
}
- if (rProj.ok) {
- const body = await rProj.json();
- setProjection(body.status === "ok" ? body.data ?? body : null);
- } else {
- setProjection(null);
+ setContributions(nextContributions);
+ // Only boards and artifact viewers read a projection; extensions without
+ // them may not serve one at all.
+ const projectionConsumer = nextContributions.board ?? nextContributions.artifact_viewers?.[0];
+ setProjection(null);
+ if (projectionConsumer) {
+ const query = projectionConsumer.projection ? `?name=${encodeURIComponent(projectionConsumer.projection)}` : "";
+ const rProj = await apiFetch(`${base}/projection${query}`);
+ if (rProj.ok) {
+ const body = await rProj.json();
+ setProjection(body.status === "ok" ? body.data ?? body : null);
+ }
}
if (rLogs.ok) setLogs(await rLogs.json());
}, []);
@@ -728,36 +698,22 @@ export function ExtensionSettings() {
{error ? (
-
+
{error}
) : null}
{notice ? (
-
+
{notice}
) : null}
-
-
- 安装 Agent Plugin
- 根目录必须包含 Agent Plugins 1.0.0 plugin.json
-
+ 根目录必须包含 Agent Plugins 1.0.0 plugin.json>}
+ >
{
setInstallDraft(draft);
setInstallPreview(null);
@@ -766,11 +722,11 @@ export function ExtensionSettings() {
{installPreview ? (
) : null}
-
+
void preview(installDraft)}
>
@@ -778,56 +734,62 @@ export function ExtensionSettings() {
!installConfirmations.includes(item))}
onClick={() => void install()}
>
{busy === "安装" ? "安装中…" : "安装"}
-
+
-
-
- Agent Plugin Catalog
- 浏览管理员提供的版本固定清单
-
-
-
Catalog 路径
-
setCatalogRoot(event.target.value)} placeholder="目录或 YAML 文件" />
-
搜索
-
setCatalogQuery(event.target.value)} placeholder="id、版本或说明" />
-
void loadCatalog()}>加载
+
+
{catalogEntries.map((entry) => (
-
+
{entry.id}@{entry.version}
{badge(entry.revoked ? "var(--red)" : entry.trust_status === "verified" ? "var(--green)" : "var(--amber)", entry.revoked ? "revoked" : entry.trust_status || "unsigned", entry.revoke_reason)}
发布者 {entry.publisher || "unknown"}
{entry.description || "无说明"}
- {
+ {
setInstallDraft({ ...EMPTY_SOURCE, kind: "catalog", catalog_root: catalogRoot, extension_id: entry.id, version: entry.version });
setInstallPreview(null);
}}>选择
))}
-
-
-
-
- 已安装 Agent Plugins
- {loading ? "加载中…" : `${cards.length} 个`}
-
- void load()}>
+
+
+ void load()}>
刷新
-
-
+ }
+ >
{loading ? (
- 正在加载插件列表…
+ 正在加载插件列表…
) : cards.length === 0 ? (
- 尚未安装任何 Agent Plugin。
+ 尚未安装任何 Agent Plugin。
) : (
cards.map(({ record, manifest, live_health: liveHealth, migrations }) => {
const state = STATE_LABELS[record.state] ?? { label: record.state, color: "var(--muted)" };
@@ -836,26 +798,20 @@ export function ExtensionSettings() {
return (
-
-
{id}
+
+ {id}
{badge("var(--blue)", ORIGIN_LABELS[record.origin] ?? record.origin)}
{selected === id && selectedStatusLabels ? (
-
+
) : (
badge(state.color, state.label)
)}
{record.active_version
- ? badge("var(--green)", `v${record.active_version}`, `已装版本:${record.installed_versions.join(", ")}`)
+ ? badge("var(--muted)", `v${record.active_version}`, `已装版本:${record.installed_versions.join(", ")}`)
: badge("var(--muted)", "未启用版本")}
{record.enabled ? (
liveHealth?.status === "healthy"
@@ -868,15 +824,15 @@ export function ExtensionSettings() {
{manifest.plugin_schema ? badge("var(--blue)", "Agent Plugins 1.0.0") : null}
{badge("var(--blue)", `Registry:${manifest.registry_state || "installed"}`)}
- void select(id)}>
+ void select(id)}>
{selected === id ? "收起" : "详情"}
-
+
{manifest.plugin_version ? plugin v{manifest.plugin_version} : null}
{manifest.version && manifest.version !== manifest.plugin_version ? host v{manifest.version} : null}
- {manifest.client_namespace ? {manifest.client_namespace} : null}
+ {manifest.client_namespace ? {manifest.client_namespace} : null}
{manifest.requires_core ? core {manifest.requires_core} : null}
{manifest.portable_components?.skills?.length ? Skills:{manifest.portable_components.skills.join(", ")} : null}
{manifest.portable_components?.mcp_state === "ready" ? MCP : null}
@@ -888,15 +844,15 @@ export function ExtensionSettings() {
{manifest.verification ? (
-
+
签名:{String(manifest.verification.signature || "unsigned")}
发布者:{String(manifest.verification.publisher || "unknown")}
- 摘要:{String(manifest.verification.content_sha256 || "").slice(0, 16) || "—"}
+ 摘要:{String(manifest.verification.content_sha256 || "").slice(0, 16) || "—"}
) : null}
{record.isolation?.enforcement ? (
-
+
权限执行:{record.isolation.enforcement}
隔离:{record.isolation.backend || "none"}
子进程:{record.isolation.subprocess_policy || "未声明"}
@@ -905,13 +861,13 @@ export function ExtensionSettings() {
) : null}
{record.registry_entries?.length ? (
-
- {record.registry_entries.map((entry) =>
{entry.type} · {entry.id} · {entry.state} )}
+
+ {record.registry_entries.map((entry) => {entry.type} · {entry.id} · {entry.state} )}
) : null}
{manifest.permissions ? (
-
+
文件系统:{manifest.permissions.filesystem.join(", ") || "无"}
网络:{manifest.permissions.network.join(", ") || "无"}
@@ -929,18 +885,18 @@ export function ExtensionSettings() {
) : null}
{record.last_error ? (
- {record.last_error}
+ {record.last_error}
) : null}
-
+
{record.enabled ? (
- void disable(id)}>
+ void disable(id)}>
停用
) : (
void enable(id)}
>
@@ -949,7 +905,7 @@ export function ExtensionSettings() {
)}
{
setShowUpgrade(showUpgrade === id ? "" : id);
@@ -960,7 +916,7 @@ export function ExtensionSettings() {
void rollback(id)}
@@ -969,7 +925,7 @@ export function ExtensionSettings() {
void refreshHealth(id)}
>
@@ -978,7 +934,7 @@ export function ExtensionSettings() {
{
setError("");
@@ -990,16 +946,8 @@ export function ExtensionSettings() {
{showUpgrade === id ? (
-
-
升级来源(旧版本按插件升级策略处理,状态迁移会写 receipt):
+
+
升级来源(旧版本按插件升级策略处理,状态迁移会写 receipt)
{
@@ -1020,12 +968,12 @@ export function ExtensionSettings() {
) : null}
-
void preview(upgradeDraft[id] ?? EMPTY_SOURCE, id)}>
+ void preview(upgradeDraft[id] ?? EMPTY_SOURCE, id)}>
{busy === `预览升级 ${id}` ? "检查中…" : "生成升级预览"}
!(upgradeConfirmations[id] || []).includes(item))}
onClick={() => void upgrade(id)}
>
@@ -1038,28 +986,29 @@ export function ExtensionSettings() {
);
})
)}
-
+
{selected && detail ? (
-
-
- 插件详情:{selected}
- {detail.live_health
- ? badge(
- detail.live_health.status === "healthy" ? "var(--green)" : "var(--amber)",
- `实时健康:${detail.live_health.status ?? "unknown"}`,
- detail.live_health.detail,
- )
- : badge("var(--muted)", "未启用,无实时健康")}
-
+ 插件详情 {selected}>}
+ actions={detail.live_health
+ ? badge(
+ detail.live_health.status === "healthy" ? "var(--green)" : "var(--amber)",
+ `实时健康:${detail.live_health.status ?? "unknown"}`,
+ detail.live_health.detail,
+ )
+ : badge("var(--muted)", "未启用,无实时健康")}
+ >
{contributions.navigation?.length ? : null}
{detail.migrations.length ? (
-
状态迁移 receipt:
+
状态迁移 receipt
{detail.migrations.map((m) => (
-
+
{m.from_version ?? "none"} → {m.to_version} · {new Date(m.migrated_at).toLocaleString()} ·{" "}
{m.files.length ? `迁移文件:${m.files.join(", ")}` : "无状态文件"}
@@ -1069,10 +1018,10 @@ export function ExtensionSettings() {
{contributions.command_forms?.length && detail.record.enabled ? (
- 命令表单(声明式 contribution):
- {contributions.command_forms.map((form) => (
+ 命令表单(声明式 contribution)
+ {contributions.command_forms.map((form, index) => (
- 看板(projection:{contributions.board.projection ?? "默认"}):
+ 看板(projection:{contributions.board.projection ?? "默认"})
) : null}
{contributions.artifact_viewers?.length && projection !== null ? (
-
Artifact viewer(声明式,受控渲染):
+
Artifact viewer(声明式,受控渲染)
{contributions.artifact_viewers.map((viewer) => (
))}
@@ -1100,7 +1049,7 @@ export function ExtensionSettings() {
{detail.config_schema ? (
- 设置 schema(config_schema 声明式渲染,提交即带配置重新启用):
+ 设置 schema(config_schema 声明式渲染,提交即带配置重新启用)
) : detail.config_schema_error ? (
-
config schema 读取失败:{detail.config_schema_error}
+
config schema 读取失败:{detail.config_schema_error}
) : null}
- 归档日志(完整内容,尾部共 {logs?.total ?? 0} 行):
+ 归档日志(完整内容,尾部共 {logs?.total ?? 0} 行)
{logs?.has_more ? (
- void loadMoreLogs()}>
+ void loadMoreLogs()}>
加载更早
) : null}
{logs && logs.lines.length ? (
-
+
{logs.lines.join("\n")}
) : (
暂无日志。
)}
-
+
) : null}
{selected && !detail ? (
-
+
正在加载 {selected} 的详情…
) : null}
!open && setUninstallDraft(null)}>
-
+
{uninstallDraft ? `卸载 ${uninstallDraft.id}` : "卸载扩展"}
卸载会停止并移除扩展。请明确选择需要保留的数据,再确认执行。
@@ -1169,7 +1105,7 @@ export function ExtensionSettings() {
状态与日志:{uninstallDraft.preserveState ? "保留" : "删除"}
Artifact:{uninstallDraft.preserveArtifacts ? "保留" : "删除"}
- {error ? {error}
: null}
+ {error ? {error}
: null}
) : null}
diff --git a/apps/web/ui/components/LoginGate.tsx b/apps/web/ui/components/LoginGate.tsx
index 026f719..dc25d0f 100644
--- a/apps/web/ui/components/LoginGate.tsx
+++ b/apps/web/ui/components/LoginGate.tsx
@@ -1,75 +1,67 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
-import { Button, FieldError, Input, Label, TextField } from "@heroui/react";
-import { checkAuth, login, onAuthRequired, currentAuthGeneration, currentAuthPersistenceWarning } from "@/lib/useRun";
-import { useT } from "@/lib/i18n";
+import { Button, Checkbox, IconButton, Spinner, TextField } from "@/components/chat/ui";
+import { checkAuth, login, onAuthRequired, currentAuthGeneration, currentAuthPersistenceWarning, currentAuthNotice, currentServiceOrigin, isNativeAuth } from "@/lib/serviceAuth";
+import { useT, useLang } from "@/lib/i18n";
import { MutekiLogo } from "@/components/MutekiLogo";
-type AuthPhase = "checking" | "locked" | "open";
-type AuthSnapshot = { authenticated: boolean; authRequired: boolean; at: number; generation: number };
+import { NotificationInbox } from "./NotificationInbox";
+
+type AuthPhase = "checking" | "locked" | "open" | "unavailable";
+type AuthSnapshot = { authenticated: boolean; authRequired: boolean; at: number; generation: number; expiresAt: number | null };
let authSnapshot: AuthSnapshot | null = null;
const AUTH_SNAPSHOT_MS = 30_000;
function snapshotIsOpen(): boolean {
if (!authSnapshot || authSnapshot.generation !== currentAuthGeneration()) return false;
- if (Date.now() - authSnapshot.at > AUTH_SNAPSHOT_MS) return false;
+ if (Date.now() - authSnapshot.at > AUTH_SNAPSHOT_MS || (authSnapshot.expiresAt !== null && authSnapshot.expiresAt * 1000 <= Date.now())) return false;
return !authSnapshot.authRequired || authSnapshot.authenticated;
}
-function rememberAuth(authenticated: boolean, authRequired: boolean): void {
- authSnapshot = { authenticated, authRequired, at: Date.now(), generation: currentAuthGeneration() };
+function rememberAuth(authenticated: boolean, authRequired: boolean, expiresAt: number | null): void {
+ authSnapshot = { authenticated, authRequired, expiresAt, at: Date.now(), generation: currentAuthGeneration() };
}
-/**
- * Auth gate (P3). Wraps the whole deck. On mount it asks the backend whether a
- * valid token is present (checkAuth → GET /api/auth/me). Three outcomes:
- *
- * - auth disabled (no MUTEKI_WEB_PASSWORD on the server) → render children.
- * - token already valid → render children.
- * - otherwise → show the password form.
- *
- * A mid-session 401 (token expired/cleared) fires onAuthRequired(), which bounces
- * back to the form without a reload. The password is POSTed to /api/auth/login
- * and exchanged for a signed session token (stored in localStorage by login());
- * the password itself never persists client-side.
- *
- * Settings and other routes remount this gate on every navigation. A short
- * in-memory snapshot keeps an already-authenticated session from flashing
- * "校验中…" while /api/auth/me revalidates in the background.
- */
export function LoginGate({ children, onAuthStateChange }: { children: React.ReactNode; onAuthStateChange?: (open: boolean) => void }) {
const t = useT();
+ const { lang } = useLang(); const en = lang === "en";
+ const [remember, setRemember] = useState(true);
+ const [connectionOrigin, setConnectionOrigin] = useState("");
const [phase, setPhase] = useState(() => (snapshotIsOpen() ? "open" : "checking"));
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
+ const [expiresAt, setExpiresAt] = useState(() => authSnapshot?.expiresAt ?? null);
useEffect(() => { onAuthStateChange?.(phase === "open"); }, [phase, onAuthStateChange]);
const verifySequence = useRef(0);
const verify = useCallback(async () => {
const sequence = ++verifySequence.current;
try {
- const { authenticated, authRequired } = await checkAuth();
+ const { authenticated, authRequired, expiresAt: expiry } = await checkAuth();
if (sequence !== verifySequence.current) return;
// Auth metadata was verified against the current service.
const open = !authRequired || authenticated;
- rememberAuth(authenticated, authRequired);
+ rememberAuth(authenticated, authRequired, expiry);
+ setExpiresAt(expiry);
setPhase(open ? "open" : "locked");
} catch (exc) {
if (sequence !== verifySequence.current) return;
setError(exc instanceof Error ? exc.message : "认证校验失败,请重试。");
authSnapshot = null;
- setPhase("locked");
+ setPhase("unavailable");
}
}, []);
useEffect(() => {
+ setConnectionOrigin(currentServiceOrigin());
verify();
- // A 401 on any later request clears the token and re-locks the gate.
+ // A later 401 invalidates verified session metadata and re-locks the gate.
return onAuthRequired((reason) => {
verifySequence.current += 1;
+ setConnectionOrigin(currentServiceOrigin());
authSnapshot = null;
setPassword("");
setError("");
@@ -78,6 +70,20 @@ export function LoginGate({ children, onAuthStateChange }: { children: React.Rea
});
}, [verify]);
+ useEffect(() => {
+ if (phase !== "open") return;
+ const visible = () => { if (document.visibilityState === "visible") void verify(); };
+ window.addEventListener("focus", visible);
+ document.addEventListener("visibilitychange", visible);
+ const timer = expiresAt === null ? null : window.setTimeout(() => void verify(),
+ Math.max(0, Math.min(2_147_483_647, expiresAt * 1000 - Date.now() + 100)));
+ return () => {
+ window.removeEventListener("focus", visible);
+ document.removeEventListener("visibilitychange", visible);
+ if (timer !== null) window.clearTimeout(timer);
+ };
+ }, [phase, expiresAt, verify]);
+
const submit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
@@ -89,12 +95,14 @@ export function LoginGate({ children, onAuthStateChange }: { children: React.Rea
setBusy(true);
setError("");
try {
- const { ok, authRequired } = await login(password);
+ const { ok, authRequired } = await login(password, remember);
if (ok) {
setPassword("");
const verified = await checkAuth();
- rememberAuth(verified.authenticated, authRequired);
+ rememberAuth(verified.authenticated, authRequired, verified.expiresAt);
+ setExpiresAt(verified.expiresAt);
setPhase(verified.authenticated ? "open" : "locked");
+ if (!verified.authenticated) setError(isNativeAuth() ? "桌面会话未能建立,请重新连接后重试。" : "浏览器未能保存登录,请允许本站使用 Cookie 后重试。");
} else {
setError(t("login.error"));
}
@@ -104,31 +112,42 @@ export function LoginGate({ children, onAuthStateChange }: { children: React.Rea
setBusy(false);
}
},
- [password, t]
+ [password, remember, t]
);
- if (phase === "open") return <>{currentAuthPersistenceWarning() ? {currentAuthPersistenceWarning()}
: null}{children}>;
+ if (phase === "open") return <>{currentAuthPersistenceWarning() ? {currentAuthPersistenceWarning()}
: null} {children}>;
return (
-
- {phase === "checking" ? (
-
{t("login.checking")}
- ) : (
-