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,812 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { workspacePath } from "../../workspace/workspace.js";
|
|
5
|
+
import { statSafe, statSafeAsync, readDirSafeAsync, resolveWithinRoot, writeFileAtomic } from "../../utils/files/index.js";
|
|
6
|
+
import { errorMessage } from "../../utils/errors.js";
|
|
7
|
+
import { badRequest, notFound, sendError, serverError } from "../../utils/httpError.js";
|
|
8
|
+
import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
9
|
+
import { GitignoreFilter } from "../../utils/gitignore.js";
|
|
10
|
+
import { getCachedReferenceDirs } from "../../workspace/reference-dirs.js";
|
|
11
|
+
|
|
12
|
+
const router = Router();
|
|
13
|
+
|
|
14
|
+
const MAX_PREVIEW_BYTES = 1024 * 1024; // 1 MB — text content embedded in JSON
|
|
15
|
+
const MAX_RAW_BYTES = 50 * 1024 * 1024; // 50 MB — cap for binary streaming
|
|
16
|
+
const HIDDEN_DIRS = new Set([".git"]);
|
|
17
|
+
|
|
18
|
+
// Files whose basename exactly matches one of these is refused by
|
|
19
|
+
// every file-API endpoint. Used to keep workspace secrets
|
|
20
|
+
// (credentials, API keys, SSH / TLS private keys) off the HTTP
|
|
21
|
+
// surface. Compared against `path.basename(...).toLowerCase()`.
|
|
22
|
+
const SENSITIVE_BASENAMES = new Set([
|
|
23
|
+
"credentials.json",
|
|
24
|
+
// Claude Code credentials file written by server/credentials.ts.
|
|
25
|
+
".session-token",
|
|
26
|
+
// Bearer auth token file — readable without auth via /api/files/*
|
|
27
|
+
// exemption, so it must be blocked here (defense in depth).
|
|
28
|
+
".npmrc",
|
|
29
|
+
".htpasswd",
|
|
30
|
+
"id_rsa",
|
|
31
|
+
"id_ecdsa",
|
|
32
|
+
"id_ed25519",
|
|
33
|
+
"id_dsa",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// File extensions whose contents are almost always secret. Compared
|
|
37
|
+
// against `path.extname(...).toLowerCase()`. Note: `.env` is matched
|
|
38
|
+
// separately below because `path.extname(".env")` returns "" —
|
|
39
|
+
// dotfiles with no second extension don't carry an extname.
|
|
40
|
+
const SENSITIVE_EXTENSIONS = new Set([".pem", ".key", ".crt"]);
|
|
41
|
+
|
|
42
|
+
// Decide whether `relPath` names a file whose contents should NEVER
|
|
43
|
+
// be served by the file API. Applied in three places:
|
|
44
|
+
//
|
|
45
|
+
// 1. `resolveSafe` returns null for sensitive paths so every
|
|
46
|
+
// endpoint (content, raw, anything future) rejects them with a
|
|
47
|
+
// generic 400.
|
|
48
|
+
// 2. `buildTreeAsync` / `listDirShallow` filter them out of
|
|
49
|
+
// `/files/tree` and `/files/dir`, so the file explorer never
|
|
50
|
+
// lists them in the first place.
|
|
51
|
+
// 3. The `.env` blocklist below is what keeps `/files/content`
|
|
52
|
+
// from leaking credentials on a matching-name lookup.
|
|
53
|
+
//
|
|
54
|
+
// Exported so `test/routes/test_filesRoute.ts` can pin the matching
|
|
55
|
+
// rules down table-driven — regressions here silently reopen a
|
|
56
|
+
// credential-exfil surface.
|
|
57
|
+
export function isSensitivePath(relPath: string): boolean {
|
|
58
|
+
const base = path.basename(relPath).toLowerCase();
|
|
59
|
+
if (SENSITIVE_BASENAMES.has(base)) return true;
|
|
60
|
+
// `.env` and every `.env.<something>` variant
|
|
61
|
+
// (`.env.local`, `.env.production`, ...). The startsWith check
|
|
62
|
+
// is scoped to `.env` to avoid false-positives on names like
|
|
63
|
+
// `.environment-notes` — we only match `.env` exact or
|
|
64
|
+
// `.env.<suffix>`.
|
|
65
|
+
if (base === ".env") return true;
|
|
66
|
+
if (base.startsWith(".env.")) return true;
|
|
67
|
+
const ext = path.extname(base);
|
|
68
|
+
if (SENSITIVE_EXTENSIONS.has(ext)) return true;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const TEXT_EXTENSIONS = new Set([
|
|
73
|
+
".md",
|
|
74
|
+
".markdown",
|
|
75
|
+
".txt",
|
|
76
|
+
".json",
|
|
77
|
+
".jsonl",
|
|
78
|
+
".ndjson",
|
|
79
|
+
".yaml",
|
|
80
|
+
".yml",
|
|
81
|
+
".js",
|
|
82
|
+
".ts",
|
|
83
|
+
".jsx",
|
|
84
|
+
".tsx",
|
|
85
|
+
".vue",
|
|
86
|
+
".html",
|
|
87
|
+
".htm",
|
|
88
|
+
".css",
|
|
89
|
+
".csv",
|
|
90
|
+
".log",
|
|
91
|
+
// `.env` intentionally removed — see `isSensitivePath` below.
|
|
92
|
+
// It used to be here, making `/files/content?path=.env` return
|
|
93
|
+
// the workspace credentials as JSON text over an open CORS
|
|
94
|
+
// endpoint. The file API now refuses sensitive paths outright;
|
|
95
|
+
// this set is kept for genuine plain-text previews only.
|
|
96
|
+
".gitignore",
|
|
97
|
+
".sh",
|
|
98
|
+
".py",
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
|
|
102
|
+
|
|
103
|
+
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".m4a", ".ogg", ".oga", ".flac", ".aac"]);
|
|
104
|
+
|
|
105
|
+
const VIDEO_EXTENSIONS = new Set([".mp4", ".webm", ".mov", ".m4v", ".ogv"]);
|
|
106
|
+
|
|
107
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
108
|
+
".png": "image/png",
|
|
109
|
+
".jpg": "image/jpeg",
|
|
110
|
+
".jpeg": "image/jpeg",
|
|
111
|
+
".gif": "image/gif",
|
|
112
|
+
".webp": "image/webp",
|
|
113
|
+
".svg": "image/svg+xml",
|
|
114
|
+
".pdf": "application/pdf",
|
|
115
|
+
".mp3": "audio/mpeg",
|
|
116
|
+
".wav": "audio/wav",
|
|
117
|
+
".m4a": "audio/mp4",
|
|
118
|
+
".ogg": "audio/ogg",
|
|
119
|
+
".oga": "audio/ogg",
|
|
120
|
+
".flac": "audio/flac",
|
|
121
|
+
".aac": "audio/aac",
|
|
122
|
+
".mp4": "video/mp4",
|
|
123
|
+
".webm": "video/webm",
|
|
124
|
+
".mov": "video/quicktime",
|
|
125
|
+
".m4v": "video/x-m4v",
|
|
126
|
+
".ogv": "video/ogg",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export interface TreeNode {
|
|
130
|
+
name: string;
|
|
131
|
+
path: string;
|
|
132
|
+
type: "file" | "dir";
|
|
133
|
+
size?: number;
|
|
134
|
+
modifiedMs?: number;
|
|
135
|
+
children?: TreeNode[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface ErrorResponse {
|
|
139
|
+
error: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface FileContentText {
|
|
143
|
+
kind: "text";
|
|
144
|
+
path: string;
|
|
145
|
+
content: string;
|
|
146
|
+
size: number;
|
|
147
|
+
modifiedMs: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface WriteContentRequest {
|
|
151
|
+
path?: unknown;
|
|
152
|
+
content?: unknown;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface WriteContentResponse {
|
|
156
|
+
path: string;
|
|
157
|
+
size: number;
|
|
158
|
+
modifiedMs: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface FileContentMeta {
|
|
162
|
+
kind: "image" | "pdf" | "audio" | "video" | "binary" | "too-large";
|
|
163
|
+
path: string;
|
|
164
|
+
size: number;
|
|
165
|
+
modifiedMs: number;
|
|
166
|
+
message?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type FileContentResponse = FileContentText | FileContentMeta;
|
|
170
|
+
|
|
171
|
+
export type ContentKind = "text" | "image" | "pdf" | "audio" | "video" | "binary";
|
|
172
|
+
|
|
173
|
+
// Exported for unit tests. Classification is purely extension-based
|
|
174
|
+
// and case-insensitive (via `path.extname(...).toLowerCase()`).
|
|
175
|
+
export function classify(filename: string): ContentKind {
|
|
176
|
+
const ext = path.extname(filename).toLowerCase();
|
|
177
|
+
if (TEXT_EXTENSIONS.has(ext)) return "text";
|
|
178
|
+
if (IMAGE_EXTENSIONS.has(ext)) return "image";
|
|
179
|
+
if (AUDIO_EXTENSIONS.has(ext)) return "audio";
|
|
180
|
+
if (VIDEO_EXTENSIONS.has(ext)) return "video";
|
|
181
|
+
if (ext === ".pdf") return "pdf";
|
|
182
|
+
// Files with no extension (e.g. README, LICENSE) — treat as text
|
|
183
|
+
if (!ext) return "text";
|
|
184
|
+
return "binary";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Cached realpath of the workspace. Computed once at module load so
|
|
188
|
+
// every request avoids the syscall. resolveWithinRoot needs an
|
|
189
|
+
// already-realpath'd root.
|
|
190
|
+
const workspaceReal = fs.realpathSync(workspacePath);
|
|
191
|
+
|
|
192
|
+
// Wraps the shared resolveWithinRoot helper with the additional
|
|
193
|
+
// hidden-dir traversal check (e.g. `.git/config`). `buildTreeAsync`
|
|
194
|
+
// / `listDirShallow` hide these from the listing, but the URL
|
|
195
|
+
// endpoints are reachable directly so they need their own check.
|
|
196
|
+
function resolveSafe(relPath: string): string | null {
|
|
197
|
+
const resolved = resolveWithinRoot(workspaceReal, relPath);
|
|
198
|
+
if (!resolved) return null;
|
|
199
|
+
const relativeFromWorkspace = path.relative(workspaceReal, resolved);
|
|
200
|
+
if (relativeFromWorkspace) {
|
|
201
|
+
for (const seg of relativeFromWorkspace.split(path.sep)) {
|
|
202
|
+
if (HIDDEN_DIRS.has(seg)) return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Reject workspace-sensitive filenames outright. `isSensitivePath`
|
|
206
|
+
// matches on the basename so it catches `.env`, `id_rsa`, and
|
|
207
|
+
// friends regardless of which directory they sit in.
|
|
208
|
+
if (isSensitivePath(resolved)) return null;
|
|
209
|
+
return resolved;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Reference directory path resolution ──────────────────────────
|
|
213
|
+
|
|
214
|
+
const REF_PREFIX = "@ref/";
|
|
215
|
+
|
|
216
|
+
function isRefPath(relPath: string): boolean {
|
|
217
|
+
return relPath.startsWith(REF_PREFIX);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolve a `@ref/<label>/remainder` path against a registered
|
|
222
|
+
* reference directory. Returns the absolute host path or null if
|
|
223
|
+
* the label is unknown, the path escapes the ref root, or the
|
|
224
|
+
* resolved file is sensitive / hidden.
|
|
225
|
+
*/
|
|
226
|
+
function resolveRefPath(prefixedPath: string): string | null {
|
|
227
|
+
const afterPrefix = prefixedPath.slice(REF_PREFIX.length);
|
|
228
|
+
const slashIdx = afterPrefix.indexOf("/");
|
|
229
|
+
const label = slashIdx >= 0 ? afterPrefix.slice(0, slashIdx) : afterPrefix;
|
|
230
|
+
const remainder = slashIdx >= 0 ? afterPrefix.slice(slashIdx + 1) : "";
|
|
231
|
+
|
|
232
|
+
const entries = getCachedReferenceDirs();
|
|
233
|
+
const entry = entries.find((e) => e.label === label);
|
|
234
|
+
if (!entry) return null;
|
|
235
|
+
|
|
236
|
+
let rootReal: string;
|
|
237
|
+
try {
|
|
238
|
+
rootReal = fs.realpathSync(entry.hostPath);
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// For root of the reference dir (no remainder), return the dir itself
|
|
244
|
+
if (!remainder) return rootReal;
|
|
245
|
+
|
|
246
|
+
const resolved = resolveWithinRoot(rootReal, remainder);
|
|
247
|
+
if (!resolved) return null;
|
|
248
|
+
|
|
249
|
+
// Apply the same hidden-dir and sensitive-path filters
|
|
250
|
+
const relFromRoot = path.relative(rootReal, resolved);
|
|
251
|
+
if (relFromRoot) {
|
|
252
|
+
for (const seg of relFromRoot.split(path.sep)) {
|
|
253
|
+
if (HIDDEN_DIRS.has(seg)) return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (isSensitivePath(resolved)) return null;
|
|
257
|
+
|
|
258
|
+
return resolved;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface ByteRange {
|
|
262
|
+
start: number;
|
|
263
|
+
end: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Parse an HTTP Range header of the form `bytes=START-END` or
|
|
267
|
+
// `bytes=-SUFFIX`. Returns null for malformed or unsatisfiable ranges
|
|
268
|
+
// so the caller can respond 416. We deliberately reject multi-range
|
|
269
|
+
// requests (`bytes=0-99,200-299`) since browsers don't issue them for
|
|
270
|
+
// media playback and supporting them would complicate the response.
|
|
271
|
+
//
|
|
272
|
+
// Exported for unit tests — this is the most security-sensitive piece
|
|
273
|
+
// of the file-serving surface, so it's covered exhaustively in
|
|
274
|
+
// `test/routes/test_filesRoute.ts`.
|
|
275
|
+
export function parseRange(header: string, size: number): ByteRange | null {
|
|
276
|
+
// RFC 7233 §2.1: "A Range request on a representation whose current
|
|
277
|
+
// length is 0 cannot be satisfied". We also need this guard at the
|
|
278
|
+
// top because the naive suffix-range math below produces `end = -1`
|
|
279
|
+
// for zero-byte files, which then crashes `fs.createReadStream`
|
|
280
|
+
// with `ERR_OUT_OF_RANGE`.
|
|
281
|
+
if (size <= 0) return null;
|
|
282
|
+
const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
|
|
283
|
+
if (!match) return null;
|
|
284
|
+
const [, startStr, endStr] = match;
|
|
285
|
+
if (startStr === "" && endStr === "") return null;
|
|
286
|
+
if (startStr === "") {
|
|
287
|
+
const suffix = Number(endStr);
|
|
288
|
+
if (!Number.isFinite(suffix) || suffix <= 0) return null;
|
|
289
|
+
return { start: Math.max(0, size - suffix), end: size - 1 };
|
|
290
|
+
}
|
|
291
|
+
const start = Number(startStr);
|
|
292
|
+
const end = endStr === "" ? size - 1 : Number(endStr);
|
|
293
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
|
294
|
+
if (start < 0 || end < start || end >= size) return null;
|
|
295
|
+
return { start, end };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Security headers applied to every `/files/raw` response. Exported
|
|
299
|
+
// so a regression test can pin the exact strings down — a silent
|
|
300
|
+
// regression here reopens a real XSS surface (see plans/
|
|
301
|
+
// fix-files-raw-csp-sandbox.md for the full threat model).
|
|
302
|
+
//
|
|
303
|
+
// `sandbox` (no allow-flags) creates an opaque origin for the
|
|
304
|
+
// response. Even if an SVG / HTML / PDF with embedded JavaScript
|
|
305
|
+
// gets loaded as a top-level document or inside an iframe, its
|
|
306
|
+
// scripts can't access the localhost:3001 origin's cookies,
|
|
307
|
+
// session storage, or hit the `/api/*` endpoints. Frames rendering
|
|
308
|
+
// the response become sandboxed too — PDFs still work because
|
|
309
|
+
// they don't rely on same-origin access to the parent.
|
|
310
|
+
//
|
|
311
|
+
// `nosniff` stops Chrome / Firefox from re-guessing Content-Type
|
|
312
|
+
// on files the server declared but the browser might want to
|
|
313
|
+
// re-interpret as HTML.
|
|
314
|
+
export const RAW_SECURITY_HEADERS: Readonly<Record<string, string>> = {
|
|
315
|
+
"Content-Security-Policy": "sandbox",
|
|
316
|
+
"X-Content-Type-Options": "nosniff",
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
function applyRawSecurityHeaders(res: Response): void {
|
|
320
|
+
for (const [name, value] of Object.entries(RAW_SECURITY_HEADERS)) {
|
|
321
|
+
res.setHeader(name, value);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// If the read stream errors mid-flight (file deleted, disk error,
|
|
326
|
+
// permissions changed), surface a clean failure to the client instead
|
|
327
|
+
// of leaving the connection hanging.
|
|
328
|
+
function pipeWithErrorHandling(stream: fs.ReadStream, res: Response<ErrorResponse>): void {
|
|
329
|
+
stream.on("error", (err) => {
|
|
330
|
+
if (res.headersSent) {
|
|
331
|
+
res.destroy(err);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
serverError(res, `Failed to read file: ${err.message}`);
|
|
335
|
+
});
|
|
336
|
+
stream.pipe(res);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Async workspace tree walker — recurses through the workspace with
|
|
340
|
+
// the same security filters as the original sync implementation
|
|
341
|
+
// (hidden dirs, sensitive files, symlinks all rejected) and the same
|
|
342
|
+
// ordering (dirs before files, alphabetical within type). Uses
|
|
343
|
+
// `fs.promises` throughout so the walk never blocks the event loop,
|
|
344
|
+
// and fans out each directory's children in parallel via
|
|
345
|
+
// `Promise.all`.
|
|
346
|
+
//
|
|
347
|
+
// Exported so unit tests can point it at a tmp dir fixture.
|
|
348
|
+
export async function buildTreeAsync(absPath: string, relPath: string, gitFilter?: GitignoreFilter): Promise<TreeNode> {
|
|
349
|
+
const stat = await statSafeAsync(absPath);
|
|
350
|
+
if (!stat) {
|
|
351
|
+
// Caller is expected to have resolved `absPath` beforehand; if it
|
|
352
|
+
// vanished between resolve and walk, surface an empty dir node.
|
|
353
|
+
return {
|
|
354
|
+
name: path.basename(absPath),
|
|
355
|
+
path: relPath,
|
|
356
|
+
type: "dir",
|
|
357
|
+
children: [],
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (!stat.isDirectory()) {
|
|
361
|
+
return {
|
|
362
|
+
name: path.basename(absPath),
|
|
363
|
+
path: relPath,
|
|
364
|
+
type: "file",
|
|
365
|
+
size: stat.size,
|
|
366
|
+
modifiedMs: stat.mtimeMs,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const entries = await readDirSafeAsync(absPath);
|
|
370
|
+
// Pick up any .gitignore in this directory so its rules apply to
|
|
371
|
+
// children. The filter chains: parent rules + local .gitignore.
|
|
372
|
+
// When gitFilter is undefined (workspace root), DON'T read the
|
|
373
|
+
// root .gitignore (it's for git, not the UI). Pass a fresh empty
|
|
374
|
+
// filter so children pick up THEIR .gitignore files.
|
|
375
|
+
const localFilter = gitFilter ? gitFilter.childForDir(absPath) : new GitignoreFilter();
|
|
376
|
+
// Build every surviving child concurrently. Filter:
|
|
377
|
+
// skip hidden dirs, sensitive files, symlinks, .gitignore matches,
|
|
378
|
+
// and entries that fail to stat.
|
|
379
|
+
const childPromises: Promise<TreeNode | null>[] = entries.map(async (entry): Promise<TreeNode | null> => {
|
|
380
|
+
if (HIDDEN_DIRS.has(entry.name)) return null;
|
|
381
|
+
if (!entry.isDirectory() && isSensitivePath(entry.name)) return null;
|
|
382
|
+
if (entry.isSymbolicLink()) return null;
|
|
383
|
+
const childRel = relPath ? path.join(relPath, entry.name) : entry.name;
|
|
384
|
+
// .gitignore check: for directories, append trailing / so
|
|
385
|
+
// directory-only patterns (e.g. "node_modules/") match.
|
|
386
|
+
if (localFilter) {
|
|
387
|
+
const testPath = entry.isDirectory() ? `${childRel}/` : childRel;
|
|
388
|
+
if (localFilter.ignores(testPath)) return null;
|
|
389
|
+
}
|
|
390
|
+
const childAbs = path.join(absPath, entry.name);
|
|
391
|
+
const childStat = await statSafeAsync(childAbs);
|
|
392
|
+
if (!childStat) return null;
|
|
393
|
+
return buildTreeAsync(childAbs, childRel, localFilter);
|
|
394
|
+
});
|
|
395
|
+
const resolved = await Promise.all(childPromises);
|
|
396
|
+
const children = resolved.filter((c): c is TreeNode => c !== null);
|
|
397
|
+
children.sort((a, b) => {
|
|
398
|
+
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
|
399
|
+
return a.name.localeCompare(b.name);
|
|
400
|
+
});
|
|
401
|
+
return {
|
|
402
|
+
name: relPath ? path.basename(relPath) : "",
|
|
403
|
+
path: relPath,
|
|
404
|
+
type: "dir",
|
|
405
|
+
modifiedMs: stat.mtimeMs,
|
|
406
|
+
children,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Shallow variant: return the given directory's immediate children
|
|
411
|
+
// only (no recursion). Used by the lazy-expand endpoint below — the
|
|
412
|
+
// client fetches one level at a time as the user expands nodes,
|
|
413
|
+
// so the initial Files view load cost is O(root entries) rather than
|
|
414
|
+
// O(all workspace files).
|
|
415
|
+
//
|
|
416
|
+
// Exported for unit tests.
|
|
417
|
+
export async function listDirShallow(absPath: string, relPath: string, gitFilter?: GitignoreFilter): Promise<TreeNode> {
|
|
418
|
+
const stat = await statSafeAsync(absPath);
|
|
419
|
+
if (!stat || !stat.isDirectory()) {
|
|
420
|
+
return {
|
|
421
|
+
name: relPath ? path.basename(relPath) : "",
|
|
422
|
+
path: relPath,
|
|
423
|
+
type: "dir",
|
|
424
|
+
children: [],
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const entries = await readDirSafeAsync(absPath);
|
|
428
|
+
// When gitFilter is undefined (workspace root), DON'T read the
|
|
429
|
+
// root .gitignore (it's for git, not the UI). Pass a fresh empty
|
|
430
|
+
// filter so children pick up THEIR .gitignore files.
|
|
431
|
+
const localFilter = gitFilter ? gitFilter.childForDir(absPath) : new GitignoreFilter();
|
|
432
|
+
const childPromises: Promise<TreeNode | null>[] = entries.map(async (entry): Promise<TreeNode | null> => {
|
|
433
|
+
if (HIDDEN_DIRS.has(entry.name)) return null;
|
|
434
|
+
if (!entry.isDirectory() && isSensitivePath(entry.name)) return null;
|
|
435
|
+
if (entry.isSymbolicLink()) return null;
|
|
436
|
+
const childRel = relPath ? path.join(relPath, entry.name) : entry.name;
|
|
437
|
+
if (localFilter) {
|
|
438
|
+
const testPath = entry.isDirectory() ? `${childRel}/` : childRel;
|
|
439
|
+
if (localFilter.ignores(testPath)) return null;
|
|
440
|
+
}
|
|
441
|
+
const childAbs = path.join(absPath, entry.name);
|
|
442
|
+
const childStat = await statSafeAsync(childAbs);
|
|
443
|
+
if (!childStat) return null;
|
|
444
|
+
if (childStat.isDirectory()) {
|
|
445
|
+
return {
|
|
446
|
+
name: entry.name,
|
|
447
|
+
path: childRel,
|
|
448
|
+
type: "dir",
|
|
449
|
+
modifiedMs: childStat.mtimeMs,
|
|
450
|
+
// No `children` field — caller fetches via another
|
|
451
|
+
// /api/files/dir call on expand.
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
name: entry.name,
|
|
456
|
+
path: childRel,
|
|
457
|
+
type: "file",
|
|
458
|
+
size: childStat.size,
|
|
459
|
+
modifiedMs: childStat.mtimeMs,
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
const resolved = await Promise.all(childPromises);
|
|
463
|
+
const children = resolved.filter((c): c is TreeNode => c !== null);
|
|
464
|
+
children.sort((a, b) => {
|
|
465
|
+
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
|
466
|
+
return a.name.localeCompare(b.name);
|
|
467
|
+
});
|
|
468
|
+
return {
|
|
469
|
+
name: relPath ? path.basename(relPath) : "",
|
|
470
|
+
path: relPath,
|
|
471
|
+
type: "dir",
|
|
472
|
+
modifiedMs: stat.mtimeMs,
|
|
473
|
+
children,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
router.get(API_ROUTES.files.tree, async (_req: Request<object, unknown, unknown, object>, res: Response<TreeNode | ErrorResponse>) => {
|
|
478
|
+
try {
|
|
479
|
+
// Start with an empty filter — the workspace root's .gitignore
|
|
480
|
+
// is for git (excluding github/ from commits), NOT for the
|
|
481
|
+
// Files UI. Only .gitignore files inside subdirectories (e.g.
|
|
482
|
+
// github/mulmoclaude/.gitignore) are applied.
|
|
483
|
+
// Pass undefined = skip workspace root .gitignore (it's for
|
|
484
|
+
// git, not the UI). Sub-dir .gitignore files still apply.
|
|
485
|
+
const tree = await buildTreeAsync(workspaceReal, "");
|
|
486
|
+
res.json(tree);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
res.status(500).json({ error: `Failed to read workspace: ${errorMessage(err)}` });
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Lazy-expand endpoint. Returns one directory's immediate children
|
|
493
|
+
// (no recursion) so the client can render the tree incrementally.
|
|
494
|
+
// `path` is optional; empty / missing = workspace root.
|
|
495
|
+
router.get(API_ROUTES.files.dir, async (req: Request<object, unknown, unknown, PathQuery>, res: Response<TreeNode | ErrorResponse>) => {
|
|
496
|
+
const relPath = typeof req.query.path === "string" ? req.query.path : "";
|
|
497
|
+
|
|
498
|
+
// Reference directory branch — resolve against the registered ref dir
|
|
499
|
+
if (isRefPath(relPath)) {
|
|
500
|
+
const absPath = resolveRefPath(relPath);
|
|
501
|
+
if (!absPath) {
|
|
502
|
+
notFound(res, "Not found");
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const stat = await statSafeAsync(absPath);
|
|
506
|
+
if (!stat || !stat.isDirectory()) {
|
|
507
|
+
notFound(res, "Not found");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const node = await listDirShallow(absPath, relPath, undefined);
|
|
511
|
+
res.json(node);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Workspace path — existing logic
|
|
516
|
+
const absPath = resolveSafe(relPath);
|
|
517
|
+
if (!absPath) {
|
|
518
|
+
notFound(res, "Not found");
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const stat = await statSafeAsync(absPath);
|
|
522
|
+
if (!stat) {
|
|
523
|
+
notFound(res, "Not found");
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (!stat.isDirectory()) {
|
|
527
|
+
badRequest(res, "path is not a directory");
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
// Build the gitignore filter chain. Start undefined at root
|
|
532
|
+
// (workspace root .gitignore is for git, not the UI). Once we
|
|
533
|
+
// descend into a sub-dir, childForDir picks up local .gitignore.
|
|
534
|
+
let filter: GitignoreFilter | undefined;
|
|
535
|
+
const segments = path.relative(workspaceReal, absPath).split(path.sep).filter(Boolean);
|
|
536
|
+
let walkAbs = workspaceReal;
|
|
537
|
+
for (const seg of segments) {
|
|
538
|
+
walkAbs = path.join(walkAbs, seg);
|
|
539
|
+
filter = filter ? filter.childForDir(walkAbs) : new GitignoreFilter().childForDir(walkAbs);
|
|
540
|
+
}
|
|
541
|
+
const listing = await listDirShallow(absPath, path.relative(workspaceReal, absPath), filter);
|
|
542
|
+
res.json(listing);
|
|
543
|
+
} catch (err) {
|
|
544
|
+
serverError(res, `Failed to read directory: ${errorMessage(err)}`);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
interface PathQuery {
|
|
549
|
+
path?: string;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Shared validation preamble for /files/content and /files/raw. Both
|
|
553
|
+
// endpoints need to: read `path` from the query, validate it's
|
|
554
|
+
// inside the workspace (with symlink hardening), stat it, and
|
|
555
|
+
// confirm it's a regular file. On any failure this writes the
|
|
556
|
+
// appropriate 4xx response and returns null; the caller bails out.
|
|
557
|
+
//
|
|
558
|
+
// `T` lets each caller's Response type stay precise — both endpoints
|
|
559
|
+
// have different success-shape unions and we just need ErrorResponse
|
|
560
|
+
// to be one of the alternatives.
|
|
561
|
+
//
|
|
562
|
+
// Order matters: stat the syntactic candidate first so a missing
|
|
563
|
+
// file gets a 404, then run the realpath-hardened resolveSafe check
|
|
564
|
+
// for symlink escapes (which would return 400). Doing them in this
|
|
565
|
+
// order keeps 404 reachable for the common "file not found" case
|
|
566
|
+
// instead of conflating it with traversal attempts.
|
|
567
|
+
function resolveAndStatFile<T>(
|
|
568
|
+
req: Request<object, unknown, unknown, PathQuery>,
|
|
569
|
+
res: Response<T | ErrorResponse>,
|
|
570
|
+
): { relPath: string; absPath: string; stat: fs.Stats } | null {
|
|
571
|
+
const relPath = typeof req.query.path === "string" ? req.query.path : "";
|
|
572
|
+
if (!relPath) {
|
|
573
|
+
badRequest(res, "path required");
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Reference directory branch
|
|
578
|
+
if (isRefPath(relPath)) {
|
|
579
|
+
const absPath = resolveRefPath(relPath);
|
|
580
|
+
if (!absPath) {
|
|
581
|
+
notFound(res, "Not found");
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
const stat = statSafe(absPath);
|
|
585
|
+
if (!stat || !stat.isFile()) {
|
|
586
|
+
notFound(res, "File not found");
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
return { relPath, absPath, stat };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Workspace path — existing logic
|
|
593
|
+
// Syntactic candidate (no symlink resolution yet).
|
|
594
|
+
const candidate = path.resolve(workspaceReal, path.normalize(relPath));
|
|
595
|
+
const stat = statSafe(candidate);
|
|
596
|
+
if (!stat) {
|
|
597
|
+
// Distinguish "missing file under workspace" (404) from "path
|
|
598
|
+
// syntactically outside workspace" (400). We check the
|
|
599
|
+
// syntactic relative form, NOT realpath, because the file
|
|
600
|
+
// doesn't exist so realpath would throw anyway.
|
|
601
|
+
const relativeFromWorkspace = path.relative(workspaceReal, candidate);
|
|
602
|
+
const escapesSyntactically = relativeFromWorkspace === ".." || relativeFromWorkspace.startsWith(`..${path.sep}`);
|
|
603
|
+
if (escapesSyntactically) {
|
|
604
|
+
badRequest(res, "Path outside workspace");
|
|
605
|
+
} else {
|
|
606
|
+
notFound(res, "File not found");
|
|
607
|
+
}
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
if (!stat.isFile()) {
|
|
611
|
+
badRequest(res, "Not a file");
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
// File exists — run the realpath-hardened check to defeat
|
|
615
|
+
// symlink-escape attempts (e.g. workspace/secret → /etc/passwd).
|
|
616
|
+
// resolveSafe also rejects paths that traverse a hidden dir.
|
|
617
|
+
const absPath = resolveSafe(relPath);
|
|
618
|
+
if (!absPath) {
|
|
619
|
+
badRequest(res, "Path outside workspace");
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
return { relPath, absPath, stat };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
router.get(API_ROUTES.files.content, (req: Request<object, unknown, unknown, PathQuery>, res: Response<FileContentResponse | ErrorResponse>) => {
|
|
626
|
+
const ctx = resolveAndStatFile(req, res);
|
|
627
|
+
if (!ctx) return;
|
|
628
|
+
const { relPath, absPath, stat } = ctx;
|
|
629
|
+
|
|
630
|
+
const meta = {
|
|
631
|
+
path: relPath,
|
|
632
|
+
size: stat.size,
|
|
633
|
+
modifiedMs: stat.mtimeMs,
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
// Anything past the binary stream cap is "too-large" regardless of
|
|
637
|
+
// type — even images/PDFs, since the client would have to fetch
|
|
638
|
+
// them via /files/raw which enforces the same limit.
|
|
639
|
+
if (stat.size > MAX_RAW_BYTES) {
|
|
640
|
+
res.json({
|
|
641
|
+
kind: "too-large",
|
|
642
|
+
...meta,
|
|
643
|
+
message: `File too large to preview (${stat.size} bytes)`,
|
|
644
|
+
});
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const kind = classify(absPath);
|
|
649
|
+
if (kind === "image" || kind === "pdf" || kind === "audio" || kind === "video") {
|
|
650
|
+
res.json({ kind, ...meta });
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (kind === "binary") {
|
|
654
|
+
res.json({
|
|
655
|
+
kind: "binary",
|
|
656
|
+
...meta,
|
|
657
|
+
message: "Binary file — preview not supported",
|
|
658
|
+
});
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (stat.size > MAX_PREVIEW_BYTES) {
|
|
662
|
+
res.json({
|
|
663
|
+
kind: "too-large",
|
|
664
|
+
...meta,
|
|
665
|
+
message: `Text file too large to preview (${stat.size} bytes)`,
|
|
666
|
+
});
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
let content: string;
|
|
670
|
+
try {
|
|
671
|
+
content = fs.readFileSync(absPath, "utf-8");
|
|
672
|
+
} catch (err) {
|
|
673
|
+
res.status(500).json({ error: `Failed to read file: ${errorMessage(err)}` });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
res.json({ kind: "text", ...meta, content });
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Write the body of an existing text file. Only text-classified files
|
|
680
|
+
// (per `classify`) are editable — binary, image, audio, etc. are
|
|
681
|
+
// refused so the endpoint can't be used to ship arbitrary uploads.
|
|
682
|
+
// The file must already exist; creating new files is out of scope.
|
|
683
|
+
router.put(API_ROUTES.files.content, async (req: Request<object, unknown, WriteContentRequest>, res: Response<WriteContentResponse | ErrorResponse>) => {
|
|
684
|
+
const { path: relPathRaw, content: contentRaw } = req.body ?? {};
|
|
685
|
+
if (typeof relPathRaw !== "string" || relPathRaw.length === 0) {
|
|
686
|
+
badRequest(res, "path required");
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (typeof contentRaw !== "string") {
|
|
690
|
+
badRequest(res, "content required");
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (Buffer.byteLength(contentRaw, "utf-8") > MAX_PREVIEW_BYTES) {
|
|
694
|
+
badRequest(res, `content exceeds ${MAX_PREVIEW_BYTES} byte limit`);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
// Two-step resolution to distinguish "path outside workspace" (400)
|
|
698
|
+
// from "file does not exist" (404): realpath throws on ENOENT, so
|
|
699
|
+
// resolveSafe conflates the two. Stat the syntactic candidate
|
|
700
|
+
// first; if it exists, THEN run the symlink-hardened resolveSafe.
|
|
701
|
+
const candidate = path.resolve(workspaceReal, path.normalize(relPathRaw));
|
|
702
|
+
const existing = await statSafeAsync(candidate);
|
|
703
|
+
if (!existing) {
|
|
704
|
+
const relativeFromWorkspace = path.relative(workspaceReal, candidate);
|
|
705
|
+
const escapesSyntactically = relativeFromWorkspace === ".." || relativeFromWorkspace.startsWith(`..${path.sep}`);
|
|
706
|
+
if (escapesSyntactically) {
|
|
707
|
+
badRequest(res, "Path outside workspace");
|
|
708
|
+
} else {
|
|
709
|
+
notFound(res, "File not found");
|
|
710
|
+
}
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (!existing.isFile()) {
|
|
714
|
+
badRequest(res, "Not a file");
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const absPath = resolveSafe(relPathRaw);
|
|
718
|
+
if (!absPath) {
|
|
719
|
+
badRequest(res, "Path outside workspace");
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (classify(absPath) !== "text") {
|
|
723
|
+
badRequest(res, "File type not editable");
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
// `uniqueTmp: true` appends a randomUUID to the tmp filename so
|
|
728
|
+
// two simultaneous PUTs to the same path can't clobber each
|
|
729
|
+
// other's staging file and race through the rename.
|
|
730
|
+
await writeFileAtomic(absPath, contentRaw, { uniqueTmp: true });
|
|
731
|
+
} catch (err) {
|
|
732
|
+
serverError(res, `Failed to write file: ${errorMessage(err)}`);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const fresh = await statSafeAsync(absPath);
|
|
736
|
+
res.json({
|
|
737
|
+
path: relPathRaw,
|
|
738
|
+
size: fresh?.size ?? Buffer.byteLength(contentRaw, "utf-8"),
|
|
739
|
+
modifiedMs: fresh?.mtimeMs ?? Date.now(),
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
router.get(API_ROUTES.files.raw, (req: Request<object, unknown, unknown, PathQuery>, res: Response<ErrorResponse>) => {
|
|
744
|
+
const ctx = resolveAndStatFile(req, res);
|
|
745
|
+
if (!ctx) return;
|
|
746
|
+
const { absPath, stat } = ctx;
|
|
747
|
+
|
|
748
|
+
if (stat.size > MAX_RAW_BYTES) {
|
|
749
|
+
sendError(res, 413, `File too large to stream (${stat.size} bytes, limit ${MAX_RAW_BYTES})`);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
753
|
+
const mime = MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
754
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
755
|
+
res.setHeader("Content-Type", mime);
|
|
756
|
+
// Sandbox the response so an `.svg` / `.html` / `.pdf` with
|
|
757
|
+
// embedded JavaScript can't escape into the localhost:3001
|
|
758
|
+
// origin via direct navigation or <iframe>. See
|
|
759
|
+
// plans/done/fix-files-raw-csp-sandbox.md for the threat model.
|
|
760
|
+
applyRawSecurityHeaders(res);
|
|
761
|
+
|
|
762
|
+
// Range support is required for `<video>` playback (Safari refuses
|
|
763
|
+
// to play media without 206 responses) and for seek-past-buffered
|
|
764
|
+
// in `<audio>`. When no Range header is sent we fall through to
|
|
765
|
+
// the existing full-file pipe.
|
|
766
|
+
const rangeHeader = req.headers.range;
|
|
767
|
+
if (rangeHeader) {
|
|
768
|
+
const range = parseRange(rangeHeader, stat.size);
|
|
769
|
+
if (!range) {
|
|
770
|
+
// The media MIME was set above so the 206 success path
|
|
771
|
+
// doesn't have to repeat it, but on a 416 we want JSON so
|
|
772
|
+
// `res.json` doesn't lie about the body's content-type. Set
|
|
773
|
+
// the Content-Range per RFC 7233 §4.4 before sending.
|
|
774
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
775
|
+
res.setHeader("Content-Range", `bytes */${stat.size}`);
|
|
776
|
+
sendError(res, 416, "Range not satisfiable");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
res.status(206);
|
|
780
|
+
res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
|
|
781
|
+
res.setHeader("Content-Length", String(range.end - range.start + 1));
|
|
782
|
+
pipeWithErrorHandling(fs.createReadStream(absPath, { start: range.start, end: range.end }), res);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
res.setHeader("Content-Length", String(stat.size));
|
|
787
|
+
pipeWithErrorHandling(fs.createReadStream(absPath), res);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// ── Reference directory roots ───────────────────────────────────
|
|
791
|
+
//
|
|
792
|
+
// Returns configured reference directories as top-level TreeNode[]
|
|
793
|
+
// for the file explorer. Each node's path uses the @ref/<label>
|
|
794
|
+
// prefix so subsequent /dir and /content requests route correctly.
|
|
795
|
+
|
|
796
|
+
router.get(API_ROUTES.files.refRoots, async (_req: Request, res: Response<TreeNode[]>) => {
|
|
797
|
+
const entries = getCachedReferenceDirs();
|
|
798
|
+
const nodes: TreeNode[] = [];
|
|
799
|
+
for (const entry of entries) {
|
|
800
|
+
const stat = await statSafeAsync(entry.hostPath);
|
|
801
|
+
if (!stat || !stat.isDirectory()) continue;
|
|
802
|
+
nodes.push({
|
|
803
|
+
name: entry.label,
|
|
804
|
+
path: `${REF_PREFIX}${entry.label}`,
|
|
805
|
+
type: "dir",
|
|
806
|
+
modifiedMs: stat.mtimeMs,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
res.json(nodes);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
export default router;
|