@neuralis/brain-core 0.3.2 → 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 +79 -1409
- package/app/README.md +1 -1
- 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 +17 -2
- package/app/filesystem/api/README.md +3 -3
- package/app/filesystem/api/index.ts +35 -11
- package/app/filesystem/api/projectPackages.ts +13 -102
- package/app/filesystem/domain/types.ts +30 -10
- package/app/filesystem/hooks/README.md +3 -3
- package/app/filesystem/hooks/useDiscoverableSources.ts +19 -30
- package/app/filesystem/hooks/useFilesystemSSE.ts +1 -1
- package/app/filesystem/host/README.md +5 -3
- package/app/filesystem/host/buildFilesystemHostValue.ts +19 -0
- package/app/filesystem/host/installFilesystemHostComponents.tsx +1 -1
- package/app/filesystem/lib/sourceKindForm.ts +118 -39
- package/app/filesystem/lib/treeVisualState.tsx +98 -4
- package/app/filesystem/store/README.md +1 -0
- package/app/filesystem/store/filesystemStore.ts +86 -5
- 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/FileEditor.tsx +15 -10
- package/app/filesystem/ui/FileTree.tsx +41 -16
- package/app/filesystem/ui/FolderPicker.tsx +251 -0
- package/app/filesystem/ui/GitControlPanel.tsx +5 -0
- package/app/filesystem/ui/PackagesPanel.tsx +52 -374
- package/app/filesystem/ui/PolicyEditor.tsx +109 -24
- package/app/filesystem/ui/README.md +17 -11
- package/app/filesystem/ui/RawMediaPreview.tsx +58 -0
- package/app/filesystem/ui/SourceBranchHeader.tsx +4 -4
- package/app/filesystem/ui/SourcesPanel.tsx +420 -360
- package/app/filesystem/ui/Toolbar.tsx +1 -2
- package/app/shared/filesystem.png +0 -0
- package/dist/app/host.js +57 -49
- 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/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 +8 -16
- package/dist/src/domain/mime.d.ts.map +1 -1
- package/dist/src/domain/mime.js +10 -99
- 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/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 +24 -1
- 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/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 +31 -19
- package/dist/src/git/sourceGitControl.d.ts.map +1 -1
- package/dist/src/git/sourceGitControl.js +208 -92
- 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 +8 -11
- 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/git.d.ts.map +1 -1
- package/dist/src/routes/git.js +183 -54
- package/dist/src/routes/git.js.map +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 +10 -3
- package/dist/src/routes/helpers.d.ts.map +1 -1
- package/dist/src/routes/helpers.js +12 -5
- package/dist/src/routes/helpers.js.map +1 -1
- package/dist/src/routes/list.d.ts.map +1 -1
- package/dist/src/routes/list.js +2 -3
- package/dist/src/routes/list.js.map +1 -1
- package/dist/src/routes/policies.d.ts +6 -4
- package/dist/src/routes/policies.d.ts.map +1 -1
- package/dist/src/routes/policies.js +22 -16
- package/dist/src/routes/policies.js.map +1 -1
- package/dist/src/routes/raw.d.ts.map +1 -1
- package/dist/src/routes/raw.js +65 -11
- package/dist/src/routes/raw.js.map +1 -1
- package/dist/src/routes/sources.d.ts +60 -18
- package/dist/src/routes/sources.d.ts.map +1 -1
- package/dist/src/routes/sources.js +411 -299
- package/dist/src/routes/sources.js.map +1 -1
- package/dist/src/services/BridgeService.d.ts +15 -35
- package/dist/src/services/BridgeService.d.ts.map +1 -1
- package/dist/src/services/BridgeService.js +30 -88
- package/dist/src/services/BridgeService.js.map +1 -1
- package/dist/src/services/FilesystemService.d.ts +3 -2
- package/dist/src/services/FilesystemService.d.ts.map +1 -1
- package/dist/src/services/FilesystemService.js +110 -32
- 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 +125 -42
- package/dist/src/services/declaredSources.d.ts.map +1 -1
- package/dist/src/services/declaredSources.js +117 -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/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/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 +15 -0
- 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/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/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/package.json +145 -54
- package/skills/connect-business-data/SKILL.md +2 -2
- package/skills/create-package/SKILL.md +18 -18
- package/skills/create-package/references/config-and-credentials.md +16 -13
- package/skills/create-package/references/connectors-and-uri-policies.md +36 -27
- package/skills/create-package/references/manifest-and-discovery.md +12 -12
- package/skills/create-package/references/runtime-and-build.md +18 -18
- package/skills/create-package/references/skills-and-context.md +35 -19
- 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 +17 -17
- package/skills/create-package/references/tools-and-schemas.md +5 -5
- package/skills/create-package/scripts/{check-package.sh → check-package.mjs} +78 -64
- package/skills/create-package/scripts/{verify-loaded.sh → verify-loaded.mjs} +156 -162
- package/skills/git/SKILL.md +31 -29
- package/skills/manage-files/SKILL.md +11 -14
- package/skills/manage-sources/SKILL.md +85 -45
- package/skills/manage-uri-policy/SKILL.md +9 -6
- package/skills/sync/SKILL.md +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/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
|
@@ -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
|
-
* sources.privileged
|
|
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,19 +32,27 @@
|
|
|
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';
|
|
@@ -48,7 +60,7 @@ export const pattern = 'sources/*';
|
|
|
48
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
|
-
// A
|
|
63
|
+
// A device-plane (paired-device) source is attached by its owner under
|
|
52
64
|
// `device.own`, which agent-core's device gate answers — not a gate here.
|
|
53
65
|
export const escalations = [
|
|
54
66
|
'sources.mount',
|
|
@@ -73,6 +85,11 @@ export const GET = async (req, raw) => {
|
|
|
73
85
|
if (sub === 'sync-status') {
|
|
74
86
|
return handleSyncStatus(req, state);
|
|
75
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
|
+
}
|
|
76
93
|
if (sub === 'health') {
|
|
77
94
|
const ctx = extractCtx(req);
|
|
78
95
|
await ensureAccess(ctx, state);
|
|
@@ -100,17 +117,18 @@ export const GET = async (req, raw) => {
|
|
|
100
117
|
// against the declared sources (unspoofable slug+kind+resolved-root) so the
|
|
101
118
|
// Sources panel can group rows by their owning package; user mounts → null.
|
|
102
119
|
const infra = await state.infraFactory.getInfra(ctx.projectId);
|
|
103
|
-
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 };
|
|
104
121
|
const declsByPkg = state.ctx.platform.packageSourceDeclarations();
|
|
105
122
|
const dataSyncExclude = seedExcludedMachineWrittenGlobs(state.ctx.platform.machineWrittenPaths());
|
|
106
123
|
// The projection reads every row's config from the listing already in hand,
|
|
107
124
|
// so the per-row root-read check costs no config fetch.
|
|
108
|
-
const
|
|
125
|
+
const stack = state.ctx.platform.runtimeStack();
|
|
126
|
+
const protectedRoots = buildProtectedRoots(stack);
|
|
109
127
|
const configCache = new Map(allConfigs.map((c) => [c.source, c]));
|
|
110
128
|
// Source-level git indicator (G2.1 / spellbound-heron) — the CONFIG-panel half
|
|
111
129
|
// of the two summary builders (the TREE half is `FilesystemService.list`;
|
|
112
130
|
// MAJOR-1 — BOTH must be enriched). CHEAP + bounded O(1)/source: for each
|
|
113
|
-
// git-backed
|
|
131
|
+
// git-backed `host` folder (this server's or a device's — read without a spawn), resolve its root and stamp
|
|
114
132
|
// `git = { isRepo, repoName, branch }` from `getSourceGitSummary` (a `.git` stat + an
|
|
115
133
|
// O(1) `.git/HEAD` branch read, TTL-cached — never the whole-repo status).
|
|
116
134
|
// Runs on `allConfigs` AFTER `listSourceConfigs`'s scope filter (leak-safe).
|
|
@@ -122,7 +140,7 @@ export const GET = async (req, raw) => {
|
|
|
122
140
|
const origin = matched?.originPackage ?? null;
|
|
123
141
|
const git = await resolveSourceGit(config, infra, ctx);
|
|
124
142
|
const readable = await rootReadable(infra, ctx, config.source, configCache);
|
|
125
|
-
const view = await sourceConfigView(req.session, config, infra?.projectRoot ?? '', protectedRoots, readable, deviceAttachVerdict(state, ctx, config.connection), !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);
|
|
126
144
|
return {
|
|
127
145
|
...toPublicSourceConfig(config, view),
|
|
128
146
|
// DERIVED, never persisted — the same chain the `<runtime_stack>`
|
|
@@ -140,7 +158,7 @@ export const GET = async (req, raw) => {
|
|
|
140
158
|
origin_package: origin,
|
|
141
159
|
orphan: !metadata, embedding_enabled: embeddingEnabled(),
|
|
142
160
|
canMaintain: hasRouteFeature(req.session, 'sources.mount') && hasRouteFeature(req.session, 'sources.sync')
|
|
143
|
-
&& canAccessScope(config.scope, access, 'write') && readable,
|
|
161
|
+
&& canAccessScope(config.scope, access, 'write', sourceScopeOrigin(config.connection)) && readable,
|
|
144
162
|
orphan_hint: metadata ? null : `Package contributing kind "${config.connection.kind}" is missing — remove the source or reinstall the package.`,
|
|
145
163
|
...(git ? { git } : {}),
|
|
146
164
|
};
|
|
@@ -149,8 +167,8 @@ export const GET = async (req, raw) => {
|
|
|
149
167
|
};
|
|
150
168
|
/**
|
|
151
169
|
* Resolve the source-row git indicator for `GET /sources` (G2.1). Returns
|
|
152
|
-
* `{ isRepo: true, repoName?, branch? }` for a git-backed
|
|
153
|
-
* 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
|
|
154
172
|
* whose NON-repo root contains nested depth-1 checkouts (the drop condition is
|
|
155
173
|
* `isRepo || containsRepos`; without the widening the field never reaches the
|
|
156
174
|
* client and the multi-repo picker is dead-on-arrival). `undefined` for
|
|
@@ -162,12 +180,12 @@ export const GET = async (req, raw) => {
|
|
|
162
180
|
* never a host path. Fail-soft: any git problem yields `undefined`.
|
|
163
181
|
*/
|
|
164
182
|
async function resolveSourceGit(config, infra, ctx) {
|
|
165
|
-
if (config.connection
|
|
183
|
+
if (!config.connection || sourcePlane(config.connection.kind ?? '', config.connection.config) === 'connector')
|
|
166
184
|
return undefined;
|
|
167
185
|
const connector = infra?.connectors?.resolveBySource?.(config.source) ?? null;
|
|
168
|
-
if (!(connector instanceof
|
|
186
|
+
if (!(connector instanceof HostConnector))
|
|
169
187
|
return undefined;
|
|
170
|
-
const summary = await getSourceGitSummary(connector
|
|
188
|
+
const summary = await getSourceGitSummary(probePlaceOf(connector));
|
|
171
189
|
if (!summary)
|
|
172
190
|
return undefined;
|
|
173
191
|
if (!summary.isRepo) {
|
|
@@ -219,7 +237,7 @@ export const POST = async (req, raw) => {
|
|
|
219
237
|
}
|
|
220
238
|
const { bridge, infra } = await getServices(state, ctx.projectId);
|
|
221
239
|
const config = await bridge.getSourceConfig(ctx, source, toScopeAccessCtx(ctx));
|
|
222
|
-
if (!config || !canAccessScope(config.scope, toScopeAccessCtx(ctx), 'write')) {
|
|
240
|
+
if (!config || !canAccessScope(config.scope, toScopeAccessCtx(ctx), 'write', sourceScopeOrigin(config.connection))) {
|
|
223
241
|
return { status: 404, body: { error: 'Source not found.' } };
|
|
224
242
|
}
|
|
225
243
|
try {
|
|
@@ -265,6 +283,14 @@ async function handleKindsList(state) {
|
|
|
265
283
|
packageId: m.packageId,
|
|
266
284
|
trust: m.packageTrust,
|
|
267
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] } : {}),
|
|
268
294
|
runtimeGated: m.runtimeGated,
|
|
269
295
|
urlScheme: m.uri?.suggestedScheme ?? m.kind,
|
|
270
296
|
configSchema: m.configSchema,
|
|
@@ -334,6 +360,7 @@ async function withEffectiveDescription(config, state, ctx, session) {
|
|
|
334
360
|
const matched = matchDeclaredSource(config, state.ctx.platform.packageSourceDeclarations(), {
|
|
335
361
|
projectRoot: infra?.projectRoot ?? '',
|
|
336
362
|
appRoot: state.ctx.platform.runtimeStack().appRoot,
|
|
363
|
+
isDocker: state.ctx.platform.runtimeStack().isDocker,
|
|
337
364
|
});
|
|
338
365
|
return {
|
|
339
366
|
...toPublicSourceConfig(config, await publicViewFor(session, ctx, config, state, infra)),
|
|
@@ -357,7 +384,7 @@ async function handleSourcesCreate(req, state, preflight = false) {
|
|
|
357
384
|
const description = body?.description;
|
|
358
385
|
// `null` = absent. A read-then-resend caller (the manage-sources skill's jq
|
|
359
386
|
// projection) legitimately round-trips a field the listing never carried.
|
|
360
|
-
const
|
|
387
|
+
const packageFolders = body?.packageFolders ?? undefined;
|
|
361
388
|
if (!source)
|
|
362
389
|
return { status: 400, body: { error: 'Missing source' } };
|
|
363
390
|
if (description !== undefined && typeof description !== 'string') {
|
|
@@ -382,13 +409,13 @@ async function handleSourcesCreate(req, state, preflight = false) {
|
|
|
382
409
|
},
|
|
383
410
|
};
|
|
384
411
|
}
|
|
385
|
-
// R2b — `
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
// is a 400 regardless of role.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 } };
|
|
392
419
|
await ensureAccess(ctx, state);
|
|
393
420
|
// A source on the caller's OWN paired device: its owner with `device.own`
|
|
394
421
|
// satisfies the whole attach stack (`sources.mount` included). On the host
|
|
@@ -397,12 +424,20 @@ async function handleSourcesCreate(req, state, preflight = false) {
|
|
|
397
424
|
if (!ownDevice.allowed && !hasRouteFeature(req.session, 'sources.mount')) {
|
|
398
425
|
return { status: 403, body: { error: 'Creating sources requires sources.mount.' } };
|
|
399
426
|
}
|
|
400
|
-
if (
|
|
401
|
-
return { status: 403, body: { error: 'Setting
|
|
402
|
-
}
|
|
403
|
-
if (recognizesPackages === true && connection.kind !== 'local') {
|
|
404
|
-
return { status: 400, body: { error: 'recognizesPackages may only be true for local-kind sources.' } };
|
|
427
|
+
if (packageFolders !== undefined && !isOwnerOrAdminEffective(req.session)) {
|
|
428
|
+
return { status: 403, body: { error: 'Setting packageFolders requires an owner/admin-effective role.' } };
|
|
405
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;
|
|
406
441
|
// fenced-courtyard (HEALTH1 B3) — privileged-root ADD gate. `sources.mount`
|
|
407
442
|
// attaches ordinary sources, but attaching a source rooted IN, OVER, or AT a
|
|
408
443
|
// protected host zone (App Zone, the Neuralis tree, the whole host) requires
|
|
@@ -413,46 +448,62 @@ async function handleSourcesCreate(req, state, preflight = false) {
|
|
|
413
448
|
// `..` / trailing-slash / a Docker free-root attach with no suggestion). The
|
|
414
449
|
// requested root is resolved with `resolveLocalRoot` — the SAME canonicaliser
|
|
415
450
|
// the connector uses — so the gate sees exactly what will be mounted.
|
|
416
|
-
// The
|
|
451
|
+
// The DEVICE plane is decided BEFORE the root-sensitive container gate below:
|
|
417
452
|
// the device's owner with `device.own`, nobody else, with no root-string
|
|
418
453
|
// inspection and no `protectedRoots.length` precondition (fail closed even
|
|
419
454
|
// when the runtime stack is unavailable; a sub-directory is gated like `/`).
|
|
420
|
-
// The
|
|
421
|
-
const missingForAttach = await missingAttachFeature(req.session,
|
|
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);
|
|
422
460
|
if (missingForAttach) {
|
|
423
461
|
return {
|
|
424
462
|
status: 403,
|
|
425
463
|
body: {
|
|
426
464
|
error: missingForAttach.plane === 'device'
|
|
427
465
|
? "Only the device's owner may attach a source on it."
|
|
428
|
-
: missingForAttach.plane === '
|
|
429
|
-
? `Attaching a
|
|
466
|
+
: missingForAttach.plane === 'owner'
|
|
467
|
+
? `Attaching a source on your device requires the ${missingForAttach.feature} feature.`
|
|
430
468
|
: `Attaching a source rooted in a protected host zone requires the ${missingForAttach.feature} feature.`,
|
|
431
469
|
},
|
|
432
470
|
};
|
|
433
471
|
}
|
|
472
|
+
const fencedAttach = await nativeFenceRefusal(state, projectId, source, pairedConnection);
|
|
473
|
+
if (fencedAttach)
|
|
474
|
+
return fencedAttach;
|
|
434
475
|
const bridge = await resolveBridge(state, projectId);
|
|
435
476
|
const scope = { userId: ctx.userId, projectId };
|
|
436
477
|
const existing = await bridge.getSourceConfig(scope, source, toScopeAccessCtx(ctx));
|
|
437
478
|
if (existing)
|
|
438
479
|
return { status: 409, body: { error: `Source already exists: ${source}` } };
|
|
439
|
-
const normalizedPerms = normalizePermissions(permissions);
|
|
480
|
+
const normalizedPerms = normalizePermissions(permissions, { rolePriorities: ctx.rolePriorities });
|
|
440
481
|
const normalizedConnection = {
|
|
441
|
-
kind:
|
|
442
|
-
config: (
|
|
482
|
+
kind: pairedConnection.kind,
|
|
483
|
+
config: (pairedConnection.config ?? {}),
|
|
443
484
|
};
|
|
444
485
|
const kindMetadata = platformRegistries(state.ctx.platform).sourceKinds.get(normalizedConnection.kind);
|
|
445
486
|
if (!kindMetadata)
|
|
446
487
|
return { status: 400, body: { error: `Unknown source kind "${normalizedConnection.kind}"` } };
|
|
447
|
-
|
|
448
|
-
|
|
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]);
|
|
449
500
|
if (!kindMetadata.scopeSelectable && sourceScope.kind !== kindMetadata.defaultScope) {
|
|
450
501
|
return { status: 400, body: { error: `Kind "${kindMetadata.kind}" is project-scope only.` } };
|
|
451
502
|
}
|
|
452
503
|
if (!allowedScopes.includes(sourceScope.kind)) {
|
|
453
504
|
return { status: 400, body: { error: `Scope "${sourceScope.kind}" is not allowed for kind "${kindMetadata.kind}".` } };
|
|
454
505
|
}
|
|
455
|
-
if (!canAccessScope(sourceScope, toScopeAccessCtx(ctx), 'write')) {
|
|
506
|
+
if (!canAccessScope(sourceScope, toScopeAccessCtx(ctx), 'write', sourceScopeOrigin(normalizedConnection))) {
|
|
456
507
|
return { status: 403, body: { error: 'Forbidden for requested source scope' } };
|
|
457
508
|
}
|
|
458
509
|
// Multi-operation callers need every conditional feature refusal before an
|
|
@@ -470,8 +521,9 @@ async function handleSourcesCreate(req, state, preflight = false) {
|
|
|
470
521
|
caller: toSyncCaller(ctx),
|
|
471
522
|
...(description !== undefined ? { description: description } : {}),
|
|
472
523
|
...(sync && isSyncConfig(sync) ? { sync: sync } : {}),
|
|
473
|
-
...(
|
|
524
|
+
...(packageFolders !== undefined ? { packageFolders: packageFolders } : {}),
|
|
474
525
|
});
|
|
526
|
+
paired.auditHandSet?.(ctx.userId, projectId, config.source);
|
|
475
527
|
const infra = await state.infraFactory.getInfra(projectId);
|
|
476
528
|
return { status: 201, body: toPublicSourceConfig(config, await publicViewFor(req.session, ctx, config, state, infra)) };
|
|
477
529
|
}
|
|
@@ -488,23 +540,6 @@ async function handleSourcesCreate(req, state, preflight = false) {
|
|
|
488
540
|
function hasRouteFeature(session, featureId) {
|
|
489
541
|
return hasFeature(session, featureId);
|
|
490
542
|
}
|
|
491
|
-
/**
|
|
492
|
-
* Owner/admin-STRENGTH check (R2), keyed on the ordinal, never on a role NAME
|
|
493
|
-
* (D-A). The built-in anchors are `owner 1 · admin 2 · manager 10 · member 20 ·
|
|
494
|
-
* viewer 30` (lower = stronger, `SessionContext.priority`) and a custom role
|
|
495
|
-
* declares its own number in 1..99 — so the admin ANCHOR is the threshold, and
|
|
496
|
-
* a custom role at priority 1 or 2 passes exactly like the built-ins. A
|
|
497
|
-
* wildcard feature grant (`'*'`) is owner-effective too, resolved through the
|
|
498
|
-
* ONE kernel `hasFeature`. Used to gate code-LOAD surfaces like
|
|
499
|
-
* `recognizesPackages`.
|
|
500
|
-
*/
|
|
501
|
-
function isOwnerOrAdminEffective(session) {
|
|
502
|
-
const s = session;
|
|
503
|
-
const priority = typeof s?.priority === 'number' ? s.priority : undefined;
|
|
504
|
-
if (priority !== undefined && priority <= BUILTIN_ROLE_PRIORITY.admin)
|
|
505
|
-
return true;
|
|
506
|
-
return hasRouteFeature(session, '*');
|
|
507
|
-
}
|
|
508
543
|
// ---------------------------------------------------------------------------
|
|
509
544
|
// fenced-courtyard (HEALTH1 B3) — the per-discoverable-source access table.
|
|
510
545
|
//
|
|
@@ -523,6 +558,24 @@ function isOwnerOrAdminEffective(session) {
|
|
|
523
558
|
// ---------------------------------------------------------------------------
|
|
524
559
|
/** Feature required to SEE/ADD a high-blast-radius host zone (C8 — no grant below the admin tier). */
|
|
525
560
|
const PRIVILEGED_MOUNT_FEATURE = 'sources.privileged';
|
|
561
|
+
/**
|
|
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.
|
|
571
|
+
*
|
|
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.
|
|
576
|
+
*/
|
|
577
|
+
/** The scopes a device folder may be attached at — never `agent` (see `handleSourcesCreate`). */
|
|
578
|
+
const DEVICE_FOLDER_SCOPES = ['user', 'project'];
|
|
526
579
|
/**
|
|
527
580
|
* Build the filter-free protected-root table from the runtime stack. App Zone,
|
|
528
581
|
* the Neuralis source tree, and the whole host (`/`) all require
|
|
@@ -539,16 +592,22 @@ export function buildProtectedRoots(stack) {
|
|
|
539
592
|
}
|
|
540
593
|
/**
|
|
541
594
|
* Containment in BOTH directions, canonical paths. Fires when the requested
|
|
542
|
-
* root IS, is UNDER, or is an ANCESTOR of a protected root — except
|
|
543
|
-
*
|
|
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.
|
|
544
602
|
*/
|
|
545
603
|
function rootMatches(requested, protectedRoot, mode = 'containment') {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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)
|
|
552
611
|
// The ANCESTOR arm belongs to ATTACH, not to enumeration. Attaching `/home`
|
|
553
612
|
// hands over everything below it, so exposing a zone from above is the same
|
|
554
613
|
// act; LISTING `/home` one level deep does not, and the browse handler drops
|
|
@@ -558,7 +617,7 @@ function rootMatches(requested, protectedRoot, mode = 'containment') {
|
|
|
558
617
|
// opens at `/`, so the first click would deny with no route downward. The
|
|
559
618
|
// zone itself stays protected: `is-or-under` still fires on it, and the
|
|
560
619
|
// handler filters protected CHILDREN out of the listing it returns.
|
|
561
|
-
if (mode === 'containment' &&
|
|
620
|
+
if (mode === 'containment' && isPathInside(requested, protectedRoot, p))
|
|
562
621
|
return true;
|
|
563
622
|
return false;
|
|
564
623
|
}
|
|
@@ -586,40 +645,41 @@ export function requiredFeaturesForRoot(root, protectedRoots, kind, mode = 'cont
|
|
|
586
645
|
* the listing projection, so "you see the path you could attach" cannot drift
|
|
587
646
|
* from what attaching requires.
|
|
588
647
|
*
|
|
589
|
-
* -
|
|
648
|
+
* - Device plane: the DEVICE's owner holding `device.own` (`ownDevice`,
|
|
590
649
|
* agent-core's device gate) and nobody else — a non-owner is refused as
|
|
591
650
|
* `device`, an owner without `device.own` is told the feature it lacks; no
|
|
592
651
|
* root inspection and no runtime-stack precondition (fail closed).
|
|
593
|
-
* -
|
|
594
|
-
* `root` —
|
|
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 —
|
|
595
654
|
* resolves through `resolveRoot` (the connector's own canonicaliser) and hits
|
|
596
655
|
* the SAME containment table. Scoping this to one kind is how a newly
|
|
597
656
|
* registered root-bearing kind walks straight past the gate.
|
|
598
657
|
*/
|
|
599
658
|
async function missingAttachFeature(session, connection, protectedRoots, resolveRoot, ownDevice) {
|
|
600
|
-
if (
|
|
659
|
+
if (sourcePlane(connection.kind, connection.config) === 'device') {
|
|
601
660
|
if (ownDevice.allowed)
|
|
602
661
|
return null;
|
|
603
|
-
return { feature: 'device.own', plane: ownDevice.owner ? '
|
|
662
|
+
return { feature: 'device.own', plane: ownDevice.owner ? 'owner' : 'device' };
|
|
604
663
|
}
|
|
605
664
|
if (protectedRoots.length === 0)
|
|
606
665
|
return null;
|
|
607
|
-
const root = connection.config
|
|
666
|
+
const { root, hostRoot } = (connection.config ?? {});
|
|
608
667
|
if (typeof root !== 'string' || root.length === 0)
|
|
609
668
|
return null;
|
|
610
|
-
|
|
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');
|
|
611
671
|
const feature = required.find((f) => !hasRouteFeature(session, f));
|
|
612
672
|
return feature ? { feature, plane: 'zone' } : null;
|
|
613
673
|
}
|
|
614
674
|
/**
|
|
615
675
|
* Ask agent-core's ONE device gate (`devices.mayAttach`) about the device a
|
|
616
|
-
*
|
|
676
|
+
* device-plane connection names. brain-core reads `config.device` as an opaque
|
|
617
677
|
* string and knows no device semantics; no agent-core, no device or a
|
|
618
678
|
* non-host kind ⇒ `{ allowed: false, owner: false }` (fail closed).
|
|
619
679
|
*/
|
|
620
680
|
function deviceAttachVerdict(state, ctx, connection) {
|
|
621
681
|
const denied = { allowed: false, owner: false };
|
|
622
|
-
if (
|
|
682
|
+
if (sourcePlane(connection.kind, connection.config) !== 'device')
|
|
623
683
|
return denied;
|
|
624
684
|
const device = connection.config?.device;
|
|
625
685
|
if (typeof device !== 'string' || !device)
|
|
@@ -629,11 +689,110 @@ function deviceAttachVerdict(state, ctx, connection) {
|
|
|
629
689
|
projectId: ctx.projectId,
|
|
630
690
|
...(ctx.agentId ? { agentId: ctx.agentId } : {}),
|
|
631
691
|
callerId: '@neuralis/brain-core',
|
|
632
|
-
callerTrust: '
|
|
633
|
-
grantedFeatures: ctx.grantedFeatures,
|
|
692
|
+
callerTrust: 'native',
|
|
693
|
+
grantedFeatures: [...ctx.grantedFeatures],
|
|
634
694
|
};
|
|
635
695
|
const agentCore = state.ctx.packages?.getPackageApi('@neuralis/agent-core', session);
|
|
636
|
-
return agentCore?.devices?.mayAttach({ userId: ctx.userId, grantedFeatures: ctx.grantedFeatures }, device) ?? denied;
|
|
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
|
+
};
|
|
637
796
|
}
|
|
638
797
|
/**
|
|
639
798
|
* May this caller READ the source's root — the same per-uri read predicate the
|
|
@@ -653,21 +812,31 @@ async function rootReadable(infra, ctx, source, configCache) {
|
|
|
653
812
|
* nothing; the raw last-sync error goes to a `sources.sync` holder, the tier
|
|
654
813
|
* `sources/sync-status` answers to. A feature, never a role name.
|
|
655
814
|
*/
|
|
656
|
-
async function sourceConfigView(session, config, projectRoot, protectedRoots, readable, ownDevice, defaultSyncExclude) {
|
|
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.
|
|
657
819
|
const mayAttach = (ownDevice.allowed || hasRouteFeature(session, 'sources.mount'))
|
|
658
|
-
&& await missingAttachFeature(session, config.connection, protectedRoots,
|
|
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;
|
|
659
827
|
return { mayReadPaths: mayAttach || readable, maySeeSyncErrors: hasRouteFeature(session, 'sources.sync'),
|
|
660
|
-
...(defaultSyncExclude ? { defaultSyncExclude } : {}) };
|
|
828
|
+
...(defaultSyncExclude ? { defaultSyncExclude } : {}), ...(rootEffective ? { rootEffective } : {}) };
|
|
661
829
|
}
|
|
662
830
|
/** `sourceConfigView` for a single-row answer (single GET, mutation echoes). */
|
|
663
831
|
async function publicViewFor(session, ctx, config, state, infra) {
|
|
664
832
|
const readable = await rootReadable(infra, ctx, config.source, new Map([[config.source, config]]));
|
|
833
|
+
const stack = state.ctx.platform.runtimeStack();
|
|
665
834
|
const matched = !config.sync ? matchDeclaredSource(config, state.ctx.platform.packageSourceDeclarations(), {
|
|
666
|
-
projectRoot: infra?.projectRoot ?? '', appRoot:
|
|
835
|
+
projectRoot: infra?.projectRoot ?? '', appRoot: stack.appRoot, isDocker: stack.isDocker,
|
|
667
836
|
}) : null;
|
|
668
837
|
const defaults = matched && isOwnDataZoneDeclaration(matched.originPackage, matched.decl, state.ctx.selfId)
|
|
669
838
|
? seedExcludedMachineWrittenGlobs(state.ctx.platform.machineWrittenPaths()) : undefined;
|
|
670
|
-
return sourceConfigView(session, config, infra?.projectRoot ?? '', buildProtectedRoots(
|
|
839
|
+
return sourceConfigView(session, config, infra?.projectRoot ?? '', stack.isDocker, buildProtectedRoots(stack), readable, deviceAttachVerdict(state, ctx, config.connection), defaults);
|
|
671
840
|
}
|
|
672
841
|
function filterSourceForList(config, ctx, requestedScope, includeObserved) {
|
|
673
842
|
if (requestedScope && config.scope.kind !== requestedScope)
|
|
@@ -677,10 +846,21 @@ function filterSourceForList(config, ctx, requestedScope, includeObserved) {
|
|
|
677
846
|
return false;
|
|
678
847
|
return true;
|
|
679
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
|
+
*/
|
|
680
855
|
function resolveRequestedSourceScope(raw, defaultScope, ctx) {
|
|
681
856
|
if (isSourceScope(raw))
|
|
682
857
|
return raw;
|
|
683
|
-
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;
|
|
684
864
|
if (requested === 'user')
|
|
685
865
|
return { kind: 'user', userId: ctx.userId };
|
|
686
866
|
if (requested === 'agent')
|
|
@@ -700,7 +880,7 @@ async function handleSourcesUpdate(req, state) {
|
|
|
700
880
|
const description = body?.description;
|
|
701
881
|
// `null` = absent. A read-then-resend caller (the manage-sources skill's jq
|
|
702
882
|
// projection) legitimately round-trips a field the listing never carried.
|
|
703
|
-
const
|
|
883
|
+
const packageFolders = body?.packageFolders ?? undefined;
|
|
704
884
|
if (!source)
|
|
705
885
|
return { status: 400, body: { error: 'Missing source' } };
|
|
706
886
|
if (description !== undefined && typeof description !== 'string') {
|
|
@@ -713,15 +893,15 @@ async function handleSourcesUpdate(req, state) {
|
|
|
713
893
|
if (enabledRaw !== undefined && typeof enabledRaw !== 'boolean' && enabledRaw !== 'auto') {
|
|
714
894
|
return { status: 400, body: { error: 'enabled must be boolean or "auto" if provided' } };
|
|
715
895
|
}
|
|
716
|
-
if (
|
|
717
|
-
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' } };
|
|
718
898
|
}
|
|
719
899
|
await ensureAccess(ctx, state);
|
|
720
|
-
// R2 (architect MINOR-2) —
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
if (
|
|
724
|
-
return { status: 403, body: { error: 'Setting
|
|
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.' } };
|
|
725
905
|
}
|
|
726
906
|
const bridge = await resolveBridge(state, projectId);
|
|
727
907
|
const scope = { userId: ctx.userId, projectId };
|
|
@@ -732,43 +912,23 @@ async function handleSourcesUpdate(req, state) {
|
|
|
732
912
|
if (!ownDevice.allowed && !hasRouteFeature(req.session, 'sources.mount')) {
|
|
733
913
|
return { status: 403, body: { error: 'Updating sources requires sources.mount.' } };
|
|
734
914
|
}
|
|
735
|
-
|
|
736
|
-
|
|
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 };
|
|
737
931
|
}
|
|
738
|
-
try {
|
|
739
|
-
assertImmutablePolicyFloorsPreserved(existing.permissions, permissions);
|
|
740
|
-
}
|
|
741
|
-
catch (err) {
|
|
742
|
-
if (err instanceof ImmutablePolicyFloorError) {
|
|
743
|
-
return { status: 409, body: { code: err.code, error: err.message } };
|
|
744
|
-
}
|
|
745
|
-
throw err;
|
|
746
|
-
}
|
|
747
|
-
if (recognizesPackages === true && existing.connection.kind !== 'local') {
|
|
748
|
-
return { status: 400, body: { error: 'recognizesPackages may only be true for local-kind sources.' } };
|
|
749
|
-
}
|
|
750
|
-
// PATCH cannot change `connection`, but it CAN widen an existing host-plane
|
|
751
|
-
// source's permissions (read → write). Editing one therefore needs what
|
|
752
|
-
// attaching one does — the device's owner with `device.own` — otherwise a
|
|
753
|
-
// plain-`sources.mount` manager could escalate a read-only host source in place.
|
|
754
|
-
// Same rule for the container plane: a PATCH can widen permissions (read →
|
|
755
|
-
// write) on a source already rooted in a protected zone, so editing one
|
|
756
|
-
// requires the same grants attaching one does — for EVERY root-bearing kind,
|
|
757
|
-
// not just `local`.
|
|
758
|
-
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, ownDevice);
|
|
759
|
-
if (missingForEdit) {
|
|
760
|
-
return {
|
|
761
|
-
status: 403,
|
|
762
|
-
body: {
|
|
763
|
-
error: missingForEdit.plane === 'device'
|
|
764
|
-
? "Only the device's owner may edit a source on it."
|
|
765
|
-
: missingForEdit.plane === 'host'
|
|
766
|
-
? `Editing a host-plane source requires the ${missingForEdit.feature} feature.`
|
|
767
|
-
: `Editing a source rooted in a protected host zone requires the ${missingForEdit.feature} feature.`,
|
|
768
|
-
},
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
const normalizedPerms = normalizePermissions(permissions);
|
|
772
932
|
try {
|
|
773
933
|
const updated = await bridge.updateSourceConfig(scope, {
|
|
774
934
|
agentId,
|
|
@@ -778,7 +938,7 @@ async function handleSourcesUpdate(req, state) {
|
|
|
778
938
|
...(sync ? { sync } : {}),
|
|
779
939
|
...(clearSync ? { clearSync: true } : {}),
|
|
780
940
|
...(enabledRaw !== undefined ? { enabled: enabledRaw } : {}),
|
|
781
|
-
...(
|
|
941
|
+
...(packageFolders !== undefined ? { packageFolders: packageFolders } : {}),
|
|
782
942
|
});
|
|
783
943
|
const infra = await state.infraFactory.getInfra(projectId);
|
|
784
944
|
return { status: 200, body: toPublicSourceConfig(updated, await publicViewFor(req.session, ctx, updated, state, infra)) };
|
|
@@ -816,9 +976,20 @@ async function handleSourcesEnabled(req, state) {
|
|
|
816
976
|
}
|
|
817
977
|
if (!existing)
|
|
818
978
|
return { status: 404, body: { error: `Source not found: ${source}` } };
|
|
819
|
-
if (!canAccessScope(existing.scope, toScopeAccessCtx(ctx), 'write')) {
|
|
979
|
+
if (!canAccessScope(existing.scope, toScopeAccessCtx(ctx), 'write', sourceScopeOrigin(existing.connection))) {
|
|
820
980
|
return { status: 403, body: { error: 'Forbidden for source scope' } };
|
|
821
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
|
+
}
|
|
822
993
|
try {
|
|
823
994
|
const updated = await bridge.setSourceEnabled(scope, { source, enabled });
|
|
824
995
|
const infra = await state.infraFactory.getInfra(ctx.projectId);
|
|
@@ -841,7 +1012,7 @@ async function handleSourcesDelete(slug, req, state) {
|
|
|
841
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;
|
|
@@ -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
|
/**
|
|
@@ -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);
|
|
@@ -1172,11 +1341,17 @@ async function handleSourcesDiscoverable(req, state) {
|
|
|
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 = 'files.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 `sources.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 `sources.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
|
-
|
|
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] });
|
|
1663
|
+
}
|
|
1471
1664
|
if (!hasRouteFeature(req.session, 'sources.mount')) {
|
|
1472
1665
|
return { status: 403, body: { error: 'Browsing filesystem paths requires the sources.mount feature.' } };
|
|
1473
1666
|
}
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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 } };
|
|
1559
|
-
}
|
|
1560
|
-
catch (err) {
|
|
1561
|
-
// Never the raw message: it carries the absolute path and distinguishes
|
|
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 } };
|
|
1567
|
-
}
|
|
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
|