mulmoclaude 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/bin/mulmoclaude.js +202 -0
- package/bin/prepare-dist.js +93 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +1 -0
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +5 -0
- package/client/assets/index-D8rhwXLq.js +4906 -0
- package/client/assets/index-KNLBjwuh.css +1 -0
- package/client/assets/index.es-D4YyL_Dg-BfRHLTZV.js +5 -0
- package/client/assets/material-icons-Dr0goTwe.woff +0 -0
- package/client/assets/material-icons-kAwBdRge.woff2 +0 -0
- package/client/assets/material-icons-outlined-BpWbwl2n.woff +0 -0
- package/client/assets/material-icons-outlined-DZhiGvEA.woff2 +0 -0
- package/client/assets/material-icons-round-BDlwx-sv.woff +0 -0
- package/client/assets/material-icons-round-DrirKXBx.woff2 +0 -0
- package/client/assets/material-icons-sharp-CH1KkVu7.woff +0 -0
- package/client/assets/material-icons-sharp-gidztirS.woff2 +0 -0
- package/client/assets/material-icons-two-tone-B7wz7mED.woff +0 -0
- package/client/assets/material-icons-two-tone-DuNIpaEj.woff2 +0 -0
- package/client/assets/mulmo_bw-ERmkSv0a.png +0 -0
- package/client/assets/purify.es-Fx1Nqyry-PeS5RUhs.js +2 -0
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +1 -0
- package/client/index.html +28 -0
- package/package.json +66 -0
- package/server/agent/attachmentConverter.ts +270 -0
- package/server/agent/config.ts +414 -0
- package/server/agent/index.ts +260 -0
- package/server/agent/mcp-server.ts +412 -0
- package/server/agent/mcp-tools/index.ts +63 -0
- package/server/agent/mcp-tools/x.ts +188 -0
- package/server/agent/plugin-names.ts +75 -0
- package/server/agent/prompt.ts +349 -0
- package/server/agent/resumeFailover.ts +129 -0
- package/server/agent/sandboxMounts.ts +329 -0
- package/server/agent/stream.ts +194 -0
- package/server/api/auth/bearerAuth.ts +61 -0
- package/server/api/auth/token.ts +98 -0
- package/server/api/csrfGuard.ts +85 -0
- package/server/api/routes/agent.ts +478 -0
- package/server/api/routes/chart.ts +98 -0
- package/server/api/routes/chat-index.ts +46 -0
- package/server/api/routes/config.ts +258 -0
- package/server/api/routes/dispatchResponse.ts +79 -0
- package/server/api/routes/files.ts +812 -0
- package/server/api/routes/html.ts +101 -0
- package/server/api/routes/image.ts +169 -0
- package/server/api/routes/mulmo-script.ts +712 -0
- package/server/api/routes/mulmoScriptValidate.ts +101 -0
- package/server/api/routes/notifications.ts +69 -0
- package/server/api/routes/pdf.ts +163 -0
- package/server/api/routes/plugins.ts +276 -0
- package/server/api/routes/presentHtml.ts +48 -0
- package/server/api/routes/roles.ts +125 -0
- package/server/api/routes/scheduler.ts +153 -0
- package/server/api/routes/schedulerHandlers.ts +151 -0
- package/server/api/routes/schedulerTasks.ts +163 -0
- package/server/api/routes/sessions.ts +294 -0
- package/server/api/routes/sessionsCursor.ts +59 -0
- package/server/api/routes/skills.ts +195 -0
- package/server/api/routes/sources.ts +540 -0
- package/server/api/routes/todos.ts +263 -0
- package/server/api/routes/todosColumnsHandlers.ts +347 -0
- package/server/api/routes/todosHandlers.ts +274 -0
- package/server/api/routes/todosItemsHandlers.ts +386 -0
- package/server/api/routes/wiki/pageIndex.ts +53 -0
- package/server/api/routes/wiki.ts +363 -0
- package/server/api/sandboxStatus.ts +64 -0
- package/server/events/notifications.ts +160 -0
- package/server/events/pub-sub/index.ts +45 -0
- package/server/events/relay-client.ts +288 -0
- package/server/events/scheduler-adapter.ts +302 -0
- package/server/events/session-store/index.ts +492 -0
- package/server/events/task-manager/index.ts +181 -0
- package/server/index.ts +572 -0
- package/server/system/config.ts +243 -0
- package/server/system/credentials.ts +220 -0
- package/server/system/docker.ts +97 -0
- package/server/system/env.ts +109 -0
- package/server/system/logger/config.ts +112 -0
- package/server/system/logger/formatters.ts +40 -0
- package/server/system/logger/index.ts +53 -0
- package/server/system/logger/rotation.ts +37 -0
- package/server/system/logger/sinks.ts +101 -0
- package/server/system/logger/types.ts +29 -0
- package/server/utils/date.ts +57 -0
- package/server/utils/errors.ts +7 -0
- package/server/utils/fetch.ts +27 -0
- package/server/utils/files/atomic.ts +125 -0
- package/server/utils/files/html-io.ts +20 -0
- package/server/utils/files/image-store.ts +66 -0
- package/server/utils/files/index.ts +45 -0
- package/server/utils/files/journal-io.ts +213 -0
- package/server/utils/files/json.ts +69 -0
- package/server/utils/files/markdown-store.ts +33 -0
- package/server/utils/files/naming.ts +50 -0
- package/server/utils/files/reference-dirs-io.ts +45 -0
- package/server/utils/files/roles-io.ts +45 -0
- package/server/utils/files/safe.ts +106 -0
- package/server/utils/files/scheduler-io.ts +20 -0
- package/server/utils/files/scheduler-overrides-io.ts +64 -0
- package/server/utils/files/session-io.ts +136 -0
- package/server/utils/files/spreadsheet-store.ts +63 -0
- package/server/utils/files/todos-io.ts +29 -0
- package/server/utils/files/user-tasks-io.ts +25 -0
- package/server/utils/files/workspace-io.ts +221 -0
- package/server/utils/gemini.ts +59 -0
- package/server/utils/gitignore.ts +69 -0
- package/server/utils/http.ts +15 -0
- package/server/utils/httpError.ts +61 -0
- package/server/utils/id.ts +16 -0
- package/server/utils/json.ts +83 -0
- package/server/utils/logBackgroundError.ts +22 -0
- package/server/utils/markdown.ts +82 -0
- package/server/utils/request.ts +29 -0
- package/server/utils/slug.ts +50 -0
- package/server/utils/spawn.ts +62 -0
- package/server/utils/time.ts +34 -0
- package/server/utils/types.ts +47 -0
- package/server/workspace/chat-index/index.ts +153 -0
- package/server/workspace/chat-index/indexer.ts +209 -0
- package/server/workspace/chat-index/paths.ts +34 -0
- package/server/workspace/chat-index/summarizer.ts +247 -0
- package/server/workspace/chat-index/types.ts +38 -0
- package/server/workspace/custom-dirs.ts +220 -0
- package/server/workspace/helps/business.md +104 -0
- package/server/workspace/helps/github.md +23 -0
- package/server/workspace/helps/index.md +60 -0
- package/server/workspace/helps/mulmoscript.md +249 -0
- package/server/workspace/helps/sandbox.md +90 -0
- package/server/workspace/helps/spreadsheet.md +43 -0
- package/server/workspace/helps/telegram.md +135 -0
- package/server/workspace/helps/wiki.md +131 -0
- package/server/workspace/journal/archivist.ts +386 -0
- package/server/workspace/journal/dailyPass.ts +743 -0
- package/server/workspace/journal/diff.ts +71 -0
- package/server/workspace/journal/index.ts +185 -0
- package/server/workspace/journal/indexFile.ts +136 -0
- package/server/workspace/journal/linkRewrite.ts +4 -0
- package/server/workspace/journal/memoryExtractor.ts +130 -0
- package/server/workspace/journal/optimizationPass.ts +160 -0
- package/server/workspace/journal/paths.ts +76 -0
- package/server/workspace/journal/state.ts +125 -0
- package/server/workspace/paths.ts +158 -0
- package/server/workspace/reference-dirs.ts +252 -0
- package/server/workspace/roles.ts +37 -0
- package/server/workspace/skills/discovery.ts +125 -0
- package/server/workspace/skills/index.ts +10 -0
- package/server/workspace/skills/parser.ts +144 -0
- package/server/workspace/skills/paths.ts +41 -0
- package/server/workspace/skills/scheduler.ts +149 -0
- package/server/workspace/skills/types.ts +30 -0
- package/server/workspace/skills/user-tasks.ts +257 -0
- package/server/workspace/skills/writer.ts +189 -0
- package/server/workspace/sources/arxivDiscovery.ts +182 -0
- package/server/workspace/sources/classifier.ts +268 -0
- package/server/workspace/sources/fetchers/arxiv.ts +170 -0
- package/server/workspace/sources/fetchers/github.ts +106 -0
- package/server/workspace/sources/fetchers/githubIssues.ts +208 -0
- package/server/workspace/sources/fetchers/githubReleases.ts +186 -0
- package/server/workspace/sources/fetchers/index.ts +71 -0
- package/server/workspace/sources/fetchers/registerAll.ts +15 -0
- package/server/workspace/sources/fetchers/rss.ts +141 -0
- package/server/workspace/sources/fetchers/rssParser.ts +295 -0
- package/server/workspace/sources/httpFetcher.ts +230 -0
- package/server/workspace/sources/interests.ts +120 -0
- package/server/workspace/sources/paths.ts +110 -0
- package/server/workspace/sources/pipeline/dedup.ts +60 -0
- package/server/workspace/sources/pipeline/fetch.ts +136 -0
- package/server/workspace/sources/pipeline/index.ts +249 -0
- package/server/workspace/sources/pipeline/notify.ts +72 -0
- package/server/workspace/sources/pipeline/plan.ts +66 -0
- package/server/workspace/sources/pipeline/summarize.ts +189 -0
- package/server/workspace/sources/pipeline/write.ts +185 -0
- package/server/workspace/sources/rateLimiter.ts +148 -0
- package/server/workspace/sources/registry.ts +326 -0
- package/server/workspace/sources/robots.ts +271 -0
- package/server/workspace/sources/sourceState.ts +135 -0
- package/server/workspace/sources/taxonomy.ts +74 -0
- package/server/workspace/sources/types.ts +144 -0
- package/server/workspace/sources/urls.ts +112 -0
- package/server/workspace/tool-trace/classify.ts +114 -0
- package/server/workspace/tool-trace/index.ts +250 -0
- package/server/workspace/tool-trace/writeSearch.ts +98 -0
- package/server/workspace/wiki-backlinks/index.ts +107 -0
- package/server/workspace/wiki-backlinks/sessionBacklinks.ts +144 -0
- package/server/workspace/workspace.ts +66 -0
- package/src/App.vue +720 -0
- package/src/assets/mulmo_bw.png +0 -0
- package/src/components/CanvasViewToggle.vue +27 -0
- package/src/components/ChatAttachmentPreview.vue +45 -0
- package/src/components/ChatImagePreview.vue +17 -0
- package/src/components/ChatInput.vue +208 -0
- package/src/components/FileContentHeader.vue +49 -0
- package/src/components/FileContentRenderer.vue +162 -0
- package/src/components/FileTree.vue +115 -0
- package/src/components/FileTreePane.vue +85 -0
- package/src/components/FilesView.vue +206 -0
- package/src/components/LockStatusPopup.vue +111 -0
- package/src/components/NotificationBell.vue +131 -0
- package/src/components/NotificationToast.vue +72 -0
- package/src/components/PluginLauncher.vue +138 -0
- package/src/components/RightSidebar.vue +113 -0
- package/src/components/RoleSelector.vue +64 -0
- package/src/components/SessionHistoryPanel.vue +176 -0
- package/src/components/SessionTabBar.vue +81 -0
- package/src/components/SettingsMcpTab.vue +350 -0
- package/src/components/SettingsModal.vue +275 -0
- package/src/components/SettingsReferenceDirsTab.vue +173 -0
- package/src/components/SettingsWorkspaceDirsTab.vue +174 -0
- package/src/components/SidebarHeader.vue +69 -0
- package/src/components/StackView.vue +360 -0
- package/src/components/SuggestionsPanel.vue +65 -0
- package/src/components/TodoExplorer.vue +358 -0
- package/src/components/ToolResultsPanel.vue +77 -0
- package/src/components/todo/TodoAddDialog.vue +131 -0
- package/src/components/todo/TodoEditDialog.vue +47 -0
- package/src/components/todo/TodoEditPanel.vue +113 -0
- package/src/components/todo/TodoKanbanView.vue +249 -0
- package/src/components/todo/TodoListView.vue +79 -0
- package/src/components/todo/TodoTableView.vue +177 -0
- package/src/composables/useActiveSession.ts +40 -0
- package/src/composables/useAppApi.ts +45 -0
- package/src/composables/useCanvasViewMode.ts +121 -0
- package/src/composables/useChatScroll.ts +47 -0
- package/src/composables/useClickOutside.ts +26 -0
- package/src/composables/useClipboardCopy.ts +44 -0
- package/src/composables/useContentDisplay.ts +52 -0
- package/src/composables/useDebugBeat.ts +23 -0
- package/src/composables/useDynamicFavicon.ts +115 -0
- package/src/composables/useEventListeners.ts +42 -0
- package/src/composables/useExpandedDirs.ts +64 -0
- package/src/composables/useFaviconState.ts +30 -0
- package/src/composables/useFileSelection.ts +115 -0
- package/src/composables/useFileSortMode.ts +24 -0
- package/src/composables/useFileTree.ts +85 -0
- package/src/composables/useFreshPluginData.ts +89 -0
- package/src/composables/useHealth.ts +38 -0
- package/src/composables/useImeAwareEnter.ts +57 -0
- package/src/composables/useKeyNavigation.ts +60 -0
- package/src/composables/useMarkdownLinkHandler.ts +46 -0
- package/src/composables/useMarkdownMode.ts +17 -0
- package/src/composables/useMcpTools.ts +71 -0
- package/src/composables/useMergedSessions.ts +27 -0
- package/src/composables/useNotifications.ts +90 -0
- package/src/composables/usePdfDownload.ts +60 -0
- package/src/composables/usePendingCalls.ts +77 -0
- package/src/composables/usePubSub.ts +85 -0
- package/src/composables/useRightSidebar.ts +23 -0
- package/src/composables/useRoles.ts +34 -0
- package/src/composables/useSandboxStatus.ts +67 -0
- package/src/composables/useSelectedResult.ts +49 -0
- package/src/composables/useSessionDerived.ts +51 -0
- package/src/composables/useSessionHistory.ts +81 -0
- package/src/composables/useSessionSync.ts +57 -0
- package/src/composables/useViewLayout.ts +55 -0
- package/src/config/apiRoutes.ts +173 -0
- package/src/config/pubsubChannels.ts +45 -0
- package/src/config/roles.ts +335 -0
- package/src/config/schedulerActions.ts +25 -0
- package/src/config/toolNames.ts +71 -0
- package/src/config/workspacePaths.ts +24 -0
- package/src/index.css +107 -0
- package/src/main.ts +25 -0
- package/src/plugins/canvas/Preview.vue +13 -0
- package/src/plugins/canvas/View.vue +333 -0
- package/src/plugins/canvas/definition.ts +38 -0
- package/src/plugins/canvas/index.ts +36 -0
- package/src/plugins/chart/Preview.vue +49 -0
- package/src/plugins/chart/View.vue +143 -0
- package/src/plugins/chart/definition.ts +58 -0
- package/src/plugins/chart/index.ts +52 -0
- package/src/plugins/editImage/Preview.vue +13 -0
- package/src/plugins/editImage/View.vue +13 -0
- package/src/plugins/editImage/definition.ts +27 -0
- package/src/plugins/editImage/index.ts +36 -0
- package/src/plugins/generateImage/Preview.vue +13 -0
- package/src/plugins/generateImage/View.vue +33 -0
- package/src/plugins/generateImage/definition.ts +32 -0
- package/src/plugins/generateImage/index.ts +56 -0
- package/src/plugins/manageRoles/Preview.vue +49 -0
- package/src/plugins/manageRoles/View.vue +525 -0
- package/src/plugins/manageRoles/definition.ts +43 -0
- package/src/plugins/manageRoles/index.ts +47 -0
- package/src/plugins/manageSkills/Preview.vue +21 -0
- package/src/plugins/manageSkills/View.vue +321 -0
- package/src/plugins/manageSkills/definition.ts +49 -0
- package/src/plugins/manageSkills/index.ts +49 -0
- package/src/plugins/manageSource/Preview.vue +33 -0
- package/src/plugins/manageSource/View.vue +697 -0
- package/src/plugins/manageSource/definition.ts +63 -0
- package/src/plugins/manageSource/index.ts +66 -0
- package/src/plugins/markdown/Preview.vue +77 -0
- package/src/plugins/markdown/View.vue +476 -0
- package/src/plugins/markdown/definition.ts +50 -0
- package/src/plugins/markdown/index.ts +36 -0
- package/src/plugins/presentHtml/Preview.vue +25 -0
- package/src/plugins/presentHtml/View.vue +52 -0
- package/src/plugins/presentHtml/definition.ts +27 -0
- package/src/plugins/presentHtml/helpers.ts +72 -0
- package/src/plugins/presentHtml/index.ts +41 -0
- package/src/plugins/presentMulmoScript/Preview.vue +23 -0
- package/src/plugins/presentMulmoScript/View.vue +1166 -0
- package/src/plugins/presentMulmoScript/definition.ts +95 -0
- package/src/plugins/presentMulmoScript/helpers.ts +162 -0
- package/src/plugins/presentMulmoScript/index.ts +40 -0
- package/src/plugins/scheduler/Preview.vue +67 -0
- package/src/plugins/scheduler/TasksTab.vue +205 -0
- package/src/plugins/scheduler/View.vue +565 -0
- package/src/plugins/scheduler/definition.ts +57 -0
- package/src/plugins/scheduler/index.ts +45 -0
- package/src/plugins/scheduler/viewModes.ts +26 -0
- package/src/plugins/spreadsheet/Preview.vue +29 -0
- package/src/plugins/spreadsheet/View.vue +997 -0
- package/src/plugins/spreadsheet/cellHighlights.ts +79 -0
- package/src/plugins/spreadsheet/definition.ts +121 -0
- package/src/plugins/spreadsheet/engine/calculator.ts +459 -0
- package/src/plugins/spreadsheet/engine/cellBuilder.ts +81 -0
- package/src/plugins/spreadsheet/engine/date-parser.ts +220 -0
- package/src/plugins/spreadsheet/engine/date-utils.ts +56 -0
- package/src/plugins/spreadsheet/engine/engine.ts +176 -0
- package/src/plugins/spreadsheet/engine/evaluator.ts +390 -0
- package/src/plugins/spreadsheet/engine/formatter.ts +172 -0
- package/src/plugins/spreadsheet/engine/formulaRefs.ts +101 -0
- package/src/plugins/spreadsheet/engine/functions/date.ts +299 -0
- package/src/plugins/spreadsheet/engine/functions/financial.ts +387 -0
- package/src/plugins/spreadsheet/engine/functions/index.ts +16 -0
- package/src/plugins/spreadsheet/engine/functions/logical.ts +262 -0
- package/src/plugins/spreadsheet/engine/functions/lookup.ts +400 -0
- package/src/plugins/spreadsheet/engine/functions/mathematical.ts +297 -0
- package/src/plugins/spreadsheet/engine/functions/statistical.ts +338 -0
- package/src/plugins/spreadsheet/engine/functions/text.ts +389 -0
- package/src/plugins/spreadsheet/engine/index.ts +27 -0
- package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +111 -0
- package/src/plugins/spreadsheet/engine/parser.ts +143 -0
- package/src/plugins/spreadsheet/engine/registry.ts +150 -0
- package/src/plugins/spreadsheet/engine/responseDecoder.ts +67 -0
- package/src/plugins/spreadsheet/engine/types.ts +64 -0
- package/src/plugins/spreadsheet/index.ts +36 -0
- package/src/plugins/textResponse/Preview.vue +94 -0
- package/src/plugins/textResponse/View.vue +503 -0
- package/src/plugins/textResponse/definition.ts +34 -0
- package/src/plugins/textResponse/index.ts +27 -0
- package/src/plugins/textResponse/plugin.ts +29 -0
- package/src/plugins/textResponse/samples.ts +97 -0
- package/src/plugins/textResponse/types.ts +11 -0
- package/src/plugins/todo/Preview.vue +63 -0
- package/src/plugins/todo/View.vue +364 -0
- package/src/plugins/todo/composables/useTodos.ts +177 -0
- package/src/plugins/todo/definition.ts +45 -0
- package/src/plugins/todo/index.ts +61 -0
- package/src/plugins/todo/labels.ts +163 -0
- package/src/plugins/todo/priority.ts +98 -0
- package/src/plugins/todo/viewModes.ts +19 -0
- package/src/plugins/ui-image/ImagePreview.vue +23 -0
- package/src/plugins/ui-image/ImageView.vue +34 -0
- package/src/plugins/ui-image/index.ts +3 -0
- package/src/plugins/ui-image/types.ts +4 -0
- package/src/plugins/wiki/Preview.vue +65 -0
- package/src/plugins/wiki/View.vue +342 -0
- package/src/plugins/wiki/definition.ts +25 -0
- package/src/plugins/wiki/helpers.ts +59 -0
- package/src/plugins/wiki/index.ts +52 -0
- package/src/router/guards.ts +61 -0
- package/src/router/index.ts +50 -0
- package/src/tools/index.ts +52 -0
- package/src/tools/types.ts +27 -0
- package/src/types/events.ts +16 -0
- package/src/types/fileTree.ts +13 -0
- package/src/types/notification.ts +67 -0
- package/src/types/session.ts +116 -0
- package/src/types/sse.ts +90 -0
- package/src/types/toolCallHistory.ts +13 -0
- package/src/utils/agent/eventDispatch.ts +74 -0
- package/src/utils/agent/request.ts +55 -0
- package/src/utils/agent/toolCalls.ts +62 -0
- package/src/utils/api.ts +218 -0
- package/src/utils/canvas/viewMode.ts +46 -0
- package/src/utils/dom/authTokenMeta.ts +20 -0
- package/src/utils/dom/clickOutside.ts +11 -0
- package/src/utils/dom/externalLink.ts +57 -0
- package/src/utils/dom/scrollable.ts +24 -0
- package/src/utils/errors.ts +11 -0
- package/src/utils/files/expandedDirs.ts +25 -0
- package/src/utils/files/filename.ts +12 -0
- package/src/utils/files/sortChildren.ts +20 -0
- package/src/utils/filesPreview/schedulerPreview.ts +38 -0
- package/src/utils/filesPreview/todoPreview.ts +40 -0
- package/src/utils/format/date.ts +85 -0
- package/src/utils/format/frontmatter.ts +80 -0
- package/src/utils/format/jsonSyntax.ts +109 -0
- package/src/utils/html/previewCsp.ts +65 -0
- package/src/utils/image/resolve.ts +8 -0
- package/src/utils/image/rewriteMarkdownImageRefs.ts +182 -0
- package/src/utils/markdown/extractFirstH1.ts +39 -0
- package/src/utils/notification/dispatch.ts +22 -0
- package/src/utils/path/relativeLink.ts +130 -0
- package/src/utils/role/icon.ts +20 -0
- package/src/utils/role/merge.ts +10 -0
- package/src/utils/role/plugins.ts +12 -0
- package/src/utils/session/mergeSessions.ts +103 -0
- package/src/utils/session/seedRoleDefault.ts +35 -0
- package/src/utils/session/sessionEntries.ts +121 -0
- package/src/utils/session/sessionFactory.ts +22 -0
- package/src/utils/session/sessionHelpers.ts +99 -0
- package/src/utils/tools/dedup.ts +17 -0
- package/src/utils/tools/mcp.ts +33 -0
- package/src/utils/tools/pendingCalls.ts +16 -0
- package/src/utils/tools/result.ts +40 -0
- package/src/utils/types.ts +44 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Host-credential mounts for the Docker sandbox (#259).
|
|
2
|
+
//
|
|
3
|
+
// Two independent opt-in mechanisms, composable:
|
|
4
|
+
//
|
|
5
|
+
// SANDBOX_SSH_AGENT_FORWARD=1
|
|
6
|
+
// Bind-mounts $SSH_AUTH_SOCK into the container and sets
|
|
7
|
+
// SSH_AUTH_SOCK to the container path. Private keys stay on the
|
|
8
|
+
// host — the agent on the host signs on behalf of the container.
|
|
9
|
+
//
|
|
10
|
+
// SANDBOX_MOUNT_CONFIGS=gh,gitconfig
|
|
11
|
+
// CSV of allowlisted config mounts. Each name resolves to a fixed
|
|
12
|
+
// host path via the server-side ALLOWED_CONFIG_MOUNTS map; users
|
|
13
|
+
// cannot pass arbitrary paths.
|
|
14
|
+
//
|
|
15
|
+
// See docs/sandbox-credentials.md for the user-facing contract.
|
|
16
|
+
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { log } from "../system/logger/index.js";
|
|
22
|
+
import { SUBPROCESS_PROBE_TIMEOUT_MS } from "../utils/time.js";
|
|
23
|
+
|
|
24
|
+
// ── Config-mount allowlist ──────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface SandboxMountSpec {
|
|
27
|
+
/** The short name users type in SANDBOX_MOUNT_CONFIGS. */
|
|
28
|
+
name: string;
|
|
29
|
+
/** Absolute path on the host. Resolved from `$HOME` at lookup. */
|
|
30
|
+
hostPath: string;
|
|
31
|
+
/** Absolute path inside the container (must match where the tool looks). */
|
|
32
|
+
containerPath: string;
|
|
33
|
+
/** Whether the host path is expected to be a file or a directory. */
|
|
34
|
+
kind: "file" | "dir";
|
|
35
|
+
/** Short human description — shown in docs and in startup logs. */
|
|
36
|
+
description: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the allowlist. Parameterized on `home` so tests can inject a
|
|
41
|
+
* temp directory without touching the real filesystem.
|
|
42
|
+
*
|
|
43
|
+
* To add a new tool:
|
|
44
|
+
* 1. Append a row here with the host path the tool reads on startup
|
|
45
|
+
* and the container path it should find the same file at.
|
|
46
|
+
* 2. Add a row in docs/sandbox-credentials.md.
|
|
47
|
+
* 3. That's it — no env var changes, no parser changes.
|
|
48
|
+
*/
|
|
49
|
+
export function buildAllowedConfigMounts(home: string = homedir()): Record<string, SandboxMountSpec> {
|
|
50
|
+
return {
|
|
51
|
+
gh: {
|
|
52
|
+
name: "gh",
|
|
53
|
+
hostPath: path.join(home, ".config", "gh"),
|
|
54
|
+
containerPath: "/home/node/.config/gh",
|
|
55
|
+
kind: "dir",
|
|
56
|
+
description: "GitHub CLI auth token + hosts config",
|
|
57
|
+
},
|
|
58
|
+
gitconfig: {
|
|
59
|
+
name: "gitconfig",
|
|
60
|
+
hostPath: path.join(home, ".gitconfig"),
|
|
61
|
+
containerPath: "/home/node/.gitconfig",
|
|
62
|
+
kind: "file",
|
|
63
|
+
description: "Git user identity (name, email, signing key)",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Name parsing / validation ──────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export interface ParsedMountList {
|
|
71
|
+
/** Names that resolved to a spec. Order preserved. */
|
|
72
|
+
resolved: SandboxMountSpec[];
|
|
73
|
+
/** Names the user requested that aren't in the allowlist. */
|
|
74
|
+
unknown: string[];
|
|
75
|
+
/** Names whose host path does not exist — silently skipped. */
|
|
76
|
+
missing: SandboxMountSpec[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse a CSV list of mount names, resolve against the allowlist,
|
|
81
|
+
* check that each host path exists. The three output buckets let the
|
|
82
|
+
* caller decide what to error on (unknown) vs warn on (missing).
|
|
83
|
+
*/
|
|
84
|
+
export function resolveMountNames(names: readonly string[], allowed: Record<string, SandboxMountSpec> = buildAllowedConfigMounts()): ParsedMountList {
|
|
85
|
+
const resolved: SandboxMountSpec[] = [];
|
|
86
|
+
const unknown: string[] = [];
|
|
87
|
+
const missing: SandboxMountSpec[] = [];
|
|
88
|
+
|
|
89
|
+
for (const raw of names) {
|
|
90
|
+
const name = raw.trim();
|
|
91
|
+
if (!name) continue;
|
|
92
|
+
const spec = allowed[name];
|
|
93
|
+
if (!spec) {
|
|
94
|
+
unknown.push(name);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!hostPathExists(spec)) {
|
|
98
|
+
missing.push(spec);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
resolved.push(spec);
|
|
102
|
+
}
|
|
103
|
+
return { resolved, unknown, missing };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hostPathExists(spec: SandboxMountSpec): boolean {
|
|
107
|
+
try {
|
|
108
|
+
const stat = fs.statSync(spec.hostPath);
|
|
109
|
+
return spec.kind === "dir" ? stat.isDirectory() : stat.isFile();
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Docker arg generation ──────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Return the `-v ...` argument pairs for the given resolved mounts.
|
|
119
|
+
* Always read-only. The caller splices these into the full docker
|
|
120
|
+
* argv in `buildDockerSpawnArgs`.
|
|
121
|
+
*/
|
|
122
|
+
export function configMountArgs(resolved: readonly SandboxMountSpec[]): string[] {
|
|
123
|
+
const args: string[] = [];
|
|
124
|
+
for (const spec of resolved) {
|
|
125
|
+
args.push("-v", `${toDockerPath(spec.hostPath)}:${spec.containerPath}:ro`);
|
|
126
|
+
}
|
|
127
|
+
return args;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── SSH agent forward ──────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/** Absolute container path the agent socket is bound to. */
|
|
133
|
+
export const SSH_AGENT_CONTAINER_SOCK = "/ssh-agent";
|
|
134
|
+
|
|
135
|
+
export interface SshAgentForwardResult {
|
|
136
|
+
args: string[];
|
|
137
|
+
/** When null, forward was requested but not possible; caller decides
|
|
138
|
+
* whether to log once (we always log in the production driver). */
|
|
139
|
+
skippedReason: string | null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Docker Desktop for Mac exposes the host SSH agent through a
|
|
143
|
+
// well-known magic socket inside the VM. Direct bind-mounting the
|
|
144
|
+
// macOS $SSH_AUTH_SOCK (/private/tmp/…) fails with "operation not
|
|
145
|
+
// supported" because Docker's Linux VM can't mkdir a Unix socket.
|
|
146
|
+
// Using the magic path sidesteps the issue entirely and works on
|
|
147
|
+
// Docker Desktop ≥ 2.3.0 (2020+).
|
|
148
|
+
const DOCKER_DESKTOP_MAC_SSH_SOCK = "/run/host-services/ssh-auth.sock";
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Return the docker argv fragment that forwards the host SSH agent
|
|
152
|
+
* into the container. On macOS + Docker Desktop, the built-in
|
|
153
|
+
* magic socket is used instead of a raw bind-mount. On Linux, the
|
|
154
|
+
* host `$SSH_AUTH_SOCK` is bind-mounted directly.
|
|
155
|
+
*
|
|
156
|
+
* Skipped (empty args + reason) when:
|
|
157
|
+
* - the flag is off
|
|
158
|
+
* - $SSH_AUTH_SOCK isn't set (no agent running on host) — on
|
|
159
|
+
* non-macOS only; macOS always has the magic socket available
|
|
160
|
+
* when Docker Desktop is running, regardless of $SSH_AUTH_SOCK
|
|
161
|
+
*/
|
|
162
|
+
export function sshAgentForwardArgs(
|
|
163
|
+
enabled: boolean,
|
|
164
|
+
sshAuthSock: string | undefined,
|
|
165
|
+
platform: typeof process.platform = process.platform,
|
|
166
|
+
): SshAgentForwardResult {
|
|
167
|
+
if (!enabled) return { args: [], skippedReason: null };
|
|
168
|
+
|
|
169
|
+
// macOS + Docker Desktop: use the magic VM-internal socket.
|
|
170
|
+
if (platform === "darwin") {
|
|
171
|
+
return {
|
|
172
|
+
args: ["-v", `${DOCKER_DESKTOP_MAC_SSH_SOCK}:${SSH_AGENT_CONTAINER_SOCK}`, "-e", `SSH_AUTH_SOCK=${SSH_AGENT_CONTAINER_SOCK}`],
|
|
173
|
+
skippedReason: null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Linux / other: bind-mount the host socket directly.
|
|
178
|
+
if (!sshAuthSock || sshAuthSock.length === 0) {
|
|
179
|
+
return {
|
|
180
|
+
args: [],
|
|
181
|
+
skippedReason: "SSH_AUTH_SOCK not set on host",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (!fs.existsSync(sshAuthSock)) {
|
|
185
|
+
return {
|
|
186
|
+
args: [],
|
|
187
|
+
skippedReason: `SSH_AUTH_SOCK=${sshAuthSock} not found on host`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
args: ["-v", `${toDockerPath(sshAuthSock)}:${SSH_AGENT_CONTAINER_SOCK}`, "-e", `SSH_AUTH_SOCK=${SSH_AGENT_CONTAINER_SOCK}`],
|
|
192
|
+
skippedReason: null,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Top-level resolver used by buildDockerSpawnArgs ────────────────
|
|
197
|
+
|
|
198
|
+
export interface ResolvedSandboxAuth {
|
|
199
|
+
/** docker argv additions: a list of `-v` / `-e` tokens. */
|
|
200
|
+
args: string[];
|
|
201
|
+
/** Descriptions the caller can log once to show what got mounted. */
|
|
202
|
+
appliedDescriptions: string[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface ResolveSandboxAuthParams {
|
|
206
|
+
sshAgentForward: boolean;
|
|
207
|
+
/** Comma-separated host whitelist for the SSH agent. Default
|
|
208
|
+
* "github.com". Passed to the container as
|
|
209
|
+
* `SANDBOX_SSH_ALLOWED_HOSTS` and consumed by the entrypoint
|
|
210
|
+
* to generate a restrictive `~/.ssh/config`. */
|
|
211
|
+
sshAllowedHosts?: string;
|
|
212
|
+
configMountNames: readonly string[];
|
|
213
|
+
sshAuthSock?: string | undefined;
|
|
214
|
+
home?: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Combine the two mechanisms. Emits a `log.warn` for unknown names
|
|
219
|
+
* (configuration error the user should fix), a `log.info` for missing
|
|
220
|
+
* paths (expected when a user hasn't set up the tool), and a
|
|
221
|
+
* `log.info` line listing what actually got mounted so the startup
|
|
222
|
+
* log shows the sandbox's effective auth posture.
|
|
223
|
+
*/
|
|
224
|
+
export function resolveSandboxAuth(params: ResolveSandboxAuthParams): ResolvedSandboxAuth {
|
|
225
|
+
const home = params.home ?? homedir();
|
|
226
|
+
const allowed = buildAllowedConfigMounts(home);
|
|
227
|
+
const parsed = resolveMountNames(params.configMountNames, allowed);
|
|
228
|
+
|
|
229
|
+
if (parsed.unknown.length > 0) {
|
|
230
|
+
log.warn("sandbox", "unknown SANDBOX_MOUNT_CONFIGS entries ignored", {
|
|
231
|
+
unknown: parsed.unknown,
|
|
232
|
+
allowed: Object.keys(allowed),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
for (const spec of parsed.missing) {
|
|
236
|
+
log.info("sandbox", "config mount skipped (host path missing)", {
|
|
237
|
+
name: spec.name,
|
|
238
|
+
hostPath: spec.hostPath,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const sshResult = sshAgentForwardArgs(params.sshAgentForward, params.sshAuthSock);
|
|
243
|
+
if (sshResult.skippedReason !== null) {
|
|
244
|
+
log.warn("sandbox", "SSH agent forward requested but skipped", {
|
|
245
|
+
reason: sshResult.skippedReason,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Pass the allowed-hosts whitelist to the container so the
|
|
250
|
+
// entrypoint can generate a restrictive ~/.ssh/config. Only
|
|
251
|
+
// included when SSH agent forward is actually active.
|
|
252
|
+
const sshAllowedHostsArgs = sshResult.args.length > 0 && params.sshAllowedHosts ? ["-e", `SANDBOX_SSH_ALLOWED_HOSTS=${params.sshAllowedHosts}`] : [];
|
|
253
|
+
|
|
254
|
+
// gh CLI keyring fallback (#259 + #164). When the user opted in
|
|
255
|
+
// to `gh` via SANDBOX_MOUNT_CONFIGS but the file mount succeeded
|
|
256
|
+
// with a keyring-based token (macOS), the mounted hosts.yml won't
|
|
257
|
+
// contain the actual token. Detect this and inject GH_TOKEN env
|
|
258
|
+
// var instead. Only runs when "gh" was explicitly requested.
|
|
259
|
+
const ghTokenArgs = resolveGhTokenFallback(params.configMountNames, parsed);
|
|
260
|
+
|
|
261
|
+
const args = [...configMountArgs(parsed.resolved), ...sshResult.args, ...sshAllowedHostsArgs, ...ghTokenArgs.args];
|
|
262
|
+
const allowedHostsSuffix = sshResult.args.length > 0 && params.sshAllowedHosts ? ` → hosts: ${params.sshAllowedHosts}` : "";
|
|
263
|
+
const appliedDescriptions = [
|
|
264
|
+
...parsed.resolved.map((s) => `${s.name} (${s.description})`),
|
|
265
|
+
...(sshResult.args.length > 0 ? [`ssh-agent forward${allowedHostsSuffix}`] : []),
|
|
266
|
+
...(ghTokenArgs.args.length > 0 ? ["gh CLI (GH_TOKEN fallback)"] : []),
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
if (appliedDescriptions.length > 0) {
|
|
270
|
+
log.info("sandbox", "host credentials attached to container", {
|
|
271
|
+
mounts: appliedDescriptions,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { args, appliedDescriptions };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── GitHub CLI token fallback ──────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
// When the user opted in to `gh` via SANDBOX_MOUNT_CONFIGS, the
|
|
281
|
+
// file mount may not carry a usable token — macOS stores it in the
|
|
282
|
+
// system keyring, not in ~/.config/gh/hosts.yml. In that case we
|
|
283
|
+
// extract the token via `gh auth token` on the host and pass it as
|
|
284
|
+
// GH_TOKEN env var. This only runs when "gh" was explicitly
|
|
285
|
+
// requested (#259 opt-in principle).
|
|
286
|
+
function resolveGhTokenFallback(requestedNames: readonly string[], parsed: ParsedMountList): { args: string[] } {
|
|
287
|
+
const ghRequested = requestedNames.some((n) => n.trim() === "gh");
|
|
288
|
+
if (!ghRequested) return { args: [] };
|
|
289
|
+
|
|
290
|
+
// If an explicit GH_TOKEN is already in the environment, pass it.
|
|
291
|
+
if (process.env.GH_TOKEN) {
|
|
292
|
+
return { args: ["-e", `GH_TOKEN=${process.env.GH_TOKEN}`] };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// If the file mount resolved (hosts.yml exists), the token might
|
|
296
|
+
// be in the file. Check if it's keyring-based by looking for
|
|
297
|
+
// "oauth_token" in the hosts.yml — if missing, fall back.
|
|
298
|
+
const ghResolved = parsed.resolved.some((s) => s.name === "gh");
|
|
299
|
+
const ghMissing = parsed.missing.some((s) => s.name === "gh");
|
|
300
|
+
|
|
301
|
+
// gh dir doesn't exist at all → try extracting from keyring
|
|
302
|
+
// gh dir exists (mounted) → still try, since keyring auth leaves
|
|
303
|
+
// the file with no usable token
|
|
304
|
+
if (ghResolved || ghMissing || !ghResolved) {
|
|
305
|
+
try {
|
|
306
|
+
const token = execFileSync("gh", ["auth", "token"], {
|
|
307
|
+
encoding: "utf-8",
|
|
308
|
+
timeout: SUBPROCESS_PROBE_TIMEOUT_MS,
|
|
309
|
+
}).trim();
|
|
310
|
+
if (token.length > 0) {
|
|
311
|
+
log.info("sandbox", "gh token extracted from host keyring (GH_TOKEN fallback)");
|
|
312
|
+
return { args: ["-e", `GH_TOKEN=${token}`] };
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
log.info("sandbox", "gh auth token failed — gh CLI may not work in sandbox");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { args: [] };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Utilities ──────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
// Docker accepts POSIX-style paths even on Windows when using
|
|
325
|
+
// Docker Desktop, and the rest of the codebase already uses this
|
|
326
|
+
// helper in buildDockerSpawnArgs.
|
|
327
|
+
function toDockerPath(p: string): string {
|
|
328
|
+
return p.replace(/\\/g, "/");
|
|
329
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { EVENT_TYPES } from "../../src/types/events.js";
|
|
2
|
+
|
|
3
|
+
export type AgentEvent =
|
|
4
|
+
| { type: typeof EVENT_TYPES.status; message: string }
|
|
5
|
+
| { type: typeof EVENT_TYPES.text; message: string }
|
|
6
|
+
| { type: typeof EVENT_TYPES.toolResult; result: unknown }
|
|
7
|
+
| { type: typeof EVENT_TYPES.switchRole; roleId: string }
|
|
8
|
+
| { type: typeof EVENT_TYPES.error; message: string }
|
|
9
|
+
| {
|
|
10
|
+
type: typeof EVENT_TYPES.toolCall;
|
|
11
|
+
toolUseId: string;
|
|
12
|
+
toolName: string;
|
|
13
|
+
args: unknown;
|
|
14
|
+
}
|
|
15
|
+
| {
|
|
16
|
+
type: typeof EVENT_TYPES.toolCallResult;
|
|
17
|
+
toolUseId: string;
|
|
18
|
+
content: string;
|
|
19
|
+
}
|
|
20
|
+
| { type: typeof EVENT_TYPES.claudeSessionId; id: string };
|
|
21
|
+
|
|
22
|
+
export interface ClaudeContentBlock {
|
|
23
|
+
type: string;
|
|
24
|
+
id?: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
input?: unknown;
|
|
27
|
+
tool_use_id?: string;
|
|
28
|
+
content?: unknown;
|
|
29
|
+
/** Text content — present in `text` type blocks. */
|
|
30
|
+
text?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ClaudeMessage {
|
|
34
|
+
content?: ClaudeContentBlock[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ClaudeStreamEvent =
|
|
38
|
+
| { type: "assistant"; message: ClaudeMessage }
|
|
39
|
+
| { type: "user"; message: ClaudeMessage }
|
|
40
|
+
| { type: "result"; result: string; session_id?: string };
|
|
41
|
+
|
|
42
|
+
// stream_event sub-types emitted when --include-partial-messages is on.
|
|
43
|
+
export interface StreamEventDelta {
|
|
44
|
+
type: "content_block_delta";
|
|
45
|
+
index: number;
|
|
46
|
+
delta: { type: string; text?: string };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RawStreamEvent {
|
|
50
|
+
type: string;
|
|
51
|
+
message?: ClaudeMessage;
|
|
52
|
+
result?: string;
|
|
53
|
+
session_id?: string;
|
|
54
|
+
/** Present when type === "stream_event". Carries partial text
|
|
55
|
+
* deltas for real-time streaming. */
|
|
56
|
+
event?: StreamEventDelta | { type: string };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function blockToEvent(block: ClaudeContentBlock): AgentEvent | null {
|
|
60
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
61
|
+
return {
|
|
62
|
+
type: EVENT_TYPES.text,
|
|
63
|
+
message: block.text,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (block.type === "tool_use" && block.id && block.name) {
|
|
67
|
+
return {
|
|
68
|
+
type: EVENT_TYPES.toolCall,
|
|
69
|
+
toolUseId: block.id,
|
|
70
|
+
toolName: block.name,
|
|
71
|
+
args: block.input,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
75
|
+
const raw = block.content;
|
|
76
|
+
const content = typeof raw === "string" ? raw : raw === undefined ? "" : JSON.stringify(raw);
|
|
77
|
+
return {
|
|
78
|
+
type: EVENT_TYPES.toolCallResult,
|
|
79
|
+
toolUseId: block.tool_use_id,
|
|
80
|
+
content,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Extract a text delta from a stream_event, or null if the event
|
|
87
|
+
// isn't a text delta. Keeps the main parse function under the
|
|
88
|
+
// cognitive-complexity cap.
|
|
89
|
+
function extractTextDelta(event: RawStreamEvent): string | null {
|
|
90
|
+
if (event.type !== "stream_event" || !event.event) return null;
|
|
91
|
+
const inner = event.event;
|
|
92
|
+
if (inner.type !== "content_block_delta" || !("delta" in inner) || inner.delta.type !== "text_delta" || typeof inner.delta.text !== "string") {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return inner.delta.text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Filter assistant block events: when deltas already streamed the
|
|
99
|
+
// text, remove text-type events to prevent duplication.
|
|
100
|
+
function filterAssistantBlocks(blockEvents: AgentEvent[], deltaStreamed: boolean): AgentEvent[] {
|
|
101
|
+
return deltaStreamed ? blockEvents.filter((e) => e.type !== EVENT_TYPES.text) : blockEvents;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Stateful parser that deduplicates text across the three stages
|
|
105
|
+
// Claude CLI emits: stream_event deltas → assistant content blocks
|
|
106
|
+
// → result full text. Uses two flags:
|
|
107
|
+
//
|
|
108
|
+
// textStreamedFromDeltas — true once text_delta chunks have been
|
|
109
|
+
// emitted from stream_event. Controls whether the full-text
|
|
110
|
+
// `assistant` block is filtered as a duplicate of those chunks.
|
|
111
|
+
//
|
|
112
|
+
// textEmitted — true once ANY text (delta or assistant block) has
|
|
113
|
+
// been emitted, so the `result` event can suppress its duplicate
|
|
114
|
+
// full-text copy. Prevents text loss when `assistant` arrives
|
|
115
|
+
// without preceding `stream_event` deltas (short replies, CLI
|
|
116
|
+
// version without `--include-partial-messages`, etc.).
|
|
117
|
+
export function createStreamParser(): {
|
|
118
|
+
parse: (event: RawStreamEvent) => AgentEvent[];
|
|
119
|
+
} {
|
|
120
|
+
let textStreamedFromDeltas = false;
|
|
121
|
+
let textEmitted = false;
|
|
122
|
+
|
|
123
|
+
function parse(event: RawStreamEvent): AgentEvent[] {
|
|
124
|
+
// Handle streaming text deltas from --include-partial-messages.
|
|
125
|
+
const delta = extractTextDelta(event);
|
|
126
|
+
if (delta !== null) {
|
|
127
|
+
textStreamedFromDeltas = true;
|
|
128
|
+
textEmitted = true;
|
|
129
|
+
return [{ type: EVENT_TYPES.text, message: delta }];
|
|
130
|
+
}
|
|
131
|
+
if (event.type === "stream_event") return [];
|
|
132
|
+
|
|
133
|
+
if (event.type === "result") {
|
|
134
|
+
const events: AgentEvent[] = [];
|
|
135
|
+
if (!textEmitted && event.result) {
|
|
136
|
+
events.push({ type: EVENT_TYPES.text, message: event.result });
|
|
137
|
+
}
|
|
138
|
+
if (event.session_id) {
|
|
139
|
+
events.push({
|
|
140
|
+
type: EVENT_TYPES.claudeSessionId,
|
|
141
|
+
id: event.session_id,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
textStreamedFromDeltas = false;
|
|
145
|
+
textEmitted = false;
|
|
146
|
+
return events;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (event.type !== "assistant" && event.type !== "user") {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const content = event.message?.content;
|
|
154
|
+
const blockEvents = Array.isArray(content) ? content.map(blockToEvent).filter((e): e is AgentEvent => e !== null) : [];
|
|
155
|
+
|
|
156
|
+
if (event.type === "assistant") {
|
|
157
|
+
const filtered = filterAssistantBlocks(blockEvents, textStreamedFromDeltas);
|
|
158
|
+
if (filtered.some((e) => e.type === EVENT_TYPES.text)) {
|
|
159
|
+
textEmitted = true;
|
|
160
|
+
}
|
|
161
|
+
return [{ type: EVENT_TYPES.status, message: "Thinking..." }, ...filtered];
|
|
162
|
+
}
|
|
163
|
+
return blockEvents;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { parse };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Stateless convenience — used by tests and one-off parsing.
|
|
170
|
+
// For the agent loop, use createStreamParser() to get dedup.
|
|
171
|
+
export function parseStreamEvent(event: RawStreamEvent): AgentEvent[] {
|
|
172
|
+
if (event.type === "result" && event.result) {
|
|
173
|
+
const events: AgentEvent[] = [{ type: EVENT_TYPES.text, message: event.result }];
|
|
174
|
+
if (event.session_id) {
|
|
175
|
+
events.push({
|
|
176
|
+
type: EVENT_TYPES.claudeSessionId,
|
|
177
|
+
id: event.session_id,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return events;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (event.type !== "assistant" && event.type !== "user") {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const content = event.message?.content;
|
|
188
|
+
const blockEvents = Array.isArray(content) ? content.map(blockToEvent).filter((e): e is AgentEvent => e !== null) : [];
|
|
189
|
+
|
|
190
|
+
if (event.type === "assistant") {
|
|
191
|
+
return [{ type: EVENT_TYPES.status, message: "Thinking..." }, ...blockEvents];
|
|
192
|
+
}
|
|
193
|
+
return blockEvents;
|
|
194
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { timingSafeEqual } from "crypto";
|
|
2
|
+
|
|
3
|
+
function safeEqual(a: string, b: string): boolean {
|
|
4
|
+
if (a.length !== b.length) return false;
|
|
5
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Bearer token middleware (#272). Reject any `/api/*` request whose
|
|
9
|
+
// `Authorization: Bearer <token>` header doesn't match the current
|
|
10
|
+
// server token.
|
|
11
|
+
//
|
|
12
|
+
// This is the local-process isolation layer. `csrfGuard.ts` handles
|
|
13
|
+
// cross-origin browser attacks (layered on top, both must pass). This
|
|
14
|
+
// middleware handles the case a sibling process on the same machine
|
|
15
|
+
// (malicious program, another user, confused script) tries to hit
|
|
16
|
+
// `/api/*`: without the startup-regenerated token, every request is
|
|
17
|
+
// 401'd.
|
|
18
|
+
//
|
|
19
|
+
// Design choices:
|
|
20
|
+
// - **One exemption**: `/api/files/*` is bearer-exempt because `<img>`
|
|
21
|
+
// tags in rendered markdown (`presentDocument`, wiki) can't attach
|
|
22
|
+
// an `Authorization` header — the browser makes a plain GET. These
|
|
23
|
+
// endpoints are still CSRF-guarded (origin check) and the server
|
|
24
|
+
// binds to loopback only, so the exposure is localhost-scoped.
|
|
25
|
+
// The exemption is applied via a regex in `server/index.ts`.
|
|
26
|
+
// - **No token in logs**. Reject messages are generic ("unauthorized")
|
|
27
|
+
// so a leaked log line doesn't reveal whether "no header" vs
|
|
28
|
+
// "wrong token" — matches common auth-hardening guidance.
|
|
29
|
+
// - **Token comparison is `===`**. These are 64-char hex strings of
|
|
30
|
+
// identical length, so early-exit timing on length is moot. A
|
|
31
|
+
// length-mismatched header is already caught at the shape check,
|
|
32
|
+
// leaving only equal-length compares for real candidates.
|
|
33
|
+
|
|
34
|
+
import type { Request, Response, NextFunction } from "express";
|
|
35
|
+
import { getCurrentToken } from "./token.js";
|
|
36
|
+
import { unauthorized } from "../../utils/httpError.js";
|
|
37
|
+
|
|
38
|
+
const BEARER_PREFIX = "Bearer ";
|
|
39
|
+
|
|
40
|
+
export function bearerAuth(req: Request, res: Response, next: NextFunction): void {
|
|
41
|
+
const expected = getCurrentToken();
|
|
42
|
+
if (expected === null) {
|
|
43
|
+
// Server hasn't finished bootstrap. This can only happen if a
|
|
44
|
+
// request beats `generateAndWriteToken()` to completion — the
|
|
45
|
+
// server fixes that by generating before `app.listen`, but we
|
|
46
|
+
// still defend the middleware against out-of-order init.
|
|
47
|
+
unauthorized(res, "unauthorized");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const header = req.headers.authorization;
|
|
51
|
+
if (typeof header !== "string" || !header.startsWith(BEARER_PREFIX)) {
|
|
52
|
+
unauthorized(res, "unauthorized");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const provided = header.slice(BEARER_PREFIX.length);
|
|
56
|
+
if (!safeEqual(provided, expected)) {
|
|
57
|
+
unauthorized(res, "unauthorized");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
next();
|
|
61
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Bearer auth token (#272). One 32-byte hex token per server startup,
|
|
2
|
+
// held in memory and mirrored to a 0600 file at
|
|
3
|
+
// `WORKSPACE_PATHS.sessionToken`.
|
|
4
|
+
//
|
|
5
|
+
// **Why file-backed**: the token must travel out-of-process to (a) the
|
|
6
|
+
// Vite dev server's `transformIndexHtml` plugin so it can embed the
|
|
7
|
+
// token in the HTML Vue receives, and (b) CLI bridges (Phase 2) that
|
|
8
|
+
// share the workspace but live in a different process. Memory-only
|
|
9
|
+
// would force every reader to go through HTTP, which is the
|
|
10
|
+
// chicken-and-egg problem bearer auth is trying to fix.
|
|
11
|
+
//
|
|
12
|
+
// **Lifecycle**: generate on startup, write atomic, delete on graceful
|
|
13
|
+
// shutdown. A stale file after a crash is harmless — the next startup
|
|
14
|
+
// generates a fresh in-memory token and overwrites, so a stolen stale
|
|
15
|
+
// file value fails 401 against the running server.
|
|
16
|
+
//
|
|
17
|
+
// **Env override (#316)**: `MULMOCLAUDE_AUTH_TOKEN` (read via `env.ts`)
|
|
18
|
+
// pins the token across restarts so long-running bridges don't need a
|
|
19
|
+
// relaunch every time the server bounces. The client-side readers
|
|
20
|
+
// (`@mulmobridge/client` token.ts, Vite plugin) already honour the same var;
|
|
21
|
+
// setting it once on both sides survives restarts.
|
|
22
|
+
|
|
23
|
+
import { randomBytes } from "crypto";
|
|
24
|
+
import fs from "fs";
|
|
25
|
+
import { writeFileAtomic } from "../../utils/files/index.js";
|
|
26
|
+
import { log } from "../../system/logger/index.js";
|
|
27
|
+
import { isNonEmptyString } from "../../utils/types.js";
|
|
28
|
+
import { WORKSPACE_PATHS } from "../../workspace/paths.js";
|
|
29
|
+
|
|
30
|
+
const TOKEN_BYTES = 32; // 64 hex chars
|
|
31
|
+
// Below this length a random 32-byte token would be 64 hex chars;
|
|
32
|
+
// anything shorter from the env override is almost certainly a
|
|
33
|
+
// placeholder like "test" that leaked into production. Warn, don't
|
|
34
|
+
// block — the operator might have reasons we don't see.
|
|
35
|
+
const MIN_RECOMMENDED_CHARS = 32;
|
|
36
|
+
|
|
37
|
+
let currentToken: string | null = null;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The token the server is currently using. Null until
|
|
41
|
+
* `generateAndWriteToken` has been called. `bearerAuth` reads this on
|
|
42
|
+
* every request.
|
|
43
|
+
*/
|
|
44
|
+
export function getCurrentToken(): string | null {
|
|
45
|
+
return currentToken;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate (or take from the env override) the startup token, store
|
|
50
|
+
* it in memory, and mirror it to the workspace file (mode 0600,
|
|
51
|
+
* atomic).
|
|
52
|
+
*
|
|
53
|
+
* @param tokenPath Injected for tests so they can target a tmp
|
|
54
|
+
* directory; production callers rely on the default
|
|
55
|
+
* `WORKSPACE_PATHS.sessionToken`.
|
|
56
|
+
* @param override Injected for tests. Production callers pass
|
|
57
|
+
* `env.authTokenOverride` from `server/env.ts`. When non-empty the
|
|
58
|
+
* override is used verbatim instead of generating random bytes.
|
|
59
|
+
*/
|
|
60
|
+
export async function generateAndWriteToken(tokenPath: string = WORKSPACE_PATHS.sessionToken, override?: string): Promise<string> {
|
|
61
|
+
const token = resolveToken(override);
|
|
62
|
+
currentToken = token;
|
|
63
|
+
await writeFileAtomic(tokenPath, token, { mode: 0o600 });
|
|
64
|
+
return token;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveToken(override: string | undefined): string {
|
|
68
|
+
if (isNonEmptyString(override)) {
|
|
69
|
+
if (override.length < MIN_RECOMMENDED_CHARS) {
|
|
70
|
+
// Visible on startup so a half-typed override doesn't silently
|
|
71
|
+
// become a security hole in dev.
|
|
72
|
+
log.warn("auth", "MULMOCLAUDE_AUTH_TOKEN is shorter than the recommended 32 characters", { length: override.length });
|
|
73
|
+
}
|
|
74
|
+
return override;
|
|
75
|
+
}
|
|
76
|
+
return randomBytes(TOKEN_BYTES).toString("hex");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Best-effort removal of the token file. Never throws; a missing file
|
|
81
|
+
* is a success for our purposes (nothing to clean up). Caller is
|
|
82
|
+
* responsible for not using the in-memory token after calling this.
|
|
83
|
+
*/
|
|
84
|
+
export async function deleteTokenFile(tokenPath: string = WORKSPACE_PATHS.sessionToken): Promise<void> {
|
|
85
|
+
try {
|
|
86
|
+
await fs.promises.unlink(tokenPath);
|
|
87
|
+
} catch {
|
|
88
|
+
/* already gone — nothing to do */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Test-only: reset module state so a suite can simulate fresh startup
|
|
94
|
+
* without reloading the module. Not exported to production callers.
|
|
95
|
+
*/
|
|
96
|
+
export function __resetForTests(): void {
|
|
97
|
+
currentToken = null;
|
|
98
|
+
}
|