@neuralis/brain-core 0.3.1 → 0.4.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 +88 -1417
- package/app/README.md +4 -4
- package/app/cards/FilesystemCardShell.tsx +42 -4
- package/app/cards/FilesystemChangeCard.tsx +34 -11
- package/app/cards/README.md +6 -5
- package/app/cards/diffUtils.ts +99 -1
- package/app/filesystem/FilesystemHost.tsx +24 -2
- package/app/filesystem/README.md +1 -1
- package/app/filesystem/api/README.md +3 -3
- package/app/filesystem/api/index.ts +38 -14
- package/app/filesystem/api/projectPackages.ts +13 -102
- package/app/filesystem/domain/types.ts +30 -10
- package/app/filesystem/hooks/README.md +4 -4
- package/app/filesystem/hooks/useDiscoverableSources.ts +19 -30
- package/app/filesystem/hooks/useFilesystemSSE.ts +42 -2
- package/app/filesystem/host/README.md +6 -4
- package/app/filesystem/host/buildFilesystemHostValue.ts +28 -2
- package/app/filesystem/host/installFilesystemHostComponents.tsx +1 -1
- package/app/filesystem/lib/sourceKindForm.ts +210 -27
- package/app/filesystem/lib/treeVisualState.tsx +98 -4
- package/app/filesystem/store/README.md +1 -0
- package/app/filesystem/store/filesystemStore.ts +99 -8
- package/app/filesystem/store/gitSlice.ts +5 -0
- package/app/filesystem/store/helpers.ts +17 -0
- package/app/filesystem/store/listSlice.ts +8 -0
- package/app/filesystem/ui/CodeEditor.tsx +18 -3
- package/app/filesystem/ui/DiscoverDock.tsx +5 -1
- package/app/filesystem/ui/FileEditor.tsx +15 -10
- package/app/filesystem/ui/FileTree.tsx +75 -24
- package/app/filesystem/ui/FilesystemWidget.tsx +10 -2
- package/app/filesystem/ui/FolderPicker.tsx +251 -0
- package/app/filesystem/ui/GitControlPanel.tsx +7 -2
- package/app/filesystem/ui/PackagesPanel.tsx +52 -374
- package/app/filesystem/ui/PendingChangesPanel.tsx +1 -1
- package/app/filesystem/ui/PolicyEditor.tsx +109 -24
- package/app/filesystem/ui/README.md +30 -23
- package/app/filesystem/ui/RawMediaPreview.tsx +58 -0
- package/app/filesystem/ui/SourceBranchHeader.tsx +4 -4
- package/app/filesystem/ui/SourcesPanel.tsx +502 -342
- package/app/filesystem/ui/Toolbar.tsx +1 -2
- package/app/filesystem/ui/VectorHealthPanel.tsx +2 -2
- package/app/shared/filesystem.png +0 -0
- package/dist/app/host.js +58 -46
- package/dist/scripts/sourceRootPairV1.d.ts +47 -0
- package/dist/scripts/sourceRootPairV1.d.ts.map +1 -0
- package/dist/scripts/sourceRootPairV1.js +147 -0
- package/dist/scripts/sourceRootPairV1.js.map +1 -0
- package/dist/scripts/sourceToFolderV1.d.ts +56 -0
- package/dist/scripts/sourceToFolderV1.d.ts.map +1 -0
- package/dist/scripts/sourceToFolderV1.js +152 -0
- package/dist/scripts/sourceToFolderV1.js.map +1 -0
- package/dist/scripts/sourceToHostV1.d.ts +56 -0
- package/dist/scripts/sourceToHostV1.d.ts.map +1 -0
- package/dist/scripts/sourceToHostV1.js +132 -0
- package/dist/scripts/sourceToHostV1.js.map +1 -0
- package/dist/src/channels/filesystem.d.ts +1 -1
- package/dist/src/channels/filesystem.js +1 -1
- package/dist/src/connectors/AppSourceConfigStore.d.ts +17 -3
- package/dist/src/connectors/AppSourceConfigStore.d.ts.map +1 -1
- package/dist/src/connectors/AppSourceConfigStore.js +46 -6
- package/dist/src/connectors/AppSourceConfigStore.js.map +1 -1
- package/dist/src/connectors/HostConnector.d.ts +201 -0
- package/dist/src/connectors/HostConnector.d.ts.map +1 -0
- package/dist/src/connectors/HostConnector.js +903 -0
- package/dist/src/connectors/HostConnector.js.map +1 -0
- package/dist/src/connectors/inProcessTransport.d.ts +131 -0
- package/dist/src/connectors/inProcessTransport.d.ts.map +1 -0
- package/dist/src/connectors/inProcessTransport.js +447 -0
- package/dist/src/connectors/inProcessTransport.js.map +1 -0
- package/dist/src/domain/access/callerPermissions.d.ts +29 -0
- package/dist/src/domain/access/callerPermissions.d.ts.map +1 -1
- package/dist/src/domain/access/callerPermissions.js +85 -5
- package/dist/src/domain/access/callerPermissions.js.map +1 -1
- package/dist/src/domain/access/nativeLocalFence.d.ts +65 -0
- package/dist/src/domain/access/nativeLocalFence.d.ts.map +1 -0
- package/dist/src/domain/access/nativeLocalFence.js +171 -0
- package/dist/src/domain/access/nativeLocalFence.js.map +1 -0
- package/dist/src/domain/access/uriPermission.js +3 -3
- package/dist/src/domain/access/uriPermission.js.map +1 -1
- package/dist/src/domain/byteRange.d.ts +15 -0
- package/dist/src/domain/byteRange.d.ts.map +1 -0
- package/dist/src/domain/byteRange.js +30 -0
- package/dist/src/domain/byteRange.js.map +1 -0
- package/dist/src/domain/defaultPermissions.d.ts.map +1 -1
- package/dist/src/domain/defaultPermissions.js +29 -0
- package/dist/src/domain/defaultPermissions.js.map +1 -1
- package/dist/src/domain/mime.d.ts +11 -19
- package/dist/src/domain/mime.d.ts.map +1 -1
- package/dist/src/domain/mime.js +13 -102
- package/dist/src/domain/mime.js.map +1 -1
- package/dist/src/domain/paths.d.ts.map +1 -1
- package/dist/src/domain/paths.js +5 -3
- package/dist/src/domain/paths.js.map +1 -1
- package/dist/src/domain/scopeAccessCtx.d.ts +2 -2
- package/dist/src/domain/scopeAccessCtx.js +2 -2
- package/dist/src/domain/sourceConfig.d.ts +52 -19
- package/dist/src/domain/sourceConfig.d.ts.map +1 -1
- package/dist/src/domain/sourceConfig.js +103 -19
- package/dist/src/domain/sourceConfig.js.map +1 -1
- package/dist/src/domain/sourcePackages.d.ts +8 -8
- package/dist/src/domain/sourcePackages.d.ts.map +1 -1
- package/dist/src/domain/sourceRootPair.d.ts +88 -0
- package/dist/src/domain/sourceRootPair.d.ts.map +1 -0
- package/dist/src/domain/sourceRootPair.js +108 -0
- package/dist/src/domain/sourceRootPair.js.map +1 -0
- package/dist/src/domain/syncTypes.d.ts +8 -1
- package/dist/src/domain/syncTypes.d.ts.map +1 -1
- package/dist/src/domain/syncTypes.js +1 -0
- package/dist/src/domain/syncTypes.js.map +1 -1
- package/dist/src/domain/types.d.ts +32 -2
- package/dist/src/domain/types.d.ts.map +1 -1
- package/dist/src/domain/uri.d.ts.map +1 -1
- package/dist/src/domain/uri.js +3 -2
- package/dist/src/domain/uri.js.map +1 -1
- package/dist/src/embeddings/clients/GeminiEmbedder.d.ts.map +1 -1
- package/dist/src/embeddings/clients/GeminiEmbedder.js +2 -5
- package/dist/src/embeddings/clients/GeminiEmbedder.js.map +1 -1
- package/dist/src/embeddings/clients/OpenAIEmbedder.d.ts.map +1 -1
- package/dist/src/embeddings/clients/OpenAIEmbedder.js +2 -3
- package/dist/src/embeddings/clients/OpenAIEmbedder.js.map +1 -1
- package/dist/src/embeddings/clients/QwenEmbedder.d.ts +2 -0
- package/dist/src/embeddings/clients/QwenEmbedder.d.ts.map +1 -1
- package/dist/src/embeddings/clients/QwenEmbedder.js +10 -2
- package/dist/src/embeddings/clients/QwenEmbedder.js.map +1 -1
- package/dist/src/embeddings/clients/VoyageEmbedder.d.ts +2 -0
- package/dist/src/embeddings/clients/VoyageEmbedder.d.ts.map +1 -1
- package/dist/src/embeddings/clients/VoyageEmbedder.js +8 -1
- package/dist/src/embeddings/clients/VoyageEmbedder.js.map +1 -1
- package/dist/src/embeddings/factory.d.ts +5 -3
- package/dist/src/embeddings/factory.d.ts.map +1 -1
- package/dist/src/embeddings/factory.js +7 -3
- package/dist/src/embeddings/factory.js.map +1 -1
- package/dist/src/embeddings/models.d.ts +1 -1
- package/dist/src/embeddings/models.d.ts.map +1 -1
- package/dist/src/embeddings/retry.js +1 -1
- package/dist/src/embeddings/retry.js.map +1 -1
- package/dist/src/embeddings/types.d.ts +6 -0
- package/dist/src/embeddings/types.d.ts.map +1 -1
- package/dist/src/embeddings/types.js +11 -0
- package/dist/src/embeddings/types.js.map +1 -1
- package/dist/src/events/pending.d.ts +1 -1
- package/dist/src/events/pending.js +1 -1
- package/dist/src/git/GitRunner.d.ts +110 -30
- package/dist/src/git/GitRunner.d.ts.map +1 -1
- package/dist/src/git/GitRunner.js +511 -199
- package/dist/src/git/GitRunner.js.map +1 -1
- package/dist/src/git/GitWatchRegistry.d.ts +41 -35
- package/dist/src/git/GitWatchRegistry.d.ts.map +1 -1
- package/dist/src/git/GitWatchRegistry.js +61 -79
- package/dist/src/git/GitWatchRegistry.js.map +1 -1
- package/dist/src/git/gitPlace.d.ts +61 -0
- package/dist/src/git/gitPlace.d.ts.map +1 -0
- package/dist/src/git/gitPlace.js +74 -0
- package/dist/src/git/gitPlace.js.map +1 -0
- package/dist/src/git/sourceGitControl.d.ts +33 -21
- package/dist/src/git/sourceGitControl.d.ts.map +1 -1
- package/dist/src/git/sourceGitControl.js +211 -95
- package/dist/src/git/sourceGitControl.js.map +1 -1
- package/dist/src/git/sourceGitStatus.js +1 -1
- package/dist/src/git/sourceGitStatus.js.map +1 -1
- package/dist/src/git/untrackedStats.js +1 -1
- package/dist/src/git/untrackedStats.js.map +1 -1
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/lifecycle.d.ts +9 -12
- package/dist/src/lifecycle.d.ts.map +1 -1
- package/dist/src/lifecycle.js +2 -1
- package/dist/src/lifecycle.js.map +1 -1
- package/dist/src/routes/approve.d.ts +1 -1
- package/dist/src/routes/approve.js +1 -1
- package/dist/src/routes/events.d.ts +1 -1
- package/dist/src/routes/events.js +1 -1
- package/dist/src/routes/git.d.ts +7 -7
- package/dist/src/routes/git.d.ts.map +1 -1
- package/dist/src/routes/git.js +201 -72
- package/dist/src/routes/git.js.map +1 -1
- package/dist/src/routes/health.d.ts +6 -6
- package/dist/src/routes/health.js +12 -12
- package/dist/src/routes/helpers/sourceNotConfigured.d.ts +1 -1
- package/dist/src/routes/helpers/sourceNotConfigured.js +1 -1
- package/dist/src/routes/helpers/uriGuard.d.ts +1 -1
- package/dist/src/routes/helpers/uriGuard.d.ts.map +1 -1
- package/dist/src/routes/helpers/uriGuard.js +3 -2
- package/dist/src/routes/helpers/uriGuard.js.map +1 -1
- package/dist/src/routes/helpers.d.ts +11 -4
- package/dist/src/routes/helpers.d.ts.map +1 -1
- package/dist/src/routes/helpers.js +13 -6
- package/dist/src/routes/helpers.js.map +1 -1
- package/dist/src/routes/list.d.ts +1 -1
- package/dist/src/routes/list.d.ts.map +1 -1
- package/dist/src/routes/list.js +3 -4
- package/dist/src/routes/list.js.map +1 -1
- package/dist/src/routes/pending.d.ts +4 -4
- package/dist/src/routes/pending.js +9 -9
- package/dist/src/routes/policies.d.ts +10 -8
- package/dist/src/routes/policies.d.ts.map +1 -1
- package/dist/src/routes/policies.js +31 -25
- package/dist/src/routes/policies.js.map +1 -1
- package/dist/src/routes/raw.d.ts +1 -1
- package/dist/src/routes/raw.d.ts.map +1 -1
- package/dist/src/routes/raw.js +66 -12
- package/dist/src/routes/raw.js.map +1 -1
- package/dist/src/routes/read.d.ts +1 -1
- package/dist/src/routes/read.js +1 -1
- package/dist/src/routes/search.d.ts +1 -1
- package/dist/src/routes/search.js +1 -1
- package/dist/src/routes/sources.d.ts +63 -6
- package/dist/src/routes/sources.d.ts.map +1 -1
- package/dist/src/routes/sources.js +494 -382
- package/dist/src/routes/sources.js.map +1 -1
- package/dist/src/routes/upload.d.ts +1 -1
- package/dist/src/routes/upload.js +1 -1
- package/dist/src/routes/write.d.ts +2 -2
- package/dist/src/routes/write.js +2 -2
- package/dist/src/services/BridgeService.d.ts +21 -35
- package/dist/src/services/BridgeService.d.ts.map +1 -1
- package/dist/src/services/BridgeService.js +42 -88
- package/dist/src/services/BridgeService.js.map +1 -1
- package/dist/src/services/FilesystemService.d.ts +4 -3
- package/dist/src/services/FilesystemService.d.ts.map +1 -1
- package/dist/src/services/FilesystemService.js +112 -34
- package/dist/src/services/FilesystemService.js.map +1 -1
- package/dist/src/services/FilesystemUiService.d.ts +4 -1
- package/dist/src/services/FilesystemUiService.d.ts.map +1 -1
- package/dist/src/services/FilesystemUiService.js +66 -25
- package/dist/src/services/FilesystemUiService.js.map +1 -1
- package/dist/src/services/SyncScheduler.d.ts +12 -4
- package/dist/src/services/SyncScheduler.d.ts.map +1 -1
- package/dist/src/services/SyncScheduler.js +37 -8
- package/dist/src/services/SyncScheduler.js.map +1 -1
- package/dist/src/services/baselineRepair.d.ts +13 -3
- package/dist/src/services/baselineRepair.d.ts.map +1 -1
- package/dist/src/services/baselineRepair.js +13 -3
- package/dist/src/services/baselineRepair.js.map +1 -1
- package/dist/src/services/declaredSources.d.ts +132 -43
- package/dist/src/services/declaredSources.d.ts.map +1 -1
- package/dist/src/services/declaredSources.js +118 -45
- package/dist/src/services/declaredSources.js.map +1 -1
- package/dist/src/store/dataFormats.d.ts +17 -2
- package/dist/src/store/dataFormats.d.ts.map +1 -1
- package/dist/src/store/dataFormats.js +18 -3
- package/dist/src/store/dataFormats.js.map +1 -1
- package/dist/src/sync/DeltaSyncRunner.d.ts.map +1 -1
- package/dist/src/sync/DeltaSyncRunner.js +144 -4
- package/dist/src/sync/DeltaSyncRunner.js.map +1 -1
- package/dist/src/sync/SyncJobQueue.d.ts +7 -2
- package/dist/src/sync/SyncJobQueue.d.ts.map +1 -1
- package/dist/src/sync/SyncJobQueue.js +16 -10
- package/dist/src/sync/SyncJobQueue.js.map +1 -1
- package/dist/src/sync/SyncWatchService.d.ts +21 -4
- package/dist/src/sync/SyncWatchService.d.ts.map +1 -1
- package/dist/src/sync/SyncWatchService.js +115 -10
- package/dist/src/sync/SyncWatchService.js.map +1 -1
- package/dist/src/sync/categoryInference.d.ts +1 -1
- package/dist/src/sync/categoryInference.js +1 -1
- package/dist/src/tools/execCtx.d.ts +2 -2
- package/dist/src/tools/execCtx.js +2 -2
- package/dist/src/tools/fs_read.d.ts.map +1 -1
- package/dist/src/tools/fs_read.js +5 -0
- package/dist/src/tools/fs_read.js.map +1 -1
- package/dist/src/usecases/admitSourceRoot.d.ts +65 -0
- package/dist/src/usecases/admitSourceRoot.d.ts.map +1 -0
- package/dist/src/usecases/admitSourceRoot.js +85 -0
- package/dist/src/usecases/admitSourceRoot.js.map +1 -0
- package/dist/src/usecases/browseSourceFolders.d.ts +85 -0
- package/dist/src/usecases/browseSourceFolders.d.ts.map +1 -0
- package/dist/src/usecases/browseSourceFolders.js +210 -0
- package/dist/src/usecases/browseSourceFolders.js.map +1 -0
- package/dist/src/usecases/createDeclaredSources.d.ts +13 -2
- package/dist/src/usecases/createDeclaredSources.d.ts.map +1 -1
- package/dist/src/usecases/createDeclaredSources.js +39 -0
- package/dist/src/usecases/createDeclaredSources.js.map +1 -1
- package/dist/src/usecases/embeddingBacklog.js +1 -1
- package/dist/src/usecases/embeddingBacklog.js.map +1 -1
- package/dist/src/usecases/list.d.ts.map +1 -1
- package/dist/src/usecases/list.js +83 -26
- package/dist/src/usecases/list.js.map +1 -1
- package/dist/src/usecases/listAttachableDevices.d.ts +13 -0
- package/dist/src/usecases/listAttachableDevices.d.ts.map +1 -0
- package/dist/src/usecases/listAttachableDevices.js +10 -0
- package/dist/src/usecases/listAttachableDevices.js.map +1 -0
- package/dist/src/usecases/listSourcePackages.d.ts.map +1 -1
- package/dist/src/usecases/listSourcePackages.js +2 -5
- package/dist/src/usecases/listSourcePackages.js.map +1 -1
- package/dist/src/usecases/purgeExcludedPoints.d.ts.map +1 -1
- package/dist/src/usecases/purgeExcludedPoints.js +3 -2
- package/dist/src/usecases/purgeExcludedPoints.js.map +1 -1
- package/dist/src/usecases/readFiles.d.ts.map +1 -1
- package/dist/src/usecases/readFiles.js +99 -1
- package/dist/src/usecases/readFiles.js.map +1 -1
- package/dist/src/usecases/reconcileSourcePackageRoots.d.ts +1 -1
- package/dist/src/usecases/reconcileSourcePackageRoots.d.ts.map +1 -1
- package/dist/src/usecases/reconcileSourcePackageRoots.js +2 -1
- package/dist/src/usecases/reconcileSourcePackageRoots.js.map +1 -1
- package/dist/src/usecases/revokePrincipal.d.ts +7 -5
- package/dist/src/usecases/revokePrincipal.d.ts.map +1 -1
- package/dist/src/usecases/revokePrincipal.js +29 -7
- package/dist/src/usecases/revokePrincipal.js.map +1 -1
- package/dist/src/usecases/scheduleSourceSync.d.ts +7 -0
- package/dist/src/usecases/scheduleSourceSync.d.ts.map +1 -1
- package/dist/src/usecases/scheduleSourceSync.js +6 -5
- package/dist/src/usecases/scheduleSourceSync.js.map +1 -1
- package/dist/src/usecases/syncPointWrites.d.ts +2 -0
- package/dist/src/usecases/syncPointWrites.d.ts.map +1 -1
- package/dist/src/usecases/syncPointWrites.js +5 -1
- package/dist/src/usecases/syncPointWrites.js.map +1 -1
- package/dist/src/usecases/triggerSourceSync.d.ts +46 -0
- package/dist/src/usecases/triggerSourceSync.d.ts.map +1 -0
- package/dist/src/usecases/triggerSourceSync.js +75 -0
- package/dist/src/usecases/triggerSourceSync.js.map +1 -0
- package/dist/src/usecases/uploadBinary.d.ts +2 -1
- package/dist/src/usecases/uploadBinary.d.ts.map +1 -1
- package/dist/src/usecases/uploadBinary.js +2 -2
- package/dist/src/usecases/uploadBinary.js.map +1 -1
- package/dist/src/usecases/vector/endpointGuard.d.ts +1 -1
- package/dist/src/usecases/vector/endpointGuard.js +1 -1
- package/dist/src/usecases/vectorMaintenance.js +1 -1
- package/dist/src/usecases/vectorMaintenance.js.map +1 -1
- package/dist/src/usecases/write/content.js +1 -1
- package/dist/src/vector/qdrantClient.d.ts +1 -1
- package/dist/src/vector/qdrantClient.js +1 -1
- package/dist/src/wiring.d.ts +3 -0
- package/dist/src/wiring.d.ts.map +1 -1
- package/dist/src/wiring.js +72 -6
- package/dist/src/wiring.js.map +1 -1
- package/dist/tools/fs_list.json +1 -1
- package/dist/tools/fs_read.json +1 -1
- package/dist/tools/fs_search.json +1 -1
- package/dist/tools/fs_write.json +1 -1
- package/instructions/brain-model.md +1 -1
- package/package.json +212 -114
- package/rules/brain-rules.md +2 -2
- package/skills/brain-start/SKILL.md +1 -1
- package/skills/connect-business-data/SKILL.md +3 -3
- package/skills/create-package/SKILL.md +26 -26
- package/skills/create-package/references/config-and-credentials.md +16 -13
- package/skills/create-package/references/connectors-and-uri-policies.md +39 -25
- package/skills/create-package/references/manifest-and-discovery.md +12 -12
- package/skills/create-package/references/runtime-and-build.md +19 -19
- package/skills/create-package/references/skills-and-context.md +36 -20
- package/skills/create-package/references/surfaces-and-design.md +16 -16
- package/skills/create-package/references/team-and-workflows.md +2 -2
- package/skills/create-package/references/test-and-ship.md +21 -21
- package/skills/create-package/references/tools-and-schemas.md +5 -5
- package/skills/create-package/scripts/{check-package.sh → check-package.mjs} +79 -65
- package/skills/create-package/scripts/{verify-loaded.sh → verify-loaded.mjs} +156 -162
- package/skills/git/SKILL.md +53 -51
- package/skills/manage-files/SKILL.md +22 -25
- package/skills/manage-sources/SKILL.md +128 -87
- package/skills/manage-uri-policy/SKILL.md +18 -15
- package/skills/organize/SKILL.md +1 -1
- package/skills/sync/SKILL.md +3 -3
- package/tools/fs_list.json +1 -1
- package/tools/fs_read.json +1 -1
- package/tools/fs_search.json +1 -1
- package/tools/fs_write.json +1 -1
- package/workflows/sources-digest.json +1 -1
- package/dist/src/connectors/LocalDiskConnector.d.ts +0 -171
- package/dist/src/connectors/LocalDiskConnector.d.ts.map +0 -1
- package/dist/src/connectors/LocalDiskConnector.js +0 -1233
- package/dist/src/connectors/LocalDiskConnector.js.map +0 -1
- package/docs/drive-overview.md +0 -59
- package/skills/manage-files/scripts/copy.sh +0 -7
- package/skills/manage-files/scripts/create.sh +0 -7
- package/skills/manage-files/scripts/mkdir.sh +0 -7
- package/skills/manage-files/scripts/modify.sh +0 -7
- package/skills/manage-files/scripts/upload.sh +0 -16
- package/skills/manage-files/scripts/write.sh +0 -29
- package/skills/manage-sources/scripts/reembed.sh +0 -23
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* GET /sources/:slug — get single source config
|
|
7
7
|
* GET /sources/kinds — list registered source kinds (+ trust, package attribution)
|
|
8
8
|
* GET /sources/kinds/:kind/schema — JSON Schema for admin source form
|
|
9
|
-
* GET /sources/discoverable — well-known unattached source roots (W3E;
|
|
9
|
+
* GET /sources/discoverable — well-known unattached source roots (W3E; sources.mount)
|
|
10
10
|
* POST /sources — create source
|
|
11
11
|
* POST /sources/preflight — validate/authorize create without writing
|
|
12
12
|
* PATCH /sources — update source
|
|
@@ -18,9 +18,13 @@
|
|
|
18
18
|
* POST /sources/reset — reset sync state (cancels active job)
|
|
19
19
|
* GET /sources/sync-status — job status (?source= single, else all visible)
|
|
20
20
|
* POST /sources/enabled — light enable/disable toggle (W3K)
|
|
21
|
-
* GET /sources/browse —
|
|
22
|
-
* protected zones + the
|
|
23
|
-
*
|
|
21
|
+
* GET /sources/browse — one level of folders: on this server
|
|
22
|
+
* (sources.mount; protected zones + the
|
|
23
|
+
* fs root need sources.privileged; in
|
|
24
|
+
* Docker under /mounts), or with
|
|
25
|
+
* ?device= on the caller's OWN device
|
|
26
|
+
* (device.own; empty path = the
|
|
27
|
+
* folders it allows this host)
|
|
24
28
|
* GET /sources/health — get sources health
|
|
25
29
|
*
|
|
26
30
|
* Sync-shaped POSTs return 202 + a SyncQueueJobSnapshot: the run executes on
|
|
@@ -28,34 +32,40 @@
|
|
|
28
32
|
* froze the host on repo-sized sources).
|
|
29
33
|
*/
|
|
30
34
|
import { existsSync } from 'node:fs';
|
|
31
|
-
import { isAbsolute, resolve as resolvePath } from 'node:path';
|
|
32
|
-
import { canAccessScope,
|
|
33
|
-
|
|
35
|
+
import { dirname as dirnameOf, isAbsolute, resolve as resolvePath } from 'node:path';
|
|
36
|
+
import { HOST_SOURCE_KIND, canAccessScope, isConnectorPlaneExecKind, isSourceScope, sourcePlane } from '@neuralis/package-system/contracts';
|
|
37
|
+
// `isOwnerOrAdminEffective` gates the code-LOAD surface (`packageFolders`): the
|
|
38
|
+
// ordinal, never a role name — the Sources panel hides the field with the SAME call.
|
|
39
|
+
import { hasFeature, isOwnerOrAdminEffective } from '@neuralis/package-system/access';
|
|
34
40
|
import { assertImmutablePolicyFloorsPreserved, CallerPolicyFloorError, containsPolicyFloorMarker, ImmutablePolicyFloorError, } from '@neuralis/package-system/policies';
|
|
35
|
-
import { protectedRootPaths, seedExcludedMachineWrittenGlobs } from '@neuralis/package-system/paths';
|
|
36
|
-
import { resolveLocalRoot,
|
|
41
|
+
import { hostPathFlavour, isFilesystemRoot, isPathInside, protectedRootPaths, seedExcludedMachineWrittenGlobs } from '@neuralis/package-system/paths';
|
|
42
|
+
import { NEURALIS_TREE_ANCHOR, resolveLocalRoot, resolvedLocalRootOrNull } from '../connectors/inProcessTransport.js';
|
|
43
|
+
import { SOURCE_ANCHOR_UNRESOLVABLE, admitSourceRoot, unlessAnchorUnresolvable } from '../usecases/admitSourceRoot.js';
|
|
44
|
+
import { HostConnector } from '../connectors/HostConnector.js';
|
|
45
|
+
import { listAttachableDevices } from '../usecases/listAttachableDevices.js';
|
|
46
|
+
import { browseDeviceFolders, browseServerFolders } from '../usecases/browseSourceFolders.js';
|
|
37
47
|
import { filterVisibleRepoNames, getSourceGitSummary, resolveGitCanWrite, resolveGitCanExec } from '../git/sourceGitControl.js';
|
|
48
|
+
import { probePlaceOf } from '../git/gitPlace.js';
|
|
38
49
|
import { matchDeclaredSource, isOwnDataZoneDeclaration } from '../services/declaredSources.js';
|
|
39
50
|
import { platformRegistries } from '../wiring.js';
|
|
40
|
-
import { resolveSourceDescription } from '../domain/sourceConfig.js';
|
|
51
|
+
import { resolveSourceDescription, validatePackageFolders, sourceScopeOrigin } from '../domain/sourceConfig.js';
|
|
41
52
|
import { extractCtx, ensureAccess, getServices, toPublicSourceConfig, toScopeAccessCtx, isPermissions, normalizePermissions, isSyncConfig } from './helpers.js';
|
|
42
53
|
import { EmbeddingScopeLimitError } from '../sync/SyncJobQueue.js';
|
|
43
54
|
import { canReadUri } from '../domain/access/uriReadable.js';
|
|
55
|
+
import { NATIVE_LOCAL_EDIT_REFUSAL, NATIVE_LOCAL_REFUSAL, attachableNatively, fencedEditWidens, nativeLocalFenceApplies, nativeLocalPlacement, nativeLocalRootRefused } from '../domain/access/nativeLocalFence.js';
|
|
44
56
|
import { listScopeDocumentUris, estimateScopedReembed } from '../usecases/vector/estimate.js';
|
|
45
57
|
import { embeddingEnabled, resolveActiveEmbeddingShape } from '../config/env.js';
|
|
46
58
|
import { parsePurgeSelector, purgeSourceSelection } from '../usecases/purgeExcludedPoints.js';
|
|
47
59
|
export const pattern = 'sources/*';
|
|
48
|
-
export const feature = '
|
|
60
|
+
export const feature = 'files.read';
|
|
49
61
|
// The module gate is the read tier (listing a source is member-visible); every
|
|
50
62
|
// mutating / enumerating arm escalates in-handler through `hasRouteFeature`.
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// in a loop — so the drift guard carries it as a named exemption row.
|
|
63
|
+
// A device-plane (paired-device) source is attached by its owner under
|
|
64
|
+
// `device.own`, which agent-core's device gate answers — not a gate here.
|
|
54
65
|
export const escalations = [
|
|
55
|
-
'
|
|
56
|
-
'
|
|
57
|
-
'
|
|
58
|
-
'drive.sync',
|
|
66
|
+
'sources.mount',
|
|
67
|
+
'sources.privileged',
|
|
68
|
+
'sources.sync',
|
|
59
69
|
'platform.vector.maintain',
|
|
60
70
|
];
|
|
61
71
|
export const GET = async (req, raw) => {
|
|
@@ -75,6 +85,11 @@ export const GET = async (req, raw) => {
|
|
|
75
85
|
if (sub === 'sync-status') {
|
|
76
86
|
return handleSyncStatus(req, state);
|
|
77
87
|
}
|
|
88
|
+
if (sub === 'devices') {
|
|
89
|
+
const ctx = extractCtx(req);
|
|
90
|
+
await ensureAccess(ctx, state);
|
|
91
|
+
return { status: 200, body: await listAttachableDevices(state.ctx.packages, ctx.userId) };
|
|
92
|
+
}
|
|
78
93
|
if (sub === 'health') {
|
|
79
94
|
const ctx = extractCtx(req);
|
|
80
95
|
await ensureAccess(ctx, state);
|
|
@@ -102,17 +117,18 @@ export const GET = async (req, raw) => {
|
|
|
102
117
|
// against the declared sources (unspoofable slug+kind+resolved-root) so the
|
|
103
118
|
// Sources panel can group rows by their owning package; user mounts → null.
|
|
104
119
|
const infra = await state.infraFactory.getInfra(ctx.projectId);
|
|
105
|
-
const matchCtx = { projectRoot: infra?.projectRoot ?? '', appRoot: state.ctx.platform.runtimeStack().appRoot };
|
|
120
|
+
const matchCtx = { projectRoot: infra?.projectRoot ?? '', appRoot: state.ctx.platform.runtimeStack().appRoot, isDocker: state.ctx.platform.runtimeStack().isDocker };
|
|
106
121
|
const declsByPkg = state.ctx.platform.packageSourceDeclarations();
|
|
107
122
|
const dataSyncExclude = seedExcludedMachineWrittenGlobs(state.ctx.platform.machineWrittenPaths());
|
|
108
123
|
// The projection reads every row's config from the listing already in hand,
|
|
109
124
|
// so the per-row root-read check costs no config fetch.
|
|
110
|
-
const
|
|
125
|
+
const stack = state.ctx.platform.runtimeStack();
|
|
126
|
+
const protectedRoots = buildProtectedRoots(stack);
|
|
111
127
|
const configCache = new Map(allConfigs.map((c) => [c.source, c]));
|
|
112
128
|
// Source-level git indicator (G2.1 / spellbound-heron) — the CONFIG-panel half
|
|
113
129
|
// of the two summary builders (the TREE half is `FilesystemService.list`;
|
|
114
130
|
// MAJOR-1 — BOTH must be enriched). CHEAP + bounded O(1)/source: for each
|
|
115
|
-
// git-backed
|
|
131
|
+
// git-backed `host` folder (this server's or a device's — read without a spawn), resolve its root and stamp
|
|
116
132
|
// `git = { isRepo, repoName, branch }` from `getSourceGitSummary` (a `.git` stat + an
|
|
117
133
|
// O(1) `.git/HEAD` branch read, TTL-cached — never the whole-repo status).
|
|
118
134
|
// Runs on `allConfigs` AFTER `listSourceConfigs`'s scope filter (leak-safe).
|
|
@@ -124,7 +140,7 @@ export const GET = async (req, raw) => {
|
|
|
124
140
|
const origin = matched?.originPackage ?? null;
|
|
125
141
|
const git = await resolveSourceGit(config, infra, ctx);
|
|
126
142
|
const readable = await rootReadable(infra, ctx, config.source, configCache);
|
|
127
|
-
const view = await sourceConfigView(req.session, config, infra?.projectRoot ?? '', protectedRoots, readable, !config.sync && matched && isOwnDataZoneDeclaration(matched.originPackage, matched.decl, state.ctx.selfId) ? dataSyncExclude : undefined);
|
|
143
|
+
const view = await sourceConfigView(req.session, config, infra?.projectRoot ?? '', stack.isDocker, protectedRoots, readable, deviceAttachVerdict(state, ctx, config.connection), !config.sync && matched && isOwnDataZoneDeclaration(matched.originPackage, matched.decl, state.ctx.selfId) ? dataSyncExclude : undefined);
|
|
128
144
|
return {
|
|
129
145
|
...toPublicSourceConfig(config, view),
|
|
130
146
|
// DERIVED, never persisted — the same chain the `<runtime_stack>`
|
|
@@ -141,8 +157,8 @@ export const GET = async (req, raw) => {
|
|
|
141
157
|
kind_trust: metadata?.packageTrust ?? null,
|
|
142
158
|
origin_package: origin,
|
|
143
159
|
orphan: !metadata, embedding_enabled: embeddingEnabled(),
|
|
144
|
-
canMaintain: hasRouteFeature(req.session, '
|
|
145
|
-
&& canAccessScope(config.scope, access, 'write') && readable,
|
|
160
|
+
canMaintain: hasRouteFeature(req.session, 'sources.mount') && hasRouteFeature(req.session, 'sources.sync')
|
|
161
|
+
&& canAccessScope(config.scope, access, 'write', sourceScopeOrigin(config.connection)) && readable,
|
|
146
162
|
orphan_hint: metadata ? null : `Package contributing kind "${config.connection.kind}" is missing — remove the source or reinstall the package.`,
|
|
147
163
|
...(git ? { git } : {}),
|
|
148
164
|
};
|
|
@@ -151,8 +167,8 @@ export const GET = async (req, raw) => {
|
|
|
151
167
|
};
|
|
152
168
|
/**
|
|
153
169
|
* Resolve the source-row git indicator for `GET /sources` (G2.1). Returns
|
|
154
|
-
* `{ isRepo: true, repoName?, branch? }` for a git-backed
|
|
155
|
-
* E1 (MAJOR-C), `{ isRepo: false, containsRepos: true }` for a
|
|
170
|
+
* `{ isRepo: true, repoName?, branch? }` for a git-backed `host` folder — or,
|
|
171
|
+
* E1 (MAJOR-C), `{ isRepo: false, containsRepos: true }` for a folder
|
|
156
172
|
* whose NON-repo root contains nested depth-1 checkouts (the drop condition is
|
|
157
173
|
* `isRepo || containsRepos`; without the widening the field never reaches the
|
|
158
174
|
* client and the multi-repo picker is dead-on-arrival). `undefined` for
|
|
@@ -164,12 +180,12 @@ export const GET = async (req, raw) => {
|
|
|
164
180
|
* never a host path. Fail-soft: any git problem yields `undefined`.
|
|
165
181
|
*/
|
|
166
182
|
async function resolveSourceGit(config, infra, ctx) {
|
|
167
|
-
if (config.connection
|
|
183
|
+
if (!config.connection || sourcePlane(config.connection.kind ?? '', config.connection.config) === 'connector')
|
|
168
184
|
return undefined;
|
|
169
185
|
const connector = infra?.connectors?.resolveBySource?.(config.source) ?? null;
|
|
170
|
-
if (!(connector instanceof
|
|
186
|
+
if (!(connector instanceof HostConnector))
|
|
171
187
|
return undefined;
|
|
172
|
-
const summary = await getSourceGitSummary(connector
|
|
188
|
+
const summary = await getSourceGitSummary(probePlaceOf(connector));
|
|
173
189
|
if (!summary)
|
|
174
190
|
return undefined;
|
|
175
191
|
if (!summary.isRepo) {
|
|
@@ -209,8 +225,8 @@ export const POST = async (req, raw) => {
|
|
|
209
225
|
if (sub === 'purge') {
|
|
210
226
|
const ctx = extractCtx(req);
|
|
211
227
|
await ensureAccess(ctx, state);
|
|
212
|
-
if (!hasRouteFeature(req.session, '
|
|
213
|
-
return { status: 403, body: { error: 'Index cleanup requires
|
|
228
|
+
if (!hasRouteFeature(req.session, 'sources.mount') || !hasRouteFeature(req.session, 'sources.sync')) {
|
|
229
|
+
return { status: 403, body: { error: 'Index cleanup requires sources.mount and sources.sync.' } };
|
|
214
230
|
}
|
|
215
231
|
const body = req.body;
|
|
216
232
|
const source = typeof body?.source === 'string' ? body.source : '';
|
|
@@ -221,7 +237,7 @@ export const POST = async (req, raw) => {
|
|
|
221
237
|
}
|
|
222
238
|
const { bridge, infra } = await getServices(state, ctx.projectId);
|
|
223
239
|
const config = await bridge.getSourceConfig(ctx, source, toScopeAccessCtx(ctx));
|
|
224
|
-
if (!config || !canAccessScope(config.scope, toScopeAccessCtx(ctx), 'write')) {
|
|
240
|
+
if (!config || !canAccessScope(config.scope, toScopeAccessCtx(ctx), 'write', sourceScopeOrigin(config.connection))) {
|
|
225
241
|
return { status: 404, body: { error: 'Source not found.' } };
|
|
226
242
|
}
|
|
227
243
|
try {
|
|
@@ -267,6 +283,14 @@ async function handleKindsList(state) {
|
|
|
267
283
|
packageId: m.packageId,
|
|
268
284
|
trust: m.packageTrust,
|
|
269
285
|
capabilities: [...m.capabilities].sort(),
|
|
286
|
+
// Can a shell run in this kind's sources at all — the `host` kind's own
|
|
287
|
+
// planes (the S1 uri-policy shell, the device's broker) or a connector that
|
|
288
|
+
// declares `exec`? The kernel's two plane predicates answer; the form offers
|
|
289
|
+
// the Exec policy bit exactly then. `execRequires` (the entry features) is
|
|
290
|
+
// information for its tooltip, never a reason to hide the bit — an owner may
|
|
291
|
+
// set `exec` for members who hold them.
|
|
292
|
+
shell: sourcePlane(m.kind) !== 'connector' || isConnectorPlaneExecKind(m.kind, m.capabilities),
|
|
293
|
+
...(m.execRequires?.length ? { execRequires: [...m.execRequires] } : {}),
|
|
270
294
|
runtimeGated: m.runtimeGated,
|
|
271
295
|
urlScheme: m.uri?.suggestedScheme ?? m.kind,
|
|
272
296
|
configSchema: m.configSchema,
|
|
@@ -336,6 +360,7 @@ async function withEffectiveDescription(config, state, ctx, session) {
|
|
|
336
360
|
const matched = matchDeclaredSource(config, state.ctx.platform.packageSourceDeclarations(), {
|
|
337
361
|
projectRoot: infra?.projectRoot ?? '',
|
|
338
362
|
appRoot: state.ctx.platform.runtimeStack().appRoot,
|
|
363
|
+
isDocker: state.ctx.platform.runtimeStack().isDocker,
|
|
339
364
|
});
|
|
340
365
|
return {
|
|
341
366
|
...toPublicSourceConfig(config, await publicViewFor(session, ctx, config, state, infra)),
|
|
@@ -359,7 +384,7 @@ async function handleSourcesCreate(req, state, preflight = false) {
|
|
|
359
384
|
const description = body?.description;
|
|
360
385
|
// `null` = absent. A read-then-resend caller (the manage-sources skill's jq
|
|
361
386
|
// projection) legitimately round-trips a field the listing never carried.
|
|
362
|
-
const
|
|
387
|
+
const packageFolders = body?.packageFolders ?? undefined;
|
|
363
388
|
if (!source)
|
|
364
389
|
return { status: 400, body: { error: 'Missing source' } };
|
|
365
390
|
if (description !== undefined && typeof description !== 'string') {
|
|
@@ -384,72 +409,101 @@ async function handleSourcesCreate(req, state, preflight = false) {
|
|
|
384
409
|
},
|
|
385
410
|
};
|
|
386
411
|
}
|
|
387
|
-
// R2b — `
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
// is a 400 regardless of role.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
412
|
+
// R2b — `packageFolders` is a code-LOAD surface (it turns folders of a source
|
|
413
|
+
// into package sources). Mirror the PATCH gate: source-relative folders,
|
|
414
|
+
// owner/admin-effective, folder kind only. Validated here BEFORE the access
|
|
415
|
+
// check so a malformed value is a 400 regardless of role.
|
|
416
|
+
const packageFoldersError = packageFolders === undefined ? undefined : validatePackageFolders(packageFolders, connection)[0];
|
|
417
|
+
if (packageFoldersError)
|
|
418
|
+
return { status: 400, body: { error: packageFoldersError } };
|
|
394
419
|
await ensureAccess(ctx, state);
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
420
|
+
// A source on the caller's OWN paired device: its owner with `device.own`
|
|
421
|
+
// satisfies the whole attach stack (`sources.mount` included). On the host
|
|
422
|
+
// plane that is the ONLY way in; every other source meets the stack.
|
|
423
|
+
const ownDevice = deviceAttachVerdict(state, ctx, connection);
|
|
424
|
+
if (!ownDevice.allowed && !hasRouteFeature(req.session, 'sources.mount')) {
|
|
425
|
+
return { status: 403, body: { error: 'Creating sources requires sources.mount.' } };
|
|
426
|
+
}
|
|
427
|
+
if (packageFolders !== undefined && !isOwnerOrAdminEffective(req.session)) {
|
|
428
|
+
return { status: 403, body: { error: 'Setting packageFolders requires an owner/admin-effective role.' } };
|
|
429
|
+
}
|
|
430
|
+
// The root PAIR is derived HERE, before any gate, so every gate below judges
|
|
431
|
+
// the pair that will be stored and mounted on either plane.
|
|
432
|
+
const paired = admitSourceRoot({
|
|
433
|
+
connection: connection,
|
|
434
|
+
stack: state.ctx.platform.runtimeStack(),
|
|
435
|
+
mayHandSet: hasRouteFeature(req.session, PRIVILEGED_MOUNT_FEATURE),
|
|
436
|
+
audit: state.ctx.hostPorts?.audit,
|
|
437
|
+
});
|
|
438
|
+
if ('status' in paired)
|
|
439
|
+
return paired;
|
|
440
|
+
const pairedConnection = paired.connection;
|
|
441
|
+
// fenced-courtyard (HEALTH1 B3) — privileged-root ADD gate. `sources.mount`
|
|
405
442
|
// attaches ordinary sources, but attaching a source rooted IN, OVER, or AT a
|
|
406
443
|
// protected host zone (App Zone, the Neuralis tree, the whole host) requires
|
|
407
|
-
// the `
|
|
444
|
+
// the `sources.privileged` feature (not granted below the `admin` tier).
|
|
408
445
|
// The check is canonical
|
|
409
446
|
// path CONTAINMENT against the SHARED, filter-free protected-root table — NOT
|
|
410
447
|
// exact-root or a client suggestion id (both spoofable via subpath / ancestor /
|
|
411
448
|
// `..` / trailing-slash / a Docker free-root attach with no suggestion). The
|
|
412
449
|
// requested root is resolved with `resolveLocalRoot` — the SAME canonicaliser
|
|
413
450
|
// the connector uses — so the gate sees exactly what will be mounted.
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
// and no `protectedRoots.length` precondition
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
451
|
+
// The DEVICE plane is decided BEFORE the root-sensitive container gate below:
|
|
452
|
+
// the device's owner with `device.own`, nobody else, with no root-string
|
|
453
|
+
// inspection and no `protectedRoots.length` precondition (fail closed even
|
|
454
|
+
// when the runtime stack is unavailable; a sub-directory is gated like `/`).
|
|
455
|
+
// The local-plane arm is kind-AGNOSTIC (see `missingAttachFeature`).
|
|
456
|
+
const missingForAttach = await missingAttachFeature(req.session, pairedConnection, buildProtectedRoots(state.ctx.platform.runtimeStack()), async (root, hostRoot) => resolveLocalRoot({
|
|
457
|
+
configured: root, hostRoot, projectRoot: (await state.infraFactory.getInfra(projectId)).projectRoot ?? '',
|
|
458
|
+
isDocker: state.ctx.platform.runtimeStack().isDocker, source,
|
|
459
|
+
}).resolvedRoot, ownDevice);
|
|
421
460
|
if (missingForAttach) {
|
|
422
461
|
return {
|
|
423
462
|
status: 403,
|
|
424
463
|
body: {
|
|
425
|
-
error: missingForAttach.plane === '
|
|
426
|
-
?
|
|
427
|
-
:
|
|
464
|
+
error: missingForAttach.plane === 'device'
|
|
465
|
+
? "Only the device's owner may attach a source on it."
|
|
466
|
+
: missingForAttach.plane === 'owner'
|
|
467
|
+
? `Attaching a source on your device requires the ${missingForAttach.feature} feature.`
|
|
468
|
+
: `Attaching a source rooted in a protected host zone requires the ${missingForAttach.feature} feature.`,
|
|
428
469
|
},
|
|
429
470
|
};
|
|
430
471
|
}
|
|
472
|
+
const fencedAttach = await nativeFenceRefusal(state, projectId, source, pairedConnection);
|
|
473
|
+
if (fencedAttach)
|
|
474
|
+
return fencedAttach;
|
|
431
475
|
const bridge = await resolveBridge(state, projectId);
|
|
432
476
|
const scope = { userId: ctx.userId, projectId };
|
|
433
477
|
const existing = await bridge.getSourceConfig(scope, source, toScopeAccessCtx(ctx));
|
|
434
478
|
if (existing)
|
|
435
479
|
return { status: 409, body: { error: `Source already exists: ${source}` } };
|
|
436
|
-
const normalizedPerms = normalizePermissions(permissions);
|
|
480
|
+
const normalizedPerms = normalizePermissions(permissions, { rolePriorities: ctx.rolePriorities });
|
|
437
481
|
const normalizedConnection = {
|
|
438
|
-
kind:
|
|
439
|
-
config: (
|
|
482
|
+
kind: pairedConnection.kind,
|
|
483
|
+
config: (pairedConnection.config ?? {}),
|
|
440
484
|
};
|
|
441
485
|
const kindMetadata = platformRegistries(state.ctx.platform).sourceKinds.get(normalizedConnection.kind);
|
|
442
486
|
if (!kindMetadata)
|
|
443
487
|
return { status: 400, body: { error: `Unknown source kind "${normalizedConnection.kind}"` } };
|
|
444
|
-
|
|
445
|
-
|
|
488
|
+
// A device folder defaults to the attaching owner's USER scope and is never
|
|
489
|
+
// agent-scoped: `files.read` is a member default, so a project-scoped folder on
|
|
490
|
+
// somebody's machine is readable project-wide the moment it exists — the owner
|
|
491
|
+
// widens it on purpose, never by omission.
|
|
492
|
+
const onDevice = sourcePlane(normalizedConnection.kind, normalizedConnection.config) === 'device';
|
|
493
|
+
const sourceScope = resolveRequestedSourceScope(requestedScope, onDevice ? 'user' : kindMetadata.defaultScope, ctx);
|
|
494
|
+
if (!sourceScope) {
|
|
495
|
+
return { status: 400, body: { code: 'invalid_scope', error: 'scope is "project", "user" or "agent", or an object with one of those kinds' } };
|
|
496
|
+
}
|
|
497
|
+
const allowedScopes = onDevice
|
|
498
|
+
? DEVICE_FOLDER_SCOPES
|
|
499
|
+
: kindMetadata.allowedScopes ?? (kindMetadata.scopeSelectable ? ['project', 'user', 'agent'] : [kindMetadata.defaultScope]);
|
|
446
500
|
if (!kindMetadata.scopeSelectable && sourceScope.kind !== kindMetadata.defaultScope) {
|
|
447
501
|
return { status: 400, body: { error: `Kind "${kindMetadata.kind}" is project-scope only.` } };
|
|
448
502
|
}
|
|
449
503
|
if (!allowedScopes.includes(sourceScope.kind)) {
|
|
450
504
|
return { status: 400, body: { error: `Scope "${sourceScope.kind}" is not allowed for kind "${kindMetadata.kind}".` } };
|
|
451
505
|
}
|
|
452
|
-
if (!canAccessScope(sourceScope, toScopeAccessCtx(ctx), 'write')) {
|
|
506
|
+
if (!canAccessScope(sourceScope, toScopeAccessCtx(ctx), 'write', sourceScopeOrigin(normalizedConnection))) {
|
|
453
507
|
return { status: 403, body: { error: 'Forbidden for requested source scope' } };
|
|
454
508
|
}
|
|
455
509
|
// Multi-operation callers need every conditional feature refusal before an
|
|
@@ -467,8 +521,9 @@ async function handleSourcesCreate(req, state, preflight = false) {
|
|
|
467
521
|
caller: toSyncCaller(ctx),
|
|
468
522
|
...(description !== undefined ? { description: description } : {}),
|
|
469
523
|
...(sync && isSyncConfig(sync) ? { sync: sync } : {}),
|
|
470
|
-
...(
|
|
524
|
+
...(packageFolders !== undefined ? { packageFolders: packageFolders } : {}),
|
|
471
525
|
});
|
|
526
|
+
paired.auditHandSet?.(ctx.userId, projectId, config.source);
|
|
472
527
|
const infra = await state.infraFactory.getInfra(projectId);
|
|
473
528
|
return { status: 201, body: toPublicSourceConfig(config, await publicViewFor(req.session, ctx, config, state, infra)) };
|
|
474
529
|
}
|
|
@@ -485,23 +540,6 @@ async function handleSourcesCreate(req, state, preflight = false) {
|
|
|
485
540
|
function hasRouteFeature(session, featureId) {
|
|
486
541
|
return hasFeature(session, featureId);
|
|
487
542
|
}
|
|
488
|
-
/**
|
|
489
|
-
* Owner/admin-STRENGTH check (R2), keyed on the ordinal, never on a role NAME
|
|
490
|
-
* (D-A). The built-in anchors are `owner 1 · admin 2 · manager 10 · member 20 ·
|
|
491
|
-
* viewer 30` (lower = stronger, `SessionContext.priority`) and a custom role
|
|
492
|
-
* declares its own number in 1..99 — so the admin ANCHOR is the threshold, and
|
|
493
|
-
* a custom role at priority 1 or 2 passes exactly like the built-ins. A
|
|
494
|
-
* wildcard feature grant (`'*'`) is owner-effective too, resolved through the
|
|
495
|
-
* ONE kernel `hasFeature`. Used to gate code-LOAD surfaces like
|
|
496
|
-
* `recognizesPackages`.
|
|
497
|
-
*/
|
|
498
|
-
function isOwnerOrAdminEffective(session) {
|
|
499
|
-
const s = session;
|
|
500
|
-
const priority = typeof s?.priority === 'number' ? s.priority : undefined;
|
|
501
|
-
if (priority !== undefined && priority <= BUILTIN_ROLE_PRIORITY.admin)
|
|
502
|
-
return true;
|
|
503
|
-
return hasRouteFeature(session, '*');
|
|
504
|
-
}
|
|
505
543
|
// ---------------------------------------------------------------------------
|
|
506
544
|
// fenced-courtyard (HEALTH1 B3) — the per-discoverable-source access table.
|
|
507
545
|
//
|
|
@@ -519,47 +557,29 @@ function isOwnerOrAdminEffective(session) {
|
|
|
519
557
|
// is containment in BOTH directions (architect MAJOR-1).
|
|
520
558
|
// ---------------------------------------------------------------------------
|
|
521
559
|
/** Feature required to SEE/ADD a high-blast-radius host zone (C8 — no grant below the admin tier). */
|
|
522
|
-
const PRIVILEGED_MOUNT_FEATURE = '
|
|
560
|
+
const PRIVILEGED_MOUNT_FEATURE = 'sources.privileged';
|
|
523
561
|
/**
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
* `
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
* non-host-plane kind with a string `root` is gated — but rule 1 below is
|
|
534
|
-
* still what makes the HOST plane safe independent of root shape.)
|
|
562
|
+
* The DEVICE plane (a folder whose stored config names a `device` — a paired
|
|
563
|
+
* device, outside the container) has ONE attach rule: the device's owner, holding `device.own`,
|
|
564
|
+
* attaches or edits a source on it, and nobody else does. agent-core's device
|
|
565
|
+
* gate (`devices.mayAttach`) answers both halves; brain-core keeps no device-plane
|
|
566
|
+
* feature of its own. `sources.mount` is a MANAGER default and never reaches a
|
|
567
|
+
* device: a non-owner is refused whatever the role grants, so a manager cannot
|
|
568
|
+
* attach someone's machine at `/` and get read/write over it through `fs_*`.
|
|
569
|
+
* That device's own allowlist (`neuralis device grant`) still bounds what the
|
|
570
|
+
* owner's source can reach.
|
|
535
571
|
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
* The operator-owned broker ceiling is the third, out-of-band clamp: even a
|
|
542
|
-
* granted caller only reaches paths the operator declared. The ceiling is the
|
|
543
|
-
* floor; the feature is the audit trail.
|
|
572
|
+
* The plane is the kernel's `sourcePlane` (`@neuralis/package-system/contracts`)
|
|
573
|
+
* over the STORED connection — imported, never re-declared. Two copies of a plane
|
|
574
|
+
* predicate is how the attach gate and the terminal gate drift apart while both
|
|
575
|
+
* look correct in isolation.
|
|
544
576
|
*/
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
// never re-declared. Two copies of a plane predicate is how the attach gate and
|
|
548
|
-
// the terminal gate drift apart while both look correct in isolation.
|
|
549
|
-
/**
|
|
550
|
-
* Features required to attach a host-plane source, independent of its root.
|
|
551
|
-
*
|
|
552
|
-
* There is deliberately NO root-sensitivity here: on the host plane every root
|
|
553
|
-
* is a protected zone, so a `/home/me/notes` attach is gated exactly like `/`.
|
|
554
|
-
* Anything less would re-open B2 through a subdirectory.
|
|
555
|
-
*/
|
|
556
|
-
function hostPlaneRequiredFeatures() {
|
|
557
|
-
return [HOST_PLANE_MOUNT_FEATURE, PRIVILEGED_MOUNT_FEATURE];
|
|
558
|
-
}
|
|
577
|
+
/** The scopes a device folder may be attached at — never `agent` (see `handleSourcesCreate`). */
|
|
578
|
+
const DEVICE_FOLDER_SCOPES = ['user', 'project'];
|
|
559
579
|
/**
|
|
560
580
|
* Build the filter-free protected-root table from the runtime stack. App Zone,
|
|
561
581
|
* the Neuralis source tree, and the whole host (`/`) all require
|
|
562
|
-
* `
|
|
582
|
+
* `sources.privileged`. `/` is ALWAYS included (both Docker and native) so
|
|
563
583
|
* the ADD gate catches a raw `config.root:'/'` attach that has no discoverable
|
|
564
584
|
* suggestion under Docker.
|
|
565
585
|
*/
|
|
@@ -572,16 +592,22 @@ export function buildProtectedRoots(stack) {
|
|
|
572
592
|
}
|
|
573
593
|
/**
|
|
574
594
|
* Containment in BOTH directions, canonical paths. Fires when the requested
|
|
575
|
-
* root IS, is UNDER, or is an ANCESTOR of a protected root — except
|
|
576
|
-
*
|
|
595
|
+
* root IS, is UNDER, or is an ANCESTOR of a protected root — except a
|
|
596
|
+
* filesystem root (`/`, a Windows drive root), which matches only a whole-host
|
|
597
|
+
* request (it is everyone's ancestor): `/`, or any drive root (`D:\`).
|
|
598
|
+
*
|
|
599
|
+
* The comparison runs in the path flavour of the zone itself: a protected root
|
|
600
|
+
* is a canonical host path, so a drive-letter or UNC form IS a Windows host —
|
|
601
|
+
* backslashes, drive roots and case-insensitive names.
|
|
577
602
|
*/
|
|
578
603
|
function rootMatches(requested, protectedRoot, mode = 'containment') {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
604
|
+
const p = hostPathFlavour(protectedRoot);
|
|
605
|
+
if (isFilesystemRoot(protectedRoot))
|
|
606
|
+
return isFilesystemRoot(requested);
|
|
607
|
+
if (!p.isAbsolute(requested))
|
|
608
|
+
return false;
|
|
609
|
+
if (isPathInside(protectedRoot, requested, p))
|
|
610
|
+
return true; // IS the zone, or under it (subpath attach)
|
|
585
611
|
// The ANCESTOR arm belongs to ATTACH, not to enumeration. Attaching `/home`
|
|
586
612
|
// hands over everything below it, so exposing a zone from above is the same
|
|
587
613
|
// act; LISTING `/home` one level deep does not, and the browse handler drops
|
|
@@ -591,7 +617,7 @@ function rootMatches(requested, protectedRoot, mode = 'containment') {
|
|
|
591
617
|
// opens at `/`, so the first click would deny with no route downward. The
|
|
592
618
|
// zone itself stays protected: `is-or-under` still fires on it, and the
|
|
593
619
|
// handler filters protected CHILDREN out of the listing it returns.
|
|
594
|
-
if (mode === 'containment' &&
|
|
620
|
+
if (mode === 'containment' && isPathInside(requested, protectedRoot, p))
|
|
595
621
|
return true;
|
|
596
622
|
return false;
|
|
597
623
|
}
|
|
@@ -619,28 +645,155 @@ export function requiredFeaturesForRoot(root, protectedRoots, kind, mode = 'cont
|
|
|
619
645
|
* the listing projection, so "you see the path you could attach" cannot drift
|
|
620
646
|
* from what attaching requires.
|
|
621
647
|
*
|
|
622
|
-
* -
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
648
|
+
* - Device plane: the DEVICE's owner holding `device.own` (`ownDevice`,
|
|
649
|
+
* agent-core's device gate) and nobody else — a non-owner is refused as
|
|
650
|
+
* `device`, an owner without `device.own` is told the feature it lacks; no
|
|
651
|
+
* root inspection and no runtime-stack precondition (fail closed).
|
|
652
|
+
* - Local plane, kind-AGNOSTIC: any kind whose config carries a string
|
|
653
|
+
* `root` — the in-process folder, and every root-bearing kind a package registers later —
|
|
626
654
|
* resolves through `resolveRoot` (the connector's own canonicaliser) and hits
|
|
627
655
|
* the SAME containment table. Scoping this to one kind is how a newly
|
|
628
656
|
* registered root-bearing kind walks straight past the gate.
|
|
629
657
|
*/
|
|
630
|
-
async function missingAttachFeature(session, connection, protectedRoots, resolveRoot) {
|
|
631
|
-
if (
|
|
632
|
-
|
|
633
|
-
|
|
658
|
+
async function missingAttachFeature(session, connection, protectedRoots, resolveRoot, ownDevice) {
|
|
659
|
+
if (sourcePlane(connection.kind, connection.config) === 'device') {
|
|
660
|
+
if (ownDevice.allowed)
|
|
661
|
+
return null;
|
|
662
|
+
return { feature: 'device.own', plane: ownDevice.owner ? 'owner' : 'device' };
|
|
634
663
|
}
|
|
635
664
|
if (protectedRoots.length === 0)
|
|
636
665
|
return null;
|
|
637
|
-
const root = connection.config
|
|
666
|
+
const { root, hostRoot } = (connection.config ?? {});
|
|
638
667
|
if (typeof root !== 'string' || root.length === 0)
|
|
639
668
|
return null;
|
|
640
|
-
|
|
669
|
+
// Only the in-process folder reads the pair; another root-bearing kind is judged on `root`.
|
|
670
|
+
const required = requiredFeaturesForRoot(await resolveRoot(root, connection.kind === HOST_SOURCE_KIND ? hostRoot : undefined), protectedRoots, 'add');
|
|
641
671
|
const feature = required.find((f) => !hasRouteFeature(session, f));
|
|
642
672
|
return feature ? { feature, plane: 'zone' } : null;
|
|
643
673
|
}
|
|
674
|
+
/**
|
|
675
|
+
* Ask agent-core's ONE device gate (`devices.mayAttach`) about the device a
|
|
676
|
+
* device-plane connection names. brain-core reads `config.device` as an opaque
|
|
677
|
+
* string and knows no device semantics; no agent-core, no device or a
|
|
678
|
+
* non-host kind ⇒ `{ allowed: false, owner: false }` (fail closed).
|
|
679
|
+
*/
|
|
680
|
+
function deviceAttachVerdict(state, ctx, connection) {
|
|
681
|
+
const denied = { allowed: false, owner: false };
|
|
682
|
+
if (sourcePlane(connection.kind, connection.config) !== 'device')
|
|
683
|
+
return denied;
|
|
684
|
+
const device = connection.config?.device;
|
|
685
|
+
if (typeof device !== 'string' || !device)
|
|
686
|
+
return denied;
|
|
687
|
+
const session = {
|
|
688
|
+
userId: ctx.userId,
|
|
689
|
+
projectId: ctx.projectId,
|
|
690
|
+
...(ctx.agentId ? { agentId: ctx.agentId } : {}),
|
|
691
|
+
callerId: '@neuralis/brain-core',
|
|
692
|
+
callerTrust: 'native',
|
|
693
|
+
grantedFeatures: [...ctx.grantedFeatures],
|
|
694
|
+
};
|
|
695
|
+
const agentCore = state.ctx.packages?.getPackageApi('@neuralis/agent-core', session);
|
|
696
|
+
return agentCore?.devices?.mayAttach({ userId: ctx.userId, grantedFeatures: [...ctx.grantedFeatures] }, device) ?? denied;
|
|
697
|
+
}
|
|
698
|
+
/** The 403 text of a refused edit — one wording for the route PATCH and the cross-package writer. */
|
|
699
|
+
function editRefusalMessage(missing) {
|
|
700
|
+
return missing.plane === 'device'
|
|
701
|
+
? "Only the device's owner may edit a source on it."
|
|
702
|
+
: missing.plane === 'owner'
|
|
703
|
+
? `Editing a source on your device requires the ${missing.feature} feature.`
|
|
704
|
+
: `Editing a source rooted in a protected host zone requires the ${missing.feature} feature.`;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* The edit gates of a source PATCH, in order — ONE ladder for every
|
|
708
|
+
* source-config writer (the route PATCH, `POST policies/uri`, a widening
|
|
709
|
+
* `POST sources/enabled`, and the cross-package writer `sourceEditFence`), so
|
|
710
|
+
* none can skip a rung:
|
|
711
|
+
* the source's scope (another member's user/agent source needs
|
|
712
|
+
* `files.writeOthers`), the immutable `floor:true` rows (409 when dropped or
|
|
713
|
+
* altered), and what attaching it needs (`missingAttachFeature`: a device folder
|
|
714
|
+
* is its owner's alone, a protected-zone root needs `sources.privileged`).
|
|
715
|
+
* The refusal the caller answers, or null.
|
|
716
|
+
*/
|
|
717
|
+
export async function sourceEditRefusal(state, editor, existing, nextPermissions, ownDevice = deviceAttachVerdict(state, editor, existing.connection)) {
|
|
718
|
+
const grants = { grantedFeatures: [...editor.grantedFeatures] };
|
|
719
|
+
if (!canAccessScope(existing.scope, { userId: editor.userId, ...(editor.agentId ? { agentId: editor.agentId } : {}), grantedFeatures: grants.grantedFeatures }, 'write', sourceScopeOrigin(existing.connection))) {
|
|
720
|
+
return { status: 403, body: { error: 'Forbidden for source scope' } };
|
|
721
|
+
}
|
|
722
|
+
try {
|
|
723
|
+
assertImmutablePolicyFloorsPreserved(existing.permissions, nextPermissions);
|
|
724
|
+
}
|
|
725
|
+
catch (err) {
|
|
726
|
+
if (err instanceof ImmutablePolicyFloorError)
|
|
727
|
+
return { status: 409, body: { code: err.code, error: err.message } };
|
|
728
|
+
throw err;
|
|
729
|
+
}
|
|
730
|
+
// PATCH cannot change `connection` (its `device` included — a transport flip is
|
|
731
|
+
// a delete and a re-attach, never an edit), but it CAN widen an existing device-plane
|
|
732
|
+
// source's permissions (read → write). Editing one therefore needs what
|
|
733
|
+
// attaching one does — the device's owner with `device.own` — otherwise a
|
|
734
|
+
// plain-`sources.mount` manager could escalate a read-only host source in place.
|
|
735
|
+
// Same rule for the local plane: a PATCH can widen permissions (read →
|
|
736
|
+
// write) on a source already rooted in a protected zone, so editing one
|
|
737
|
+
// requires the same grants attaching one does — for EVERY root-bearing kind,
|
|
738
|
+
// not just the folder kind.
|
|
739
|
+
const stack = state.ctx.platform.runtimeStack();
|
|
740
|
+
const editJudged = await unlessAnchorUnresolvable(missingAttachFeature(grants, existing.connection, buildProtectedRoots(stack), async (root, hostRoot) => resolveLocalRoot({
|
|
741
|
+
configured: root, hostRoot, projectRoot: (await state.infraFactory.getInfra(editor.projectId)).projectRoot ?? '',
|
|
742
|
+
isDocker: stack.isDocker, source: existing.source,
|
|
743
|
+
}).resolvedRoot, ownDevice));
|
|
744
|
+
// An anchored folder this plane cannot open is never edited (fail closed, nothing written).
|
|
745
|
+
if (!editJudged)
|
|
746
|
+
return { status: 400, body: SOURCE_ANCHOR_UNRESOLVABLE };
|
|
747
|
+
return editJudged.value ? { status: 403, body: { error: editRefusalMessage(editJudged.value) } } : null;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* The native folder fence for an attach or an edit (`domain/access/nativeLocalFence.ts`):
|
|
751
|
+
* the 403 a fenced root answers, or null. Judged on the root as the connector
|
|
752
|
+
* resolves it; a device folder or another kind ⇒ never this gate's.
|
|
753
|
+
*/
|
|
754
|
+
export async function nativeFenceRefusal(state, projectId, source, connection) {
|
|
755
|
+
if (sourcePlane(connection.kind, connection.config) !== 'local')
|
|
756
|
+
return null;
|
|
757
|
+
const stack = state.ctx.platform.runtimeStack();
|
|
758
|
+
if (!nativeLocalFenceApplies(stack))
|
|
759
|
+
return null;
|
|
760
|
+
const projectRoot = (await state.infraFactory.getInfra(projectId)).projectRoot ?? '';
|
|
761
|
+
const { root, hostRoot } = (connection.config ?? {});
|
|
762
|
+
const refused = await nativeLocalRootRefused({
|
|
763
|
+
stack,
|
|
764
|
+
connection,
|
|
765
|
+
resolvedRoot: resolveLocalRoot({ configured: typeof root === 'string' ? root : '', hostRoot, projectRoot, isDocker: stack.isDocker, source }).resolvedRoot,
|
|
766
|
+
projectRoot,
|
|
767
|
+
privilegedRoots: buildProtectedRoots(stack).map((p) => p.root),
|
|
768
|
+
});
|
|
769
|
+
return refused ? { status: 403, body: NATIVE_LOCAL_REFUSAL } : null;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* The route PATCH's edit gates on the cross-package write (`upsertSourceConfig`):
|
|
773
|
+
* the SAME `sourceEditRefusal` ladder (scope, immutable floors, device owner,
|
|
774
|
+
* protected zone) with the route's codes and texts, then the native fence, which
|
|
775
|
+
* refuses only an edit that widens a fenced source; an unjudgeable shape is refused.
|
|
776
|
+
*/
|
|
777
|
+
export function sourceEditFence(state) {
|
|
778
|
+
return async (caller, existing, next) => {
|
|
779
|
+
const refused = await sourceEditRefusal(state, caller, existing, next.permissions);
|
|
780
|
+
if (refused)
|
|
781
|
+
return { status: refused.status, error: refused.body.error, ...(refused.body.code ? { code: refused.body.code } : {}) };
|
|
782
|
+
if (!(await nativeFenceRefusal(state, caller.projectId, existing.source, existing.connection)))
|
|
783
|
+
return null;
|
|
784
|
+
let widens = true;
|
|
785
|
+
try {
|
|
786
|
+
widens = fencedEditWidens(existing, {
|
|
787
|
+
permissions: next.permissions,
|
|
788
|
+
...(next.sync ? { sync: next.sync } : {}),
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
widens = true;
|
|
793
|
+
}
|
|
794
|
+
return widens ? { status: 403, error: NATIVE_LOCAL_EDIT_REFUSAL.error } : null;
|
|
795
|
+
};
|
|
796
|
+
}
|
|
644
797
|
/**
|
|
645
798
|
* May this caller READ the source's root — the same per-uri read predicate the
|
|
646
799
|
* listing paths use. Fails closed per row: no config store, or a throwing
|
|
@@ -653,27 +806,37 @@ async function rootReadable(infra, ctx, source, configCache) {
|
|
|
653
806
|
}
|
|
654
807
|
/**
|
|
655
808
|
* The view `toPublicSourceConfig` answers with. The connection config goes to
|
|
656
|
-
* a caller who could attach this source (`
|
|
809
|
+
* a caller who could attach this source (`sources.mount` plus the gates
|
|
657
810
|
* `missingAttachFeature` runs) OR can read its root — the files there already
|
|
658
811
|
* show their host path as `os_uri`, so withholding it from a reader hides
|
|
659
|
-
* nothing; the raw last-sync error goes to a `
|
|
812
|
+
* nothing; the raw last-sync error goes to a `sources.sync` holder, the tier
|
|
660
813
|
* `sources/sync-status` answers to. A feature, never a role name.
|
|
661
814
|
*/
|
|
662
|
-
async function sourceConfigView(session, config, projectRoot, protectedRoots, readable, defaultSyncExclude) {
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
815
|
+
async function sourceConfigView(session, config, projectRoot, isDocker, protectedRoots, readable, ownDevice, defaultSyncExclude) {
|
|
816
|
+
const resolve = (root, hostRoot) => resolveLocalRoot({ configured: root, hostRoot, projectRoot, isDocker, source: config.source }).resolvedRoot;
|
|
817
|
+
// A stored anchor this plane cannot open is a folder nobody may attach here
|
|
818
|
+
// (and it opens nowhere) — never an error that empties the whole listing.
|
|
819
|
+
const mayAttach = (ownDevice.allowed || hasRouteFeature(session, 'sources.mount'))
|
|
820
|
+
&& (await unlessAnchorUnresolvable(missingAttachFeature(session, config.connection, protectedRoots, resolve, ownDevice)))?.value === null;
|
|
821
|
+
// The folder THIS plane opens — natively the pair's machine half, never the container spelling.
|
|
822
|
+
const { root, hostRoot } = config.connection.config ?? {};
|
|
823
|
+
const inProcess = sourcePlane(config.connection.kind, config.connection.config) === 'local';
|
|
824
|
+
const rootEffective = inProcess && typeof root === 'string' && root
|
|
825
|
+
? resolvedLocalRootOrNull({ configured: root, hostRoot, projectRoot, isDocker, source: config.source }) ?? undefined
|
|
826
|
+
: undefined;
|
|
827
|
+
return { mayReadPaths: mayAttach || readable, maySeeSyncErrors: hasRouteFeature(session, 'sources.sync'),
|
|
828
|
+
...(defaultSyncExclude ? { defaultSyncExclude } : {}), ...(rootEffective ? { rootEffective } : {}) };
|
|
667
829
|
}
|
|
668
830
|
/** `sourceConfigView` for a single-row answer (single GET, mutation echoes). */
|
|
669
831
|
async function publicViewFor(session, ctx, config, state, infra) {
|
|
670
832
|
const readable = await rootReadable(infra, ctx, config.source, new Map([[config.source, config]]));
|
|
833
|
+
const stack = state.ctx.platform.runtimeStack();
|
|
671
834
|
const matched = !config.sync ? matchDeclaredSource(config, state.ctx.platform.packageSourceDeclarations(), {
|
|
672
|
-
projectRoot: infra?.projectRoot ?? '', appRoot:
|
|
835
|
+
projectRoot: infra?.projectRoot ?? '', appRoot: stack.appRoot, isDocker: stack.isDocker,
|
|
673
836
|
}) : null;
|
|
674
837
|
const defaults = matched && isOwnDataZoneDeclaration(matched.originPackage, matched.decl, state.ctx.selfId)
|
|
675
838
|
? seedExcludedMachineWrittenGlobs(state.ctx.platform.machineWrittenPaths()) : undefined;
|
|
676
|
-
return sourceConfigView(session, config, infra?.projectRoot ?? '', buildProtectedRoots(state
|
|
839
|
+
return sourceConfigView(session, config, infra?.projectRoot ?? '', stack.isDocker, buildProtectedRoots(stack), readable, deviceAttachVerdict(state, ctx, config.connection), defaults);
|
|
677
840
|
}
|
|
678
841
|
function filterSourceForList(config, ctx, requestedScope, includeObserved) {
|
|
679
842
|
if (requestedScope && config.scope.kind !== requestedScope)
|
|
@@ -683,10 +846,21 @@ function filterSourceForList(config, ctx, requestedScope, includeObserved) {
|
|
|
683
846
|
return false;
|
|
684
847
|
return true;
|
|
685
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* The create body's `scope`: a kind name, or an object. A complete object is taken as
|
|
851
|
+
* written (a foreign `userId` then meets the write `canAccessScope`); an object naming
|
|
852
|
+
* only a valid `kind` gets the caller's ids, like the name; any other object or name is
|
|
853
|
+
* `null` (400 `invalid_scope`) — never silently the kind default. Absent = the default.
|
|
854
|
+
*/
|
|
686
855
|
function resolveRequestedSourceScope(raw, defaultScope, ctx) {
|
|
687
856
|
if (isSourceScope(raw))
|
|
688
857
|
return raw;
|
|
689
|
-
const
|
|
858
|
+
const named = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw.kind : raw;
|
|
859
|
+
if (raw && typeof raw === 'object' && named !== 'project' && named !== 'user' && named !== 'agent')
|
|
860
|
+
return null;
|
|
861
|
+
if (typeof raw === 'string' && raw !== 'project' && raw !== 'user' && raw !== 'agent')
|
|
862
|
+
return null;
|
|
863
|
+
const requested = named === 'project' || named === 'user' || named === 'agent' ? named : defaultScope;
|
|
690
864
|
if (requested === 'user')
|
|
691
865
|
return { kind: 'user', userId: ctx.userId };
|
|
692
866
|
if (requested === 'agent')
|
|
@@ -706,7 +880,7 @@ async function handleSourcesUpdate(req, state) {
|
|
|
706
880
|
const description = body?.description;
|
|
707
881
|
// `null` = absent. A read-then-resend caller (the manage-sources skill's jq
|
|
708
882
|
// projection) legitimately round-trips a field the listing never carried.
|
|
709
|
-
const
|
|
883
|
+
const packageFolders = body?.packageFolders ?? undefined;
|
|
710
884
|
if (!source)
|
|
711
885
|
return { status: 400, body: { error: 'Missing source' } };
|
|
712
886
|
if (description !== undefined && typeof description !== 'string') {
|
|
@@ -719,59 +893,42 @@ async function handleSourcesUpdate(req, state) {
|
|
|
719
893
|
if (enabledRaw !== undefined && typeof enabledRaw !== 'boolean' && enabledRaw !== 'auto') {
|
|
720
894
|
return { status: 400, body: { error: 'enabled must be boolean or "auto" if provided' } };
|
|
721
895
|
}
|
|
722
|
-
if (
|
|
723
|
-
return { status: 400, body: { error: '
|
|
896
|
+
if (packageFolders !== undefined && !Array.isArray(packageFolders)) {
|
|
897
|
+
return { status: 400, body: { error: 'packageFolders must be an array of folder paths if provided' } };
|
|
724
898
|
}
|
|
725
899
|
await ensureAccess(ctx, state);
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
// other source fields are drive.mount. Deny-by-default.
|
|
732
|
-
if (recognizesPackages !== undefined && !isOwnerOrAdminEffective(req.session)) {
|
|
733
|
-
return { status: 403, body: { error: 'Setting recognizesPackages requires an owner/admin-effective role.' } };
|
|
900
|
+
// R2 (architect MINOR-2) — a `packageFolders` entry creates a code-LOAD
|
|
901
|
+
// surface, so the WRITE is owner/admin-effective only, even though other
|
|
902
|
+
// source fields are sources.mount. Deny-by-default.
|
|
903
|
+
if (packageFolders !== undefined && !isOwnerOrAdminEffective(req.session)) {
|
|
904
|
+
return { status: 403, body: { error: 'Setting packageFolders requires an owner/admin-effective role.' } };
|
|
734
905
|
}
|
|
735
906
|
const bridge = await resolveBridge(state, projectId);
|
|
736
907
|
const scope = { userId: ctx.userId, projectId };
|
|
737
908
|
const existing = await bridge.getSourceConfig(scope, source, toScopeAccessCtx(ctx));
|
|
738
909
|
if (!existing)
|
|
739
910
|
return { status: 404, body: { error: `Source not found: ${source}` } };
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
// write) on a source already rooted in a protected zone, so editing one
|
|
761
|
-
// requires the same grants attaching one does — for EVERY root-bearing kind,
|
|
762
|
-
// not just `local`.
|
|
763
|
-
const missingForEdit = await missingAttachFeature(req.session, existing.connection, buildProtectedRoots(state.ctx.platform.runtimeStack()), async (root) => resolveLocalRoot({ configured: root, projectRoot: (await state.infraFactory.getInfra(projectId)).projectRoot ?? '', source }).resolvedRoot);
|
|
764
|
-
if (missingForEdit) {
|
|
765
|
-
return {
|
|
766
|
-
status: 403,
|
|
767
|
-
body: {
|
|
768
|
-
error: missingForEdit.plane === 'host'
|
|
769
|
-
? `Editing a host-plane source requires the ${missingForEdit.feature} feature.`
|
|
770
|
-
: `Editing a source rooted in a protected host zone requires the ${missingForEdit.feature} feature.`,
|
|
771
|
-
},
|
|
772
|
-
};
|
|
911
|
+
const ownDevice = deviceAttachVerdict(state, ctx, existing.connection);
|
|
912
|
+
if (!ownDevice.allowed && !hasRouteFeature(req.session, 'sources.mount')) {
|
|
913
|
+
return { status: 403, body: { error: 'Updating sources requires sources.mount.' } };
|
|
914
|
+
}
|
|
915
|
+
const refused = await sourceEditRefusal(state, ctx, existing, permissions, ownDevice);
|
|
916
|
+
if (refused)
|
|
917
|
+
return refused;
|
|
918
|
+
const packageFoldersError = packageFolders === undefined ? undefined : validatePackageFolders(packageFolders, existing.connection)[0];
|
|
919
|
+
if (packageFoldersError)
|
|
920
|
+
return { status: 400, body: { error: packageFoldersError } };
|
|
921
|
+
const normalizedPerms = normalizePermissions(permissions, { rolePriorities: ctx.rolePriorities, existing: existing.permissions });
|
|
922
|
+
// A source the native fence refuses (it predates the fence) may still be
|
|
923
|
+
// narrowed — disabled, a right dropped, described — never widened.
|
|
924
|
+
if (await nativeFenceRefusal(state, projectId, source, existing.connection) && fencedEditWidens(existing, {
|
|
925
|
+
permissions: normalizedPerms, clearSync,
|
|
926
|
+
...(sync ? { sync } : {}),
|
|
927
|
+
...(enabledRaw !== undefined ? { enabled: enabledRaw } : {}),
|
|
928
|
+
...(packageFolders !== undefined ? { packageFolders: packageFolders } : {}),
|
|
929
|
+
})) {
|
|
930
|
+
return { status: 403, body: NATIVE_LOCAL_EDIT_REFUSAL };
|
|
773
931
|
}
|
|
774
|
-
const normalizedPerms = normalizePermissions(permissions);
|
|
775
932
|
try {
|
|
776
933
|
const updated = await bridge.updateSourceConfig(scope, {
|
|
777
934
|
agentId,
|
|
@@ -781,7 +938,7 @@ async function handleSourcesUpdate(req, state) {
|
|
|
781
938
|
...(sync ? { sync } : {}),
|
|
782
939
|
...(clearSync ? { clearSync: true } : {}),
|
|
783
940
|
...(enabledRaw !== undefined ? { enabled: enabledRaw } : {}),
|
|
784
|
-
...(
|
|
941
|
+
...(packageFolders !== undefined ? { packageFolders: packageFolders } : {}),
|
|
785
942
|
});
|
|
786
943
|
const infra = await state.infraFactory.getInfra(projectId);
|
|
787
944
|
return { status: 200, body: toPublicSourceConfig(updated, await publicViewFor(req.session, ctx, updated, state, infra)) };
|
|
@@ -809,17 +966,30 @@ async function handleSourcesEnabled(req, state) {
|
|
|
809
966
|
return { status: 400, body: { error: 'enabled must be boolean or "auto"' } };
|
|
810
967
|
}
|
|
811
968
|
await ensureAccess(ctx, state);
|
|
812
|
-
if (!hasRouteFeature(req.session, 'drive.mount')) {
|
|
813
|
-
return { status: 403, body: { error: 'Toggling source enabled state requires drive.mount.' } };
|
|
814
|
-
}
|
|
815
969
|
const bridge = await resolveBridge(state, ctx.projectId);
|
|
816
970
|
const scope = { userId: ctx.userId, projectId: ctx.projectId };
|
|
817
971
|
const existing = await bridge.getSourceConfig(scope, source, toScopeAccessCtx(ctx));
|
|
972
|
+
// The owner of a paired device manages a source on it with `device.own` —
|
|
973
|
+
// online or offline (D24).
|
|
974
|
+
if (!hasRouteFeature(req.session, 'sources.mount') && !(existing && deviceAttachVerdict(state, ctx, existing.connection).allowed)) {
|
|
975
|
+
return { status: 403, body: { error: 'Toggling source enabled state requires sources.mount.' } };
|
|
976
|
+
}
|
|
818
977
|
if (!existing)
|
|
819
978
|
return { status: 404, body: { error: `Source not found: ${source}` } };
|
|
820
|
-
if (!canAccessScope(existing.scope, toScopeAccessCtx(ctx), 'write')) {
|
|
979
|
+
if (!canAccessScope(existing.scope, toScopeAccessCtx(ctx), 'write', sourceScopeOrigin(existing.connection))) {
|
|
821
980
|
return { status: 403, body: { error: 'Forbidden for source scope' } };
|
|
822
981
|
}
|
|
982
|
+
// Turning a source ON widens its reach, so it runs the edit ladder (a device
|
|
983
|
+
// folder is its owner's alone, a protected zone needs its feature). Turning it
|
|
984
|
+
// off narrows and stays the `sources.mount` holder's.
|
|
985
|
+
if (fencedEditWidens(existing, { enabled })) {
|
|
986
|
+
const refused = await sourceEditRefusal(state, ctx, existing, existing.permissions);
|
|
987
|
+
if (refused)
|
|
988
|
+
return refused;
|
|
989
|
+
if (await nativeFenceRefusal(state, ctx.projectId, source, existing.connection)) {
|
|
990
|
+
return { status: 403, body: NATIVE_LOCAL_EDIT_REFUSAL };
|
|
991
|
+
}
|
|
992
|
+
}
|
|
823
993
|
try {
|
|
824
994
|
const updated = await bridge.setSourceEnabled(scope, { source, enabled });
|
|
825
995
|
const infra = await state.infraFactory.getInfra(ctx.projectId);
|
|
@@ -832,16 +1002,17 @@ async function handleSourcesEnabled(req, state) {
|
|
|
832
1002
|
async function handleSourcesDelete(slug, req, state) {
|
|
833
1003
|
const ctx = extractCtx(req);
|
|
834
1004
|
await ensureAccess(ctx, state);
|
|
835
|
-
if (!hasRouteFeature(req.session, 'drive.mount')) {
|
|
836
|
-
return { status: 403, body: { error: 'Deleting sources requires drive.mount.' } };
|
|
837
|
-
}
|
|
838
1005
|
const bridge = await resolveBridge(state, ctx.projectId);
|
|
839
1006
|
const scope = { userId: ctx.userId, projectId: ctx.projectId };
|
|
840
1007
|
const configs = await bridge.listSourceConfigs(scope, toScopeAccessCtx(ctx));
|
|
841
1008
|
const match = configs.find((c) => c.source.replace(/[^a-zA-Z0-9-]/g, '-') === slug);
|
|
1009
|
+
// The owner of a paired device removes a source on it with `device.own` (D24).
|
|
1010
|
+
if (!hasRouteFeature(req.session, 'sources.mount') && !(match && deviceAttachVerdict(state, ctx, match.connection).allowed)) {
|
|
1011
|
+
return { status: 403, body: { error: 'Deleting sources requires sources.mount.' } };
|
|
1012
|
+
}
|
|
842
1013
|
if (!match)
|
|
843
1014
|
return { status: 404, body: { error: `Source not found: ${slug}` } };
|
|
844
|
-
if (!canAccessScope(match.scope, toScopeAccessCtx(ctx), 'write')) {
|
|
1015
|
+
if (!canAccessScope(match.scope, toScopeAccessCtx(ctx), 'write', sourceScopeOrigin(match.connection))) {
|
|
845
1016
|
return { status: 403, body: { error: 'Forbidden for source scope' } };
|
|
846
1017
|
}
|
|
847
1018
|
const purgeRaw = req.query?.purge;
|
|
@@ -876,8 +1047,8 @@ async function handleSourcesSync(req, state) {
|
|
|
876
1047
|
if (!source)
|
|
877
1048
|
return { status: 400, body: { error: 'Missing source' } };
|
|
878
1049
|
await ensureAccess(ctx, state);
|
|
879
|
-
if (!hasRouteFeature(req.session, '
|
|
880
|
-
return { status: 403, body: { error: 'Sync requires
|
|
1050
|
+
if (!hasRouteFeature(req.session, 'sources.sync')) {
|
|
1051
|
+
return { status: 403, body: { error: 'Sync requires sources.sync.' } };
|
|
881
1052
|
}
|
|
882
1053
|
const bridge = await resolveBridge(state, projectId);
|
|
883
1054
|
const scope = { userId: ctx.userId, projectId };
|
|
@@ -897,7 +1068,9 @@ async function handleSourcesSync(req, state) {
|
|
|
897
1068
|
if (!config.sync) {
|
|
898
1069
|
return { status: 400, body: { error: `Source "${config.source}" has no sync configuration. Add one before triggering a sync.` } };
|
|
899
1070
|
}
|
|
900
|
-
|
|
1071
|
+
// `confirmEmpty` lets ONE full pass tombstone a source whose root now holds nothing.
|
|
1072
|
+
const confirmEmpty = body?.confirmEmpty === true;
|
|
1073
|
+
const job = await bridge.triggerSourceSync(scope, { agentId, source, caller: toSyncCaller(ctx), ...(confirmEmpty ? { confirmEmpty } : {}) });
|
|
901
1074
|
return { status: 202, body: job };
|
|
902
1075
|
}
|
|
903
1076
|
/**
|
|
@@ -906,13 +1079,13 @@ async function handleSourcesSync(req, state) {
|
|
|
906
1079
|
* (architect F4): cancel is allowed exactly where the caller could trigger a
|
|
907
1080
|
* sync — the same `getSourceConfig(…, access)` visibility check — which
|
|
908
1081
|
* deliberately covers background/system jobs on that source (the freeze
|
|
909
|
-
* scenario must be killable by any
|
|
1082
|
+
* scenario must be killable by any sources.sync holder who can see the source).
|
|
910
1083
|
*/
|
|
911
1084
|
async function handleSourcesCancel(req, state) {
|
|
912
1085
|
const ctx = extractCtx(req);
|
|
913
1086
|
await ensureAccess(ctx, state);
|
|
914
|
-
if (!hasRouteFeature(req.session, '
|
|
915
|
-
return { status: 403, body: { error: 'Cancelling sync requires
|
|
1087
|
+
if (!hasRouteFeature(req.session, 'sources.sync')) {
|
|
1088
|
+
return { status: 403, body: { error: 'Cancelling sync requires sources.sync.' } };
|
|
916
1089
|
}
|
|
917
1090
|
const bridge = await resolveBridge(state, ctx.projectId);
|
|
918
1091
|
const scope = { userId: ctx.userId, projectId: ctx.projectId };
|
|
@@ -935,7 +1108,7 @@ async function handleSourcesCancel(req, state) {
|
|
|
935
1108
|
return { status: 200, body: { ok: true, cancelled } };
|
|
936
1109
|
}
|
|
937
1110
|
/**
|
|
938
|
-
* POST /sources/reindex — three operations behind ONE gate (`
|
|
1111
|
+
* POST /sources/reindex — three operations behind ONE gate (`sources.sync` +
|
|
939
1112
|
* source visibility):
|
|
940
1113
|
* - `{source}` — the full re-sync from scratch ("Rescan changes"): markers
|
|
941
1114
|
* cleared, a full job enqueued; unchanged indexed files reuse their vectors,
|
|
@@ -951,7 +1124,7 @@ async function handleSourcesCancel(req, state) {
|
|
|
951
1124
|
* `scope:'platform'` (`platform.vector.maintain`, whole source only) it
|
|
952
1125
|
* catches up EVERY writer's points — the way back after a dense clear.
|
|
953
1126
|
* The document set is the sync inventory's scope, filtered by the caller's
|
|
954
|
-
* per-uri READ policy before anything is counted — a `
|
|
1127
|
+
* per-uri READ policy before anything is counted — a `sources.sync` holder never
|
|
955
1128
|
* learns the size of a file it may not read.
|
|
956
1129
|
*/
|
|
957
1130
|
async function handleSourcesReindex(req, state) {
|
|
@@ -965,8 +1138,8 @@ async function handleSourcesReindex(req, state) {
|
|
|
965
1138
|
// CLAUDE.md principle 12).
|
|
966
1139
|
const ctx = extractCtx(req);
|
|
967
1140
|
await ensureAccess(ctx, state);
|
|
968
|
-
if (!hasRouteFeature(req.session, '
|
|
969
|
-
return { status: 403, body: { error: 'Reindex requires
|
|
1141
|
+
if (!hasRouteFeature(req.session, 'sources.sync')) {
|
|
1142
|
+
return { status: 403, body: { error: 'Reindex requires sources.sync.' } };
|
|
970
1143
|
}
|
|
971
1144
|
const force = body?.force === true;
|
|
972
1145
|
const embeddingOnly = body?.embeddingOnly === true;
|
|
@@ -1089,8 +1262,8 @@ async function handleScopedReembed(ctx, state, bridge, req) {
|
|
|
1089
1262
|
async function handleSyncStatus(req, state) {
|
|
1090
1263
|
const ctx = extractCtx(req);
|
|
1091
1264
|
await ensureAccess(ctx, state);
|
|
1092
|
-
if (!hasRouteFeature(req.session, '
|
|
1093
|
-
return { status: 403, body: { error: 'Sync status requires
|
|
1265
|
+
if (!hasRouteFeature(req.session, 'sources.sync')) {
|
|
1266
|
+
return { status: 403, body: { error: 'Sync status requires sources.sync.' } };
|
|
1094
1267
|
}
|
|
1095
1268
|
const bridge = await resolveBridge(state, ctx.projectId);
|
|
1096
1269
|
const scope = { userId: ctx.userId, projectId: ctx.projectId };
|
|
@@ -1116,10 +1289,10 @@ async function handleSourcesReset(req, state) {
|
|
|
1116
1289
|
const ctx = extractCtx(req);
|
|
1117
1290
|
await ensureAccess(ctx, state);
|
|
1118
1291
|
// Reset mutates sync markers AND cancels the source's active job — it is
|
|
1119
|
-
// a sync-shaped mutation, so it carries the same
|
|
1292
|
+
// a sync-shaped mutation, so it carries the same sources.sync gate + source
|
|
1120
1293
|
// visibility check as sync/cancel/reindex (architect MAJOR-2, F4 rule).
|
|
1121
|
-
if (!hasRouteFeature(req.session, '
|
|
1122
|
-
return { status: 403, body: { error: 'Reset requires
|
|
1294
|
+
if (!hasRouteFeature(req.session, 'sources.sync')) {
|
|
1295
|
+
return { status: 403, body: { error: 'Reset requires sources.sync.' } };
|
|
1123
1296
|
}
|
|
1124
1297
|
const bridge = await resolveBridge(state, ctx.projectId);
|
|
1125
1298
|
const scope = { userId: ctx.userId, projectId: ctx.projectId };
|
|
@@ -1139,20 +1312,16 @@ async function handleSourcesReset(req, state) {
|
|
|
1139
1312
|
* suggested kind + config.
|
|
1140
1313
|
*
|
|
1141
1314
|
* Suggested kinds today:
|
|
1142
|
-
* - `app` — `{neuralisHome}/app/` (admin-only zone
|
|
1143
|
-
*
|
|
1144
|
-
* a follow-up commit; until then we suggest the path
|
|
1145
|
-
* with `kind: 'local'` so the existing add-source flow
|
|
1146
|
-
* works).
|
|
1315
|
+
* - `app` — `{neuralisHome}/app/` (admin-only zone), suggested as a
|
|
1316
|
+
* `host` source.
|
|
1147
1317
|
* - `neuralis` — the downloaded Neuralis tree
|
|
1148
1318
|
* (`/home/<user>/projects/Neuralis` on host installs,
|
|
1149
|
-
* `os://repo` mount inside Docker)
|
|
1150
|
-
*
|
|
1151
|
-
* with `kind: 'local'`.
|
|
1319
|
+
* `os://repo` mount inside Docker), suggested as a `host`
|
|
1320
|
+
* source.
|
|
1152
1321
|
* - `projects` — the per-project zone next door (`{neuralisHome}/projects`).
|
|
1153
1322
|
*
|
|
1154
1323
|
* Already-attached paths are filtered out by comparing each suggestion's
|
|
1155
|
-
*
|
|
1324
|
+
* root against every persisted source's root as THIS plane resolves it.
|
|
1156
1325
|
*/
|
|
1157
1326
|
async function handleSourcesDiscoverable(req, state) {
|
|
1158
1327
|
const ctx = extractCtx(req);
|
|
@@ -1164,19 +1333,25 @@ async function handleSourcesDiscoverable(req, state) {
|
|
|
1164
1333
|
// without this a viewer still read every mount's name and host path. The UI
|
|
1165
1334
|
// is already fail-soft on a 403 here (`FileTree` gates its fetch on the same
|
|
1166
1335
|
// feature; `SourcesPanel` catches into an empty list).
|
|
1167
|
-
if (!hasRouteFeature(req.session, '
|
|
1168
|
-
return { status: 403, body: { error: 'Listing discoverable source roots requires the
|
|
1336
|
+
if (!hasRouteFeature(req.session, 'sources.mount')) {
|
|
1337
|
+
return { status: 403, body: { error: 'Listing discoverable source roots requires the sources.mount feature.' } };
|
|
1169
1338
|
}
|
|
1170
1339
|
const stack = state.ctx.platform.runtimeStack();
|
|
1171
1340
|
const bridge = await resolveBridge(state, ctx.projectId);
|
|
1172
1341
|
const scope = { userId: ctx.userId, projectId: ctx.projectId };
|
|
1173
1342
|
const access = toScopeAccessCtx(ctx);
|
|
1174
1343
|
const existing = await bridge.listSourceConfigs(scope, access);
|
|
1344
|
+
// Compared on the root THIS plane opens, so a natively attached folder (stored
|
|
1345
|
+
// under its container spelling) is not offered again.
|
|
1346
|
+
const attachedProjectRoot = (await state.infraFactory.getInfra(ctx.projectId)).projectRoot ?? '';
|
|
1175
1347
|
const attachedRoots = new Set();
|
|
1176
1348
|
for (const config of existing) {
|
|
1177
1349
|
const root = config.connection?.config?.root?.trim();
|
|
1178
|
-
|
|
1179
|
-
|
|
1350
|
+
const attached = root
|
|
1351
|
+
? resolvedLocalRootOrNull({ configured: root, hostRoot: config.connection.config.hostRoot, projectRoot: attachedProjectRoot, isDocker: stack.isDocker, source: config.source })
|
|
1352
|
+
: null;
|
|
1353
|
+
if (attached !== null)
|
|
1354
|
+
attachedRoots.add(attached);
|
|
1180
1355
|
}
|
|
1181
1356
|
const suggestions = [];
|
|
1182
1357
|
// Declarative `seed:'discoverable'` sources (modular-brewing-sutherland) —
|
|
@@ -1210,7 +1385,7 @@ async function handleSourcesDiscoverable(req, state) {
|
|
|
1210
1385
|
...(def ? { suggestedAccess: { read: !!def.read, write: !!def.write, exec: !!def.exec } } : {}),
|
|
1211
1386
|
...(decl.visibility?.seeRequires?.length ? { seeRequires: decl.visibility.seeRequires } : {}),
|
|
1212
1387
|
};
|
|
1213
|
-
if (decl.kind ===
|
|
1388
|
+
if (decl.kind === HOST_SOURCE_KIND) {
|
|
1214
1389
|
let resolvedRoot = null;
|
|
1215
1390
|
let hostRoot;
|
|
1216
1391
|
if (decl.root === '${appRoot}') {
|
|
@@ -1263,10 +1438,16 @@ async function handleSourcesDiscoverable(req, state) {
|
|
|
1263
1438
|
// shape `neuralis-operations` prescribes), and the whole-tree proposal
|
|
1264
1439
|
// stays available for source work but says what it exposes and pre-fills a
|
|
1265
1440
|
// `.env*` read-deny.
|
|
1441
|
+
//
|
|
1442
|
+
// A native host has no overlay and no container: its packaged operator
|
|
1443
|
+
// skills sit in its OWN tree (`stack.neuralisTree`, the Neuralis tree the
|
|
1444
|
+
// privileged-root table names there), so the skills proposal is rooted
|
|
1445
|
+
// there — the whole-tree proposal stays the overlay's.
|
|
1266
1446
|
const neuralisMount = stack.osMounts.find((m) => m.key === 'neuralis');
|
|
1267
1447
|
const neuralisRoot = neuralisMount?.containerPath ?? null;
|
|
1268
|
-
|
|
1269
|
-
|
|
1448
|
+
const skillsTree = neuralisRoot ?? (stack.isDocker ? null : stack.neuralisTree);
|
|
1449
|
+
if (skillsTree) {
|
|
1450
|
+
const skillsRoot = resolvePath(skillsTree, 'skills');
|
|
1270
1451
|
// Guarded: an overlay rooted somewhere without a `skills/` folder must
|
|
1271
1452
|
// not advertise a directory that is not there — silent-zero-index is the
|
|
1272
1453
|
// exact trap this suggestion exists to remove.
|
|
@@ -1275,12 +1456,13 @@ async function handleSourcesDiscoverable(req, state) {
|
|
|
1275
1456
|
id: 'discover:neuralis-skills',
|
|
1276
1457
|
label: 'Neuralis Skills',
|
|
1277
1458
|
description: "The deployment's own operator skills (`neuralis/skills/`). Each SKILL.md folder is a discovered source package, so the operator-command vocabulary reaches an agent's context by itself.",
|
|
1278
|
-
suggestedKind:
|
|
1459
|
+
suggestedKind: HOST_SOURCE_KIND,
|
|
1279
1460
|
suggestedScheme: 'neuralis-skills',
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1461
|
+
// The LOGICAL root — the tree's `skills/` on whichever plane opens the
|
|
1462
|
+
// source (the overlay in Docker, the host's own tree natively) — so the
|
|
1463
|
+
// attach survives a move between the two; the machine half derives
|
|
1464
|
+
// from the overlay's mount at bind time.
|
|
1465
|
+
suggestedConfig: { root: `${NEURALIS_TREE_ANCHOR}skills` },
|
|
1284
1466
|
root: skillsRoot,
|
|
1285
1467
|
icon: 'BookOpen',
|
|
1286
1468
|
suggestedScope: 'user',
|
|
@@ -1299,20 +1481,17 @@ async function handleSourcesDiscoverable(req, state) {
|
|
|
1299
1481
|
// trigger, which rides the contract rather than the form's old
|
|
1300
1482
|
// hard-coded literal.
|
|
1301
1483
|
suggestedSync: { trigger: 'manual' },
|
|
1302
|
-
note: 'Owner-only. Mounted via `pnpm neuralis:mount add … --neuralis`.',
|
|
1484
|
+
note: neuralisRoot ? 'Owner-only. Mounted via `pnpm neuralis:mount add … --neuralis`.' : "Owner-only. This install's own packaged skills (read-only).",
|
|
1303
1485
|
});
|
|
1304
1486
|
}
|
|
1305
|
-
if (!attachedRoots.has(neuralisRoot)) {
|
|
1487
|
+
if (neuralisRoot && !attachedRoots.has(neuralisRoot)) {
|
|
1306
1488
|
suggestions.push({
|
|
1307
1489
|
id: 'discover:neuralis',
|
|
1308
1490
|
label: 'Neuralis Tree',
|
|
1309
1491
|
description: "The deployment's own `neuralis/` folder, live from the host (scripts/, skills/, src/, config). Holds `.env` — attach it only for source work; prefer Neuralis Skills to give an agent the operator commands.",
|
|
1310
|
-
suggestedKind:
|
|
1492
|
+
suggestedKind: HOST_SOURCE_KIND,
|
|
1311
1493
|
suggestedScheme: 'neuralis',
|
|
1312
|
-
suggestedConfig: {
|
|
1313
|
-
root: neuralisRoot,
|
|
1314
|
-
...(neuralisMount?.hostPath ? { hostRoot: neuralisMount.hostPath } : {}),
|
|
1315
|
-
},
|
|
1494
|
+
suggestedConfig: { root: NEURALIS_TREE_ANCHOR },
|
|
1316
1495
|
root: neuralisRoot,
|
|
1317
1496
|
icon: 'GitBranch',
|
|
1318
1497
|
// Privileged host zone — default to USER scope so it is not shared
|
|
@@ -1355,7 +1534,7 @@ async function handleSourcesDiscoverable(req, state) {
|
|
|
1355
1534
|
id: 'discover:projects',
|
|
1356
1535
|
label: 'Projects Zone',
|
|
1357
1536
|
description: '`{neuralisHome}/projects/` — sibling of the active project workspace.',
|
|
1358
|
-
suggestedKind:
|
|
1537
|
+
suggestedKind: HOST_SOURCE_KIND,
|
|
1359
1538
|
suggestedScheme: 'local',
|
|
1360
1539
|
suggestedConfig: {
|
|
1361
1540
|
root: stack.projectsRoot,
|
|
@@ -1365,22 +1544,30 @@ async function handleSourcesDiscoverable(req, state) {
|
|
|
1365
1544
|
icon: 'FolderTree',
|
|
1366
1545
|
});
|
|
1367
1546
|
}
|
|
1368
|
-
// Full host — only in native (non-Docker) mode, where
|
|
1369
|
-
// and is directly readable
|
|
1370
|
-
// (`
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
// (
|
|
1374
|
-
//
|
|
1375
|
-
|
|
1547
|
+
// Full host — only in native (non-Docker) mode, where the filesystem root IS
|
|
1548
|
+
// the host root and is directly readable: `/`, on Windows each drive root a
|
|
1549
|
+
// zone lives on (`C:\` — `/` there means "the current drive"). In Docker
|
|
1550
|
+
// the host is invisible unless bound (`pnpm neuralis:mount add / --slug
|
|
1551
|
+
// host`), which then surfaces via the generic mount loop below. Opt-in,
|
|
1552
|
+
// gated by `sources.privileged` (no grant below the `admin` tier), attach
|
|
1553
|
+
// READ-ONLY by default (suggestedAccess) — the whole-host blast radius
|
|
1554
|
+
// warrants deny-by-default; the attacher narrows the uri-policy (or widens
|
|
1555
|
+
// write) before use. The roots are the privileged table's own whole-host rows.
|
|
1556
|
+
const hostRoots = stack.isDocker ? [] : protectedRootPaths(stack).filter(isFilesystemRoot);
|
|
1557
|
+
for (const root of hostRoots) {
|
|
1558
|
+
if (attachedRoots.has(root))
|
|
1559
|
+
continue;
|
|
1560
|
+
const drive = /^([A-Za-z]):/.exec(root)?.[1]?.toLowerCase();
|
|
1561
|
+
if (root !== '/' && !drive)
|
|
1562
|
+
continue; // a UNC share root is not "the host"
|
|
1376
1563
|
suggestions.push({
|
|
1377
|
-
id: 'discover:host-root',
|
|
1378
|
-
label: 'Full Host',
|
|
1379
|
-
description:
|
|
1380
|
-
suggestedKind:
|
|
1381
|
-
suggestedScheme: 'host',
|
|
1382
|
-
suggestedConfig: { root
|
|
1383
|
-
root
|
|
1564
|
+
id: drive ? `discover:host-root:${drive}` : 'discover:host-root',
|
|
1565
|
+
label: drive ? `Full Host (${root})` : 'Full Host',
|
|
1566
|
+
description: `The entire host filesystem (\`${root}\`). Attach read-only and scope it before use.`,
|
|
1567
|
+
suggestedKind: HOST_SOURCE_KIND,
|
|
1568
|
+
suggestedScheme: drive ? `host-${drive}` : 'host',
|
|
1569
|
+
suggestedConfig: { root },
|
|
1570
|
+
root,
|
|
1384
1571
|
icon: 'HardDrive',
|
|
1385
1572
|
// Privileged host zone — default to USER scope (not shared project-wide).
|
|
1386
1573
|
suggestedScope: 'user',
|
|
@@ -1405,7 +1592,7 @@ async function handleSourcesDiscoverable(req, state) {
|
|
|
1405
1592
|
id: `discover:mount:${mount.key}`,
|
|
1406
1593
|
label: mount.key,
|
|
1407
1594
|
description: `Host bind \`${mount.hostPath ?? mount.containerPath}\` mounted at \`${mount.containerPath}\`.`,
|
|
1408
|
-
suggestedKind:
|
|
1595
|
+
suggestedKind: HOST_SOURCE_KIND,
|
|
1409
1596
|
suggestedScheme: scheme,
|
|
1410
1597
|
suggestedConfig: {
|
|
1411
1598
|
root: mount.containerPath,
|
|
@@ -1424,7 +1611,12 @@ async function handleSourcesDiscoverable(req, state) {
|
|
|
1424
1611
|
// ADD gate in handleSourcesCreate enforces the boundary independently — the
|
|
1425
1612
|
// client filter is UX only.
|
|
1426
1613
|
const protectedRoots = buildProtectedRoots(stack);
|
|
1614
|
+
// The native folder fence: without a container no root outside the project
|
|
1615
|
+
// zone and the privileged rows is offered — that folder is the device's path.
|
|
1616
|
+
const fenceProject = nativeLocalFenceApplies(stack) ? (await state.infraFactory.getInfra(ctx.projectId)).projectRoot ?? '' : null;
|
|
1427
1617
|
const visible = suggestions
|
|
1618
|
+
.filter((s) => fenceProject === null || s.suggestedKind !== HOST_SOURCE_KIND || !s.root
|
|
1619
|
+
|| attachableNatively(nativeLocalPlacement(hostPathFlavour(s.root).resolve(s.root), fenceProject && resolvePath(fenceProject), protectedRoots.map((p) => p.root))))
|
|
1428
1620
|
.map((s) => {
|
|
1429
1621
|
// Union the authoritative protected-root features with any declaration
|
|
1430
1622
|
// `visibility.seeRequires` already on the suggestion (defense-in-depth).
|
|
@@ -1443,131 +1635,51 @@ async function handleSourcesDiscoverable(req, state) {
|
|
|
1443
1635
|
return { status: 200, body: { suggestions: visible } };
|
|
1444
1636
|
}
|
|
1445
1637
|
/**
|
|
1446
|
-
* `GET /sources/browse?path
|
|
1447
|
-
* PRECEDES attaching a source
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1452
|
-
* `feature = 'drive.read'` is a VIEWER default, so on a native install any
|
|
1453
|
-
* signed-in reader could walk the operator's host one level at a time
|
|
1454
|
-
* (`?path=/` → `/home` → `/root`), and on win32 enumerate the drive letters.
|
|
1455
|
-
*
|
|
1456
|
-
* The rule is one sentence: **you may browse exactly what you would be allowed
|
|
1457
|
-
* to attach.** The base feature is `drive.mount` — the same gate
|
|
1458
|
-
* `handleSourcesCreate` gives the act this one prepares — and per-path the
|
|
1459
|
-
* SHARED protected-root table decides, so `/`, the App Zone and the Neuralis
|
|
1460
|
-
* tree cost `drive.mount.privileged` here exactly as they do at attach time.
|
|
1461
|
-
* `'see'` is the semantically right column; today both columns hold the same
|
|
1462
|
-
* feature, so nothing in the suite proves the distinction — it is intent, not
|
|
1463
|
-
* enforcement.
|
|
1638
|
+
* `GET /sources/browse?path=…[&device=dev_…]` — one level of folders, the step
|
|
1639
|
+
* that PRECEDES attaching a source: on this server, or on the caller's OWN
|
|
1640
|
+
* paired device. The work lives in `usecases/browseSourceFolders.ts`; this
|
|
1641
|
+
* handler supplies the gates this file owns (the protected-root table, the
|
|
1642
|
+
* native fence, the device attach gate). The rule: **you may browse exactly
|
|
1643
|
+
* what you would be allowed to attach.**
|
|
1464
1644
|
*/
|
|
1465
1645
|
async function handleSourcesBrowse(req, state) {
|
|
1466
1646
|
const ctx = extractCtx(req);
|
|
1647
|
+
// Not the security gate — the usecase's gates are. `ensureAccess` buys
|
|
1648
|
+
// uniformity with every sibling handler and the project-override 403 in
|
|
1649
|
+
// `extractCtx`, and it needs a real agent in context.
|
|
1467
1650
|
await ensureAccess(ctx, state);
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
if (process.env.NEURALIS_DOCKER === 'true') {
|
|
1481
|
-
const mountsRoot = '/mounts';
|
|
1482
|
-
try {
|
|
1483
|
-
const entries = await readdir(mountsRoot, { withFileTypes: true });
|
|
1484
|
-
const dirs = entries.filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))
|
|
1485
|
-
.map((e) => ({ name: e.name, path: join(mountsRoot, e.name), type: 'directory' }));
|
|
1486
|
-
return { status: 200, body: { entries: dirs } };
|
|
1487
|
-
}
|
|
1488
|
-
catch {
|
|
1489
|
-
return { status: 200, body: { entries: [] } };
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
|
-
const rawPath = (req.query?.path ?? '');
|
|
1493
|
-
if (!rawPath)
|
|
1494
|
-
return { status: 400, body: { error: 'Missing path parameter' } };
|
|
1495
|
-
// A NUL byte survives `resolve()` unchanged and would slip past every string
|
|
1496
|
-
// prefix comparison in the table below before `readdir` rejects it.
|
|
1497
|
-
if (rawPath.includes('\0'))
|
|
1498
|
-
return { status: 400, body: { error: BROWSE_UNREADABLE } };
|
|
1499
|
-
// A relative path would resolve against the SERVER process cwd — never what
|
|
1500
|
-
// the caller meant, and it also closes the drive-relative `C:foo` form.
|
|
1501
|
-
if (!isAbsolute(rawPath))
|
|
1502
|
-
return { status: 400, body: { error: 'Path must be absolute' } };
|
|
1503
|
-
// ONE canonical value from here on: the gate, the root test, the readdir and
|
|
1504
|
-
// the child paths all use it. Two resolutions is how a gate ends up judging a
|
|
1505
|
-
// different string than the syscall receives.
|
|
1506
|
-
const canonical = resolve(rawPath);
|
|
1507
|
-
const protectedRoots = buildProtectedRoots(state.ctx.platform.runtimeStack());
|
|
1508
|
-
const denied = missingBrowseFeatures(canonical, protectedRoots, state.ctx.platform.runtimeStack(), req.session, dirname);
|
|
1509
|
-
if (denied.length > 0) {
|
|
1510
|
-
return { status: 403, body: { error: `Browsing this path requires the ${denied[0]} feature.` } };
|
|
1511
|
-
}
|
|
1512
|
-
// `resolve` is purely LEXICAL, so a symlink whose literal path is innocent can
|
|
1513
|
-
// still point at `/` or the App Zone. Re-run the same gate on the real path.
|
|
1514
|
-
let real;
|
|
1515
|
-
try {
|
|
1516
|
-
real = await realpath(canonical);
|
|
1517
|
-
}
|
|
1518
|
-
catch {
|
|
1519
|
-
return { status: 400, body: { error: BROWSE_UNREADABLE } };
|
|
1520
|
-
}
|
|
1521
|
-
if (real !== canonical) {
|
|
1522
|
-
const deniedReal = missingBrowseFeatures(real, protectedRoots, state.ctx.platform.runtimeStack(), req.session, dirname);
|
|
1523
|
-
if (deniedReal.length > 0) {
|
|
1524
|
-
return { status: 403, body: { error: `Browsing this path requires the ${deniedReal[0]} feature.` } };
|
|
1525
|
-
}
|
|
1526
|
-
}
|
|
1527
|
-
// Whole-host discovery by definition — the drive letters ARE the host. The
|
|
1528
|
-
// filesystem-root fixpoint above already required the privileged feature
|
|
1529
|
-
// (`win32.resolve('/')` is `\`, whose dirname is itself), and this second
|
|
1530
|
-
// check keeps that true even if the table below it ever changes.
|
|
1531
|
-
const { platform } = await import('node:os');
|
|
1532
|
-
if (platform() === 'win32' && dirname(real) === real) {
|
|
1533
|
-
if (!hasRouteFeature(req.session, PRIVILEGED_MOUNT_FEATURE)) {
|
|
1534
|
-
return { status: 403, body: { error: `Browsing this path requires the ${PRIVILEGED_MOUNT_FEATURE} feature.` } };
|
|
1535
|
-
}
|
|
1536
|
-
const { stat } = await import('node:fs/promises');
|
|
1537
|
-
const drives = [];
|
|
1538
|
-
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
|
1539
|
-
try {
|
|
1540
|
-
await stat(`${letter}:\\`);
|
|
1541
|
-
drives.push({ name: `${letter}:\\`, path: `${letter}:\\`, type: 'directory' });
|
|
1542
|
-
}
|
|
1543
|
-
catch { /* skip */ }
|
|
1544
|
-
}
|
|
1545
|
-
return { status: 200, body: { entries: drives } };
|
|
1546
|
-
}
|
|
1547
|
-
try {
|
|
1548
|
-
const entries = await readdir(real, { withFileTypes: true });
|
|
1549
|
-
const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
1550
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
1551
|
-
.map((e) => ({ name: e.name, path: join(real, e.name), type: 'directory' }))
|
|
1552
|
-
// Hidden-not-locked, the file's own S2 discipline: a child the caller may
|
|
1553
|
-
// not see is simply absent, never a locked row. This is what lets the
|
|
1554
|
-
// gate above match IS-or-UNDER instead of full containment — the parent
|
|
1555
|
-
// stays walkable, the zone inside it does not appear.
|
|
1556
|
-
.filter((e) => requiredFeaturesForRoot(e.path, protectedRoots, 'see', 'is-or-under')
|
|
1557
|
-
.every((f) => hasRouteFeature(req.session, f)));
|
|
1558
|
-
return { status: 200, body: { entries: dirs } };
|
|
1651
|
+
const rawPath = typeof req.query?.path === 'string' ? req.query.path : '';
|
|
1652
|
+
const device = typeof req.query?.device === 'string' ? req.query.device : '';
|
|
1653
|
+
if (device) {
|
|
1654
|
+
// The device plane's ONE attach gate, asked BEFORE the `sources.mount`
|
|
1655
|
+
// check: the owner attaches (and so browses) with `device.own` alone.
|
|
1656
|
+
const verdict = deviceAttachVerdict(state, ctx, { kind: HOST_SOURCE_KIND, config: { device } });
|
|
1657
|
+
const session = {
|
|
1658
|
+
userId: ctx.userId, projectId: ctx.projectId, ...(ctx.agentId ? { agentId: ctx.agentId } : {}),
|
|
1659
|
+
callerId: '@neuralis/brain-core', callerTrust: 'native', grantedFeatures: [...ctx.grantedFeatures],
|
|
1660
|
+
};
|
|
1661
|
+
const devices = state.ctx.packages?.getPackageApi('@neuralis/agent-core', session)?.devices;
|
|
1662
|
+
return browseDeviceFolders({ device, rawPath, allowed: verdict.allowed }, devices, { userId: ctx.userId, grantedFeatures: [...ctx.grantedFeatures] });
|
|
1559
1663
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
// "does not exist" from "cannot be read", which together are an existence
|
|
1563
|
-
// oracle over the host (invariant 9). The operator reads the detail in the
|
|
1564
|
-
// server log; the caller gets one stable sentence for every failure.
|
|
1565
|
-
console.warn('[sources/browse] readdir failed:', err instanceof Error ? err.message : String(err));
|
|
1566
|
-
return { status: 400, body: { error: BROWSE_UNREADABLE } };
|
|
1664
|
+
if (!hasRouteFeature(req.session, 'sources.mount')) {
|
|
1665
|
+
return { status: 403, body: { error: 'Browsing filesystem paths requires the sources.mount feature.' } };
|
|
1567
1666
|
}
|
|
1667
|
+
// The kernel's runtime stack is the ONE Docker signal; it throws before the
|
|
1668
|
+
// platform facts exist (fail closed).
|
|
1669
|
+
const stack = state.ctx.platform.runtimeStack();
|
|
1670
|
+
const protectedRoots = buildProtectedRoots(stack);
|
|
1671
|
+
return browseServerFolders({ rawPath, isDocker: stack.isDocker === true }, {
|
|
1672
|
+
missingFeatures: (candidate) => missingBrowseFeatures(candidate, protectedRoots, stack, req.session, dirnameOf),
|
|
1673
|
+
childVisible: (child) => requiredFeaturesForRoot(child, protectedRoots, 'see', 'is-or-under').every((f) => hasRouteFeature(req.session, f)),
|
|
1674
|
+
mayBrowseDrives: () => hasRouteFeature(req.session, PRIVILEGED_MOUNT_FEATURE),
|
|
1675
|
+
privilegedFeature: PRIVILEGED_MOUNT_FEATURE,
|
|
1676
|
+
privilegedRoots: protectedRoots.map((p) => p.root),
|
|
1677
|
+
// The native folder fence, the browse half: the project zone and the
|
|
1678
|
+
// privileged rows are browsed as before, an ANCESTOR of the project zone
|
|
1679
|
+
// only on the way down to it, and anything else is the device's path.
|
|
1680
|
+
fenceProject: async () => (nativeLocalFenceApplies(stack) ? (await state.infraFactory.getInfra(ctx.projectId)).projectRoot ?? '' : null),
|
|
1681
|
+
});
|
|
1568
1682
|
}
|
|
1569
|
-
/** One stable sentence for every browse failure — see the catch above. */
|
|
1570
|
-
const BROWSE_UNREADABLE = 'That path is not available.';
|
|
1571
1683
|
/**
|
|
1572
1684
|
* Features the caller is MISSING to enumerate `candidate`.
|
|
1573
1685
|
*
|
|
@@ -1576,7 +1688,7 @@ const BROWSE_UNREADABLE = 'That path is not available.';
|
|
|
1576
1688
|
* all while the runtime stack is unavailable. `buildProtectedRoots(null)`
|
|
1577
1689
|
* returns `[]`, and an empty table means "unrestricted", so without this
|
|
1578
1690
|
* the gate is fail-OPEN exactly when the host classification is missing.
|
|
1579
|
-
* The create gate's
|
|
1691
|
+
* The create gate's device-plane arm is written the same way and says so.
|
|
1580
1692
|
* 2. the shared protected-root table, matched IS-or-UNDER (see `RootMatchMode`).
|
|
1581
1693
|
*
|
|
1582
1694
|
* The root test is the `dirname(p) === p` fixpoint rather than a comparison
|