@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
|
@@ -2,19 +2,22 @@
|
|
|
2
2
|
* GitRunner — the single, security-hardened entry point to git for a
|
|
3
3
|
* git-backed source (porcelain-marten / G0).
|
|
4
4
|
*
|
|
5
|
-
* It is keyed on a RESOLVED
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* It is keyed on a RESOLVED CHECKOUT ROOT — the `host` connector's root, on
|
|
6
|
+
* this machine or on a paired device — so git can never operate outside the
|
|
7
|
+
* source boundary. Every git surface (badges, the Git Control panel, the
|
|
8
|
+
* routes-based git skill, remote ops) goes through this class: it is the floor
|
|
9
|
+
* they all build on.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Every op is ONE argv run through `#spawnGit` — never a shell, never a wrapper
|
|
12
|
+
* library. On this machine git runs IN-PROCESS as native trusted host code
|
|
13
|
+
* (D2), NOT inside the Landlock shell sandbox; on a device it runs through that
|
|
14
|
+
* device's broker (`transport.exec`), under the owner's ceiling and, for anyone
|
|
15
|
+
* but the device's owner, a live sandbox. The four containment requirements
|
|
16
|
+
* below hold IDENTICALLY on both — a working tree (especially one cloned from
|
|
17
|
+
* an untrusted remote) is attacker-influenced data.
|
|
15
18
|
*
|
|
16
19
|
* The four containment floors (architect Finding 2, porcelain-marten):
|
|
17
|
-
* 1. ROOT BINDING — every op runs with `cwd =
|
|
20
|
+
* 1. ROOT BINDING — every op runs with `cwd = root`; sub-path args
|
|
18
21
|
* are guarded to stay inside it; `git rev-parse --show-toplevel` is
|
|
19
22
|
* clamped so a `.git` ABOVE the source is rejected, AND (G2.5) the
|
|
20
23
|
* EFFECTIVE `--absolute-git-dir` is clamped so a `.git` FILE / symlink
|
|
@@ -28,25 +31,31 @@
|
|
|
28
31
|
* hooks + fsmonitor + pager, closing the malicious-checkout RCE class.
|
|
29
32
|
* 4. CONTROLLED ENV — ambient `GIT_DIR`/`GIT_WORK_TREE`/`GIT_CONFIG*` etc.
|
|
30
33
|
* are stripped and global/system config is neutralised, so no inherited
|
|
31
|
-
* env var can redirect the repo or inject config
|
|
34
|
+
* env var can redirect the repo or inject config (on a device the broker
|
|
35
|
+
* strips them from its own base for every argv run).
|
|
36
|
+
*
|
|
37
|
+
* The hooks path and the neutralised config files name the NULL DEVICE of the
|
|
38
|
+
* machine git runs on (`gitNullDevice`: `/dev/null`, `NUL` on a Windows device).
|
|
32
39
|
*
|
|
33
40
|
* G4 (keyring-kestrel Slice 2): remote ops (fetch/push/pull) ARE implemented —
|
|
34
|
-
* `fetch`/`push`/`pull`/`mergeFetched` route through `#spawnGitRemote` (
|
|
35
|
-
* `node:child_process
|
|
41
|
+
* `fetch`/`push`/`pull`/`mergeFetched` route through `#spawnGitRemote` (this
|
|
42
|
+
* machine's raw `node:child_process`; on a device checkout they run on the device for its owner
|
|
43
|
+
* only, with the owner's own credential helpers and never a server token), which prepends the full
|
|
36
44
|
* protected-config-aware remote-hardening `-c` set (S1 empty-reset-then-constant
|
|
37
45
|
* credential helper, S3 askPass off, `protocol.{file,ext,git}.allow=never`,
|
|
38
|
-
* pinned `remote.<n>.{upload,receive}pack`,
|
|
46
|
+
* `push.gpgSign=false`, pinned `remote.<n>.{upload,receive}pack`,
|
|
47
|
+
* `core.sshCommand=`/`core.gitProxy=`)
|
|
39
48
|
* plus a CONSTANT host-matching credential helper reading the token from the
|
|
40
49
|
* child env. NO clone, NO force-push (D6). The token is present ONLY on
|
|
41
50
|
* network-only invocations (fetch/push), never a working-tree checkout/merge
|
|
42
51
|
* (S4 — `pull` = `fetch` + a token-free `mergeFetched`).
|
|
43
52
|
*/
|
|
44
53
|
var _a;
|
|
45
|
-
import path from 'node:path';
|
|
46
54
|
import { realpath } from 'node:fs/promises';
|
|
47
55
|
import { spawn } from 'node:child_process';
|
|
48
|
-
import { simpleGit } from 'simple-git';
|
|
49
56
|
import { resolveSearchBinaries } from '../search/binaryResolver.js';
|
|
57
|
+
import { placePath, toGitPlace } from './gitPlace.js';
|
|
58
|
+
import { transportRefusalReason } from '../connectors/HostConnector.js';
|
|
50
59
|
// ---------------------------------------------------------------------------
|
|
51
60
|
// Structured errors
|
|
52
61
|
// ---------------------------------------------------------------------------
|
|
@@ -211,15 +220,61 @@ export function classifyGitOp(op, args = []) {
|
|
|
211
220
|
// ---------------------------------------------------------------------------
|
|
212
221
|
/**
|
|
213
222
|
* `-c` overrides applied to EVERY git invocation (highest precedence — beats a
|
|
214
|
-
* malicious repo-local `.git/config`).
|
|
215
|
-
*
|
|
216
|
-
|
|
223
|
+
* malicious repo-local `.git/config`). The remote ops add their own set on top
|
|
224
|
+
* (`#spawnGitRemote`).
|
|
225
|
+
*/
|
|
226
|
+
function hardeningConfig(nullDevice) {
|
|
227
|
+
return [
|
|
228
|
+
`core.hooksPath=${nullDevice}`, // no repo-local hook executes as the host process
|
|
229
|
+
'core.fsmonitor=false', // no repo-local fsmonitor command runs
|
|
230
|
+
'core.pager=cat', // never spawn a pager (belt; non-interactive anyway)
|
|
231
|
+
// A repo-local `commit.gpgSign` / `tag.gpgSign` + `gpg.program` would run a
|
|
232
|
+
// program of the repo's choosing on commit / tag (measured, git 2.43.0).
|
|
233
|
+
'commit.gpgSign=false',
|
|
234
|
+
'tag.gpgSign=false',
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Every CONFINED call also points git's default global ignore and attributes
|
|
239
|
+
* files at the null device. A null GIT_CONFIG_GLOBAL still leaves those defaults
|
|
240
|
+
* (`$XDG_CONFIG_HOME/git/…`, `~/.config/git/…`) in force; a sandboxed device
|
|
241
|
+
* spawn cannot open them, and git then dies with "cannot use … as an exclude
|
|
242
|
+
* file" on every `status` / `diff --cached`. Kept apart from `hardeningConfig`
|
|
243
|
+
* because the device owner's own fetch / push runs unconfined and its argv must
|
|
244
|
+
* equal the broker's fixed set (`host-broker/ownerRemoteGit.mjs`).
|
|
245
|
+
*/
|
|
246
|
+
function noGlobalFilesConfig(nullDevice) {
|
|
247
|
+
return [`core.excludesFile=${nullDevice}`, `core.attributesFile=${nullDevice}`];
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The null device floors 3 and 4 point hooks and the global/system config at,
|
|
251
|
+
* in the flavour of the machine git RUNS on. A Windows device takes `NUL`: a
|
|
252
|
+
* `/dev/null` hooks directory there is an ordinary path on the current drive
|
|
253
|
+
* (`\dev\null\<hook>`) whoever can write that drive could fill, while `NUL` is
|
|
254
|
+
* a reserved device name no directory can carry. The Windows half is pinned by
|
|
255
|
+
* a unit on the argv the device transport receives; the on-machine run is a
|
|
256
|
+
* Windows owner row.
|
|
217
257
|
*/
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
258
|
+
export function gitNullDevice(flavour) {
|
|
259
|
+
return flavour === 'win32' ? 'NUL' : '/dev/null';
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The four fixed `GIT_*` values every call carries (floor 4) — constants, never
|
|
263
|
+
* an inherited value. Locally they ride `sanitizedGitEnv()`; on a device they are
|
|
264
|
+
* the request's named env, over the broker's base from which every ambient
|
|
265
|
+
* `GIT_*` and `UNSAFE_PLAIN_ENV` name is already gone (`host-broker/server.mjs`
|
|
266
|
+
* argv runs).
|
|
267
|
+
*/
|
|
268
|
+
export function gitFloorEnv(nullDevice) {
|
|
269
|
+
return {
|
|
270
|
+
GIT_CONFIG_GLOBAL: nullDevice,
|
|
271
|
+
GIT_CONFIG_SYSTEM: nullDevice,
|
|
272
|
+
// Never block on an interactive credential/askpass prompt.
|
|
273
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
274
|
+
// Read-only-status floor — see `sanitizedGitEnv`.
|
|
275
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
276
|
+
};
|
|
277
|
+
}
|
|
223
278
|
/**
|
|
224
279
|
* The CONSTANT (S2) credential-helper snippet installed on remote ops. It is
|
|
225
280
|
* NEVER interpolated with any dynamic value — username/host/token arrive via the
|
|
@@ -284,8 +339,8 @@ const GIT_LOG_FS = '\x1f';
|
|
|
284
339
|
/**
|
|
285
340
|
* Env names OUTSIDE the `GIT_*` namespace that still drive git command
|
|
286
341
|
* execution (git falls back to them when the GIT_ form is absent). These
|
|
287
|
-
* command-bearing variables are stripped alongside the GIT_* sweep
|
|
288
|
-
*
|
|
342
|
+
* command-bearing variables are stripped alongside the GIT_* sweep (the device
|
|
343
|
+
* broker strips the same set for an argv run): a non-interactive hardened
|
|
289
344
|
* runner needs none of them, even when its parent exports an editor command.
|
|
290
345
|
*/
|
|
291
346
|
const UNSAFE_PLAIN_ENV = new Set(['EDITOR', 'VISUAL', 'PAGER', 'PREFIX', 'SSH_ASKPASS']);
|
|
@@ -305,11 +360,9 @@ function sanitizedGitEnv() {
|
|
|
305
360
|
continue;
|
|
306
361
|
env[key] = value;
|
|
307
362
|
}
|
|
308
|
-
// Neutralise ambient global/system config injection (~/.gitconfig, /etc/gitconfig)
|
|
309
|
-
|
|
310
|
-
env
|
|
311
|
-
// Never block on an interactive credential/askpass prompt (G4 floor, harmless now).
|
|
312
|
-
env.GIT_TERMINAL_PROMPT = '0';
|
|
363
|
+
// Neutralise ambient global/system config injection (~/.gitconfig, /etc/gitconfig),
|
|
364
|
+
// never prompt, never take optional locks — the four constants of `gitFloorEnv`.
|
|
365
|
+
Object.assign(env, gitFloorEnv(gitNullDevice('posix')));
|
|
313
366
|
// Read-only-status floor (MAJOR-4a, G2). git opportunistically takes the
|
|
314
367
|
// `.git/index.lock` and REWRITES `.git/index` (stat-cache refresh) even on a
|
|
315
368
|
// pure `status`/`diff` read — which, once the G2.2 `.git`-watcher is live,
|
|
@@ -317,41 +370,95 @@ function sanitizedGitEnv() {
|
|
|
317
370
|
// loop. Disabling OPTIONAL locks makes read queries truly index-read-only; the
|
|
318
371
|
// mutating ops (add/commit/…) still take their real locks, so this is harmless
|
|
319
372
|
// to writes. Set here (env) rather than per-call so EVERY read path inherits it.
|
|
320
|
-
env.GIT_OPTIONAL_LOCKS = '0';
|
|
321
373
|
return env;
|
|
322
374
|
}
|
|
323
|
-
/**
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
375
|
+
/**
|
|
376
|
+
* A git call on a paired device could not be asked (offline, outdated broker,
|
|
377
|
+
* the device's ceiling, an unconfined device for a non-owner). `reason` is the
|
|
378
|
+
* transport's FIXED code — never a path, never the broker's text.
|
|
379
|
+
*/
|
|
380
|
+
export class GitDeviceUnavailableError extends Error {
|
|
381
|
+
reason;
|
|
382
|
+
code = 'git_device_unavailable';
|
|
383
|
+
constructor(reason) {
|
|
384
|
+
super(`git could not run on the device (${reason})`);
|
|
385
|
+
this.reason = reason;
|
|
386
|
+
this.name = 'GitDeviceUnavailableError';
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* A server credential handed to a remote op on a paired device's folder. THIS
|
|
391
|
+
* server's push token (`NRS_GIT_TOKEN`) never crosses a device link — a
|
|
392
|
+
* device's remote ops use its owner's own git credentials there.
|
|
393
|
+
*/
|
|
394
|
+
export class GitRemoteOnDeviceError extends Error {
|
|
395
|
+
code = 'git_remote_on_device';
|
|
396
|
+
constructor() {
|
|
397
|
+
super("a server git credential never reaches a paired device");
|
|
398
|
+
this.name = 'GitRemoteOnDeviceError';
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* fetch / pull / push on a paired device's folder by anyone but the device's
|
|
403
|
+
* owner. The only credential there is the OWNER's own (their git credential
|
|
404
|
+
* helper on their machine); running it for another person would borrow it.
|
|
405
|
+
*/
|
|
406
|
+
export class GitRemoteOwnerOnlyError extends Error {
|
|
407
|
+
code = 'git_remote_owner_only';
|
|
408
|
+
constructor() {
|
|
409
|
+
super("only the device's owner fetches, pulls and pushes a folder on it");
|
|
410
|
+
this.name = 'GitRemoteOwnerOnlyError';
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/** The credential-helper keys an owner's own git config may name — plain and URL-scoped (`credential.<url>.helper`). */
|
|
414
|
+
const OWNER_HELPER_KEY_RE = /^credential(\.[^=\s]+)?\.helper$/i;
|
|
415
|
+
/** A fixed transport code (`device_offline`, `others_denied`, …) off a thrown device error, else a generic one. */
|
|
416
|
+
function deviceReasonOf(error) {
|
|
417
|
+
return transportRefusalReason(error) ?? 'device_unreachable';
|
|
331
418
|
}
|
|
332
|
-
// ---------------------------------------------------------------------------
|
|
333
|
-
// GitRunner
|
|
334
|
-
// ---------------------------------------------------------------------------
|
|
335
419
|
export class GitRunner {
|
|
336
420
|
#root;
|
|
337
|
-
|
|
338
|
-
|
|
421
|
+
/** The machine's path arithmetic (a Windows device answers in `win32`). */
|
|
422
|
+
#path;
|
|
423
|
+
/** The resolved LOCAL git binary; `null` on a device checkout (the device's own `git` runs). */
|
|
339
424
|
#bin;
|
|
340
|
-
/**
|
|
425
|
+
/** A device checkout's exec seat; `null` for a checkout on this machine. */
|
|
426
|
+
#device;
|
|
427
|
+
#nullDevice;
|
|
428
|
+
/** The per-op timeout (an inactivity cap locally, the broker's wall-clock cap on a device). */
|
|
341
429
|
#timeoutMs;
|
|
342
430
|
#repoChecked = null;
|
|
343
|
-
constructor(
|
|
344
|
-
this.#root = root;
|
|
345
|
-
this.#
|
|
346
|
-
this.#bin = bin;
|
|
347
|
-
this.#
|
|
431
|
+
constructor(init) {
|
|
432
|
+
this.#root = init.root;
|
|
433
|
+
this.#path = init.path;
|
|
434
|
+
this.#bin = init.bin;
|
|
435
|
+
this.#device = init.device;
|
|
436
|
+
this.#nullDevice = gitNullDevice(init.device?.transport.flavour ?? 'posix');
|
|
437
|
+
this.#timeoutMs = init.timeoutMs && init.timeoutMs > 0 ? init.timeoutMs : undefined;
|
|
348
438
|
}
|
|
349
|
-
/** The
|
|
439
|
+
/** The checkout root this runner is bound to (a device path for a device checkout). */
|
|
350
440
|
get root() {
|
|
351
441
|
return this.#root;
|
|
352
442
|
}
|
|
353
443
|
static async create(options) {
|
|
354
|
-
const
|
|
444
|
+
const place = toGitPlace(options.root);
|
|
445
|
+
const p = placePath(place);
|
|
446
|
+
if (place.device) {
|
|
447
|
+
// A device spawn follows the device gate, never precedes it: a place built
|
|
448
|
+
// for a read-only probe carries no verdict and cannot run anything.
|
|
449
|
+
const { transport, owner } = place.device;
|
|
450
|
+
if (owner === undefined)
|
|
451
|
+
throw new GitArgumentError('a device checkout needs the device gate verdict');
|
|
452
|
+
if (!transport.exec)
|
|
453
|
+
throw new GitUnavailableError();
|
|
454
|
+
return new _a({
|
|
455
|
+
root: p.resolve(place.root),
|
|
456
|
+
path: p,
|
|
457
|
+
bin: null,
|
|
458
|
+
device: { transport, exec: transport.exec.bind(transport), owner },
|
|
459
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
355
462
|
let bin = options.gitBin;
|
|
356
463
|
if (!bin) {
|
|
357
464
|
const bins = await resolveSearchBinaries();
|
|
@@ -359,74 +466,92 @@ export class GitRunner {
|
|
|
359
466
|
}
|
|
360
467
|
if (!bin)
|
|
361
468
|
throw new GitUnavailableError();
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
// effective wall-clock cap. Only set when the caller asked (G1); untimed
|
|
370
|
-
// otherwise so G4 clone/fetch (legitimately long, bursty) is unaffected.
|
|
371
|
-
...(options.timeoutMs && options.timeoutMs > 0
|
|
372
|
-
? { timeout: { block: options.timeoutMs } }
|
|
373
|
-
: {}),
|
|
374
|
-
config: HARDENING_CONFIG,
|
|
375
|
-
// Only the sanitizer's four defensive Git overrides may cross v4's gate.
|
|
376
|
-
allowEnvironment: [
|
|
377
|
-
'GIT_CONFIG_GLOBAL',
|
|
378
|
-
'GIT_CONFIG_SYSTEM',
|
|
379
|
-
'GIT_TERMINAL_PROMPT',
|
|
380
|
-
'GIT_OPTIONAL_LOCKS',
|
|
381
|
-
],
|
|
382
|
-
// simple-git blocks `-c core.hooksPath|fsmonitor|pager` because those are
|
|
383
|
-
// the exact keys an ATTACKER-supplied arg would abuse. Here the values are
|
|
384
|
-
// HARDCODED safe constants (HARDENING_CONFIG) set DEFENSIVELY, never
|
|
385
|
-
// caller-influenced — so the explicit opt-in is correct and required.
|
|
386
|
-
unsafe: {
|
|
387
|
-
allowUnsafeHooksPath: true,
|
|
388
|
-
allowUnsafeFsMonitor: true,
|
|
389
|
-
allowUnsafePager: true,
|
|
390
|
-
// We set GIT_CONFIG_GLOBAL/SYSTEM=/dev/null in the env (hardcoded safe
|
|
391
|
-
// constants) to neutralise ambient global/system gitconfig — simple-git
|
|
392
|
-
// flags that env as a config-path override, so opt in for our own value.
|
|
393
|
-
allowUnsafeConfigPaths: true,
|
|
394
|
-
},
|
|
395
|
-
}).env(sanitizedGitEnv());
|
|
396
|
-
return new _a(root, git, bin, options.timeoutMs);
|
|
469
|
+
return new _a({
|
|
470
|
+
root: p.resolve(place.root),
|
|
471
|
+
path: p,
|
|
472
|
+
bin,
|
|
473
|
+
device: null,
|
|
474
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
475
|
+
});
|
|
397
476
|
}
|
|
398
477
|
/**
|
|
399
|
-
* Run git
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
* The four containment floors are replicated by hand: cwd = the bound root,
|
|
409
|
-
* the hardcoded `-c` hardening prefix, and `sanitizedGitEnv()` (GIT_*
|
|
410
|
-
* stripped, global/system config neutralised). No shell — argv array,
|
|
411
|
-
* `shell:false` (spawn default). Output is chunk-concatenated as BYTES and
|
|
412
|
-
* decoded once (a per-chunk decode would corrupt multibyte content at chunk
|
|
413
|
-
* boundaries — `show` serves file content verbatim).
|
|
478
|
+
* Run ONE git command — every op of this class goes through here, argv only,
|
|
479
|
+
* never a shell. The four containment floors ride every call: cwd = the bound
|
|
480
|
+
* root, the hardcoded `-c` hardening prefix (in the flavour of the machine git
|
|
481
|
+
* runs on), and the controlled env (`sanitizedGitEnv()` locally; the
|
|
482
|
+
* `gitFloorEnv` constants over the broker's GIT-free base on a device). The
|
|
483
|
+
* output is bounded by `GIT_MAX_OUTPUT_BYTES` (→ `GitOutputLimitError`) and
|
|
484
|
+
* the runner's timeout (→ `GitTimeoutError`) on BOTH transports; callers that
|
|
485
|
+
* tolerate a nonzero exit (`git diff --no-index` exits 1 when the files differ)
|
|
486
|
+
* pass `allowExitCodes`.
|
|
414
487
|
*/
|
|
415
488
|
#spawnGit(args, opts = {}) {
|
|
416
|
-
const
|
|
417
|
-
|
|
489
|
+
const config = [...hardeningConfig(this.#nullDevice), ...noGlobalFilesConfig(this.#nullDevice)];
|
|
490
|
+
const fullArgs = [...config.flatMap((c) => ['-c', c]), ...args];
|
|
491
|
+
return this.#exec(fullArgs, {}, opts);
|
|
492
|
+
}
|
|
493
|
+
/** The transport fork: this machine's spawn, or the device's broker. */
|
|
494
|
+
#exec(fullArgs, extraEnv, opts) {
|
|
495
|
+
if (this.#device)
|
|
496
|
+
return this.#execDevice(this.#device, fullArgs, extraEnv, opts);
|
|
497
|
+
return this.#execSpawn(fullArgs, { ...sanitizedGitEnv(), ...extraEnv }, opts);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* The device half: `git <argv>` through the broker's ONE spawn planner
|
|
501
|
+
* (`/exec` argv mode) — the owner's ceiling, and for anyone but the device's
|
|
502
|
+
* owner a live sandbox plus the owner's per-folder "others" level
|
|
503
|
+
* (`actor:'other'`, derived by the client from `requireConfinement`).
|
|
504
|
+
*/
|
|
505
|
+
async #execDevice(seat, fullArgs, extraEnv, opts) {
|
|
506
|
+
let result;
|
|
507
|
+
// `ownerGlobalConfig` = the ONE read that may see the owner's own global and
|
|
508
|
+
// system config (`#ownerCredentialHelpers`, owner-only); every other call
|
|
509
|
+
// neutralises both.
|
|
510
|
+
const floor = gitFloorEnv(this.#nullDevice);
|
|
511
|
+
if (opts.ownerGlobalConfig) {
|
|
512
|
+
delete floor.GIT_CONFIG_GLOBAL;
|
|
513
|
+
delete floor.GIT_CONFIG_SYSTEM;
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
result = await seat.exec({
|
|
517
|
+
argv: ['git', ...fullArgs],
|
|
518
|
+
cwd: this.#root,
|
|
519
|
+
env: { ...floor, ...extraEnv },
|
|
520
|
+
...(opts.stdin !== undefined ? { stdin: opts.stdin } : {}),
|
|
521
|
+
...(this.#timeoutMs !== undefined ? { timeoutMs: this.#timeoutMs } : {}),
|
|
522
|
+
maxOutputBytes: GIT_MAX_OUTPUT_BYTES,
|
|
523
|
+
// `ownerRemote` = the owner's fetch / push / helper read: the device's
|
|
524
|
+
// broker runs exactly that fixed argv outside its sandbox (the owner's
|
|
525
|
+
// helpers live in their home and keychain); every other op stays confined.
|
|
526
|
+
...(opts.ownerRemote && seat.owner ? { ownerRemoteGit: true } : {}),
|
|
527
|
+
}, { owner: seat.owner });
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
throw new GitDeviceUnavailableError(deviceReasonOf(error));
|
|
531
|
+
}
|
|
532
|
+
if (result.timedOut)
|
|
533
|
+
throw new GitTimeoutError(this.#root);
|
|
534
|
+
if (result.truncated)
|
|
535
|
+
throw new GitOutputLimitError(this.#root);
|
|
536
|
+
const ok = result.exitCode === 0 || (opts.allowExitCodes?.includes(result.exitCode) ?? false);
|
|
537
|
+
if (ok)
|
|
538
|
+
return result.stdout;
|
|
539
|
+
const cmd = fullArgs.find((a, i) => fullArgs[i - 1] !== '-c' && !a.startsWith('-')) ?? '';
|
|
540
|
+
throw new GitApplyError(result.stderr.trim() || `git ${cmd} exited with code ${result.exitCode}`);
|
|
418
541
|
}
|
|
419
542
|
/**
|
|
420
|
-
* The
|
|
543
|
+
* The local spawn executor for BOTH the local direct-spawn path (`#spawnGit`)
|
|
421
544
|
* and the remote-op path (`#spawnGitRemote`): the timeout + output-accumulation
|
|
422
545
|
* bound + exit-code handling, over a caller-composed argv + env. Isolating it
|
|
423
546
|
* keeps the two callers from drifting on the containment plumbing while letting
|
|
424
|
-
* the remote path compose its OWN hardening `-c` set + credential env
|
|
425
|
-
* MUST NOT go through simple-git — J3).
|
|
547
|
+
* the remote path compose its OWN hardening `-c` set + credential env.
|
|
426
548
|
*/
|
|
427
549
|
#execSpawn(fullArgs, env, opts = {}) {
|
|
550
|
+
const bin = this.#bin;
|
|
551
|
+
if (bin === null)
|
|
552
|
+
return Promise.reject(new GitUnavailableError());
|
|
428
553
|
return new Promise((resolve, reject) => {
|
|
429
|
-
const child = spawn(
|
|
554
|
+
const child = spawn(bin, fullArgs, {
|
|
430
555
|
cwd: this.#root,
|
|
431
556
|
env,
|
|
432
557
|
stdio: [opts.stdin !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
@@ -490,15 +615,20 @@ export class GitRunner {
|
|
|
490
615
|
});
|
|
491
616
|
}
|
|
492
617
|
/**
|
|
493
|
-
* The REMOTE-op spawn variant (G4 keyring-kestrel Slice 2)
|
|
494
|
-
* `node:child_process
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
618
|
+
* The REMOTE-op spawn variant (G4 keyring-kestrel Slice 2) — this machine's
|
|
619
|
+
* raw `node:child_process`, or a paired device's broker for its OWNER only
|
|
620
|
+
* (`GitRemoteOwnerOnlyError` for anyone else). A server credential on a
|
|
621
|
+
* device refuses (`GitRemoteOnDeviceError`): the token never crosses a link.
|
|
622
|
+
* On a device the owner's own credential helpers (`ownerHelpers`, read from
|
|
623
|
+
* their global/system config) take the place of the constant helper, after
|
|
624
|
+
* the same empty reset; every other key below is identical on both. That op
|
|
625
|
+
* (and the helper read) is the one a SANDBOXED device's broker runs outside
|
|
626
|
+
* its sandbox, after judging it exactly this fixed argv
|
|
627
|
+
* (`host-broker/ownerRemoteGit.mjs`) — the owner's own trust: nothing an agent
|
|
628
|
+
* types reaches the argv or its config, and the keys below neutralise the
|
|
629
|
+
* repo-local exec vectors. The GAP-HTTP probe and the pull's merge stay confined.
|
|
500
630
|
*
|
|
501
|
-
* It prepends, IN ADDITION to `
|
|
631
|
+
* It prepends, IN ADDITION to `hardeningConfig(<null device>)`, the full protected-config-aware
|
|
502
632
|
* remote-hardening `-c` set (only 4 git config keys are protected; every other
|
|
503
633
|
* key is honored from a hostile repo-local `.git/config`, and `-c` = command
|
|
504
634
|
* scope wins over repo-local):
|
|
@@ -510,7 +640,8 @@ export class GitRunner {
|
|
|
510
640
|
* `core.askPass` is consulted BEFORE the `GIT_TERMINAL_PROMPT=0` gate).
|
|
511
641
|
* - `core.sshCommand=` / `core.gitProxy=` (RCE keys; HTTPS-only makes them moot,
|
|
512
642
|
* pinned anyway); `protocol.{file,ext,git}.allow=never` (a repo-local
|
|
513
|
-
* `protocol.ext.allow=always` self-enables `ext::` RCE).
|
|
643
|
+
* `protocol.ext.allow=always` self-enables `ext::` RCE); `push.gpgSign=false`
|
|
644
|
+
* (a repo-local signing program runs on push, the credential in its env).
|
|
514
645
|
* - `remote.<name>.{upload,receive}pack` pinned (the remote NAME is
|
|
515
646
|
* server-known, so these ARE pinnable) — belt with `protocol.file.allow=never`.
|
|
516
647
|
* NOT `url.*.insteadOf`/`pushInsteadOf` (un-`-c`-clearable — closed by the
|
|
@@ -524,10 +655,25 @@ export class GitRunner {
|
|
|
524
655
|
* (fetch/push). `mergeFetched` calls this with NO credential (S4).
|
|
525
656
|
*/
|
|
526
657
|
#spawnGitRemote(args, opts = {}) {
|
|
527
|
-
|
|
658
|
+
if (this.#device) {
|
|
659
|
+
if (opts.credential)
|
|
660
|
+
return Promise.reject(new GitRemoteOnDeviceError());
|
|
661
|
+
if (!this.#device.owner)
|
|
662
|
+
return Promise.reject(new GitRemoteOwnerOnlyError());
|
|
663
|
+
}
|
|
664
|
+
else if (opts.ownerHelpers) {
|
|
665
|
+
return Promise.reject(new GitArgumentError('owner credential helpers are a device-only input'));
|
|
666
|
+
}
|
|
667
|
+
const hardening = hardeningConfig(this.#nullDevice);
|
|
668
|
+
if (opts.ownerHelpers === undefined)
|
|
669
|
+
hardening.push(...noGlobalFilesConfig(this.#nullDevice));
|
|
528
670
|
hardening.push('core.askPass=', 'credential.interactive=false'); // S3
|
|
529
671
|
hardening.push('core.sshCommand=', 'core.gitProxy=');
|
|
530
672
|
hardening.push('protocol.file.allow=never', 'protocol.ext.allow=never', 'protocol.git.allow=never');
|
|
673
|
+
// A repo-local `push.gpgSign` + `gpg.program` runs a program of the repo's
|
|
674
|
+
// choosing on push (measured, git 2.43.0, against a receiver that asks for
|
|
675
|
+
// a push certificate) — with the push credential in its environment.
|
|
676
|
+
hardening.push('push.gpgSign=false');
|
|
531
677
|
if (opts.remoteName !== undefined) {
|
|
532
678
|
const remote = _a.#guardRemoteName(opts.remoteName);
|
|
533
679
|
hardening.push(`remote.${remote}.uploadpack=git-upload-pack`, `remote.${remote}.receivepack=git-receive-pack`);
|
|
@@ -556,8 +702,18 @@ export class GitRunner {
|
|
|
556
702
|
env.NRS_GIT_USER = opts.credential.username;
|
|
557
703
|
env.NRS_GIT_TOKEN = opts.credential.token;
|
|
558
704
|
}
|
|
705
|
+
if (opts.ownerHelpers) {
|
|
706
|
+
// S1 on the device: the same empty reset FIRST (a hostile repo-local helper
|
|
707
|
+
// never runs and never receives the owner's credential), then the owner's own.
|
|
708
|
+
hardening.push('credential.helper=', ...opts.ownerHelpers);
|
|
709
|
+
}
|
|
559
710
|
const fullArgs = [...hardening.flatMap((c) => ['-c', c]), ...args];
|
|
560
|
-
|
|
711
|
+
const exit = opts.allowExitCodes ? { allowExitCodes: opts.allowExitCodes } : {};
|
|
712
|
+
// The owner's own fetch / push (it alone carries their helpers) is the op a
|
|
713
|
+
// sandboxed device runs unconfined; the GAP-HTTP probe and the pull's merge stay confined.
|
|
714
|
+
if (this.#device)
|
|
715
|
+
return this.#execDevice(this.#device, fullArgs, {}, { ...exit, ownerRemote: opts.ownerHelpers !== undefined });
|
|
716
|
+
return this.#execSpawn(fullArgs, env, exit);
|
|
561
717
|
}
|
|
562
718
|
// --- Containment guards (floors 1 & 2) ----------------------------------
|
|
563
719
|
/** Reject a caller ref that could be parsed as an option or carries a NUL. */
|
|
@@ -594,12 +750,24 @@ export class GitRunner {
|
|
|
594
750
|
if (p.startsWith('-')) {
|
|
595
751
|
throw new GitArgumentError(`git path may not begin with '-': "${p}"`);
|
|
596
752
|
}
|
|
597
|
-
const resolved = path.resolve(this.#root, p);
|
|
598
|
-
if (
|
|
753
|
+
const resolved = this.#path.resolve(this.#root, p);
|
|
754
|
+
if (!this.#within(this.#root, resolved)) {
|
|
599
755
|
throw new GitOutsideSourceError(this.#root, resolved);
|
|
600
756
|
}
|
|
601
757
|
return p; // pass the ORIGINAL token to git (cwd is already the root)
|
|
602
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* `child` at or below `parent` in the checkout machine's own path arithmetic —
|
|
761
|
+
* a Windows device compares case-insensitively, and git answers there with
|
|
762
|
+
* forward slashes (`C:/x`), which `win32.resolve` normalizes.
|
|
763
|
+
*/
|
|
764
|
+
#within(parent, child) {
|
|
765
|
+
const p = this.#path;
|
|
766
|
+
const fold = (s) => (p.sep === '\\' ? p.resolve(s).toLowerCase() : p.resolve(s));
|
|
767
|
+
const a = fold(parent);
|
|
768
|
+
const b = fold(child);
|
|
769
|
+
return a === b || b.startsWith(a.endsWith(p.sep) ? a : a + p.sep);
|
|
770
|
+
}
|
|
603
771
|
/**
|
|
604
772
|
* Verify the repo's toplevel is at or below the source root — a `.git`
|
|
605
773
|
* ABOVE the source (source is a subdir of a bigger repo) is rejected so git
|
|
@@ -617,11 +785,14 @@ export class GitRunner {
|
|
|
617
785
|
// realpath check on the `.git` entry's own location — is caught here.
|
|
618
786
|
let lines;
|
|
619
787
|
try {
|
|
620
|
-
lines = (await this.#
|
|
788
|
+
lines = (await this.#spawnGit(['rev-parse', '--show-toplevel', '--absolute-git-dir']))
|
|
621
789
|
.trim()
|
|
622
|
-
.split(
|
|
790
|
+
.split(/\r?\n/);
|
|
623
791
|
}
|
|
624
|
-
catch {
|
|
792
|
+
catch (error) {
|
|
793
|
+
// "Could not ask the device" is not "not a repository".
|
|
794
|
+
if (error instanceof GitDeviceUnavailableError)
|
|
795
|
+
throw error;
|
|
625
796
|
throw new GitNotARepoError(this.#root);
|
|
626
797
|
}
|
|
627
798
|
const toplevel = (lines[0] ?? '').trim();
|
|
@@ -634,8 +805,8 @@ export class GitRunner {
|
|
|
634
805
|
// (bind-mount REAL paths, no symlink component) — floor 1b below is what
|
|
635
806
|
// makes containment symlink-robust, canonicalizing the root for the
|
|
636
807
|
// git-dir clamp (`--absolute-git-dir` is itself git-canonicalized).
|
|
637
|
-
const realTop = path.resolve(toplevel);
|
|
638
|
-
if (
|
|
808
|
+
const realTop = this.#path.resolve(toplevel);
|
|
809
|
+
if (!this.#within(this.#root, realTop)) {
|
|
639
810
|
throw new GitOutsideSourceError(this.#root, realTop);
|
|
640
811
|
}
|
|
641
812
|
// Floor 1b (G2.5) — the EFFECTIVE git-dir at/below the source root.
|
|
@@ -644,9 +815,12 @@ export class GitRunner {
|
|
|
644
815
|
// legitimately symlinked mount path (the same reason discovery realpaths
|
|
645
816
|
// its root) would false-reject a normal `<root>/.git`. Fail-closed: a
|
|
646
817
|
// redirect OUTSIDE the canonical root throws (readers degrade to null).
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
818
|
+
// On a device this process cannot `realpath` the root: it is compared as
|
|
819
|
+
// stored, so a device root spelled through a symlink fails CLOSED (no git
|
|
820
|
+
// there), never open.
|
|
821
|
+
const realGitDir = this.#path.resolve(gitDir);
|
|
822
|
+
const canonRoot = this.#device ? this.#root : await realpath(this.#root).catch(() => this.#root);
|
|
823
|
+
if (!this.#within(canonRoot, realGitDir)) {
|
|
650
824
|
throw new GitOutsideSourceError(this.#root, realGitDir);
|
|
651
825
|
}
|
|
652
826
|
})();
|
|
@@ -668,20 +842,11 @@ export class GitRunner {
|
|
|
668
842
|
*/
|
|
669
843
|
async status(subPath) {
|
|
670
844
|
await this.#assertRepoInsideSource();
|
|
671
|
-
const args = ['--untracked-files=all'];
|
|
845
|
+
const args = ['status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z'];
|
|
672
846
|
if (subPath !== undefined && subPath !== '') {
|
|
673
847
|
args.push('--', this.#guardPath(subPath));
|
|
674
848
|
}
|
|
675
|
-
|
|
676
|
-
try {
|
|
677
|
-
result = await this.#git.status(args);
|
|
678
|
-
}
|
|
679
|
-
catch (err) {
|
|
680
|
-
if (isGitTimeout(err))
|
|
681
|
-
throw new GitTimeoutError(this.#root);
|
|
682
|
-
throw err;
|
|
683
|
-
}
|
|
684
|
-
return normalizeStatus(result);
|
|
849
|
+
return parseStatusPorcelainV2(await this.#spawnGit(args));
|
|
685
850
|
}
|
|
686
851
|
/**
|
|
687
852
|
* Diff WT↔Index (default), Index↔HEAD (`staged`), or ref↔ref. Paths (if any)
|
|
@@ -814,7 +979,7 @@ export class GitRunner {
|
|
|
814
979
|
async isUntracked(p) {
|
|
815
980
|
await this.#assertRepoInsideSource();
|
|
816
981
|
const safe = this.#guardPath(p);
|
|
817
|
-
const out = await this.#
|
|
982
|
+
const out = await this.#spawnGit(['ls-files', '--', safe]);
|
|
818
983
|
return out.trim().length === 0;
|
|
819
984
|
}
|
|
820
985
|
/**
|
|
@@ -831,19 +996,23 @@ export class GitRunner {
|
|
|
831
996
|
}
|
|
832
997
|
async log(opts = {}) {
|
|
833
998
|
await this.#assertRepoInsideSource();
|
|
834
|
-
const
|
|
999
|
+
const args = ['log', `--pretty=format:%H${GIT_LOG_FS}%ai${GIT_LOG_FS}%s${GIT_LOG_FS}%an${GIT_LOG_FS}%ae%x00`];
|
|
835
1000
|
if (opts.maxCount && opts.maxCount > 0)
|
|
836
|
-
|
|
1001
|
+
args.push(`--max-count=${Math.floor(opts.maxCount)}`);
|
|
837
1002
|
if (opts.ref)
|
|
838
|
-
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
email
|
|
846
|
-
|
|
1003
|
+
args.push(_a.#guardRef(opts.ref));
|
|
1004
|
+
const out = await this.#spawnGit(args);
|
|
1005
|
+
const entries = [];
|
|
1006
|
+
for (const raw of out.split('\x00')) {
|
|
1007
|
+
const record = raw.replace(/^\n+/, '');
|
|
1008
|
+
if (!record)
|
|
1009
|
+
continue;
|
|
1010
|
+
const [hash, date, message, author, email] = record.split(GIT_LOG_FS);
|
|
1011
|
+
if (!hash)
|
|
1012
|
+
continue;
|
|
1013
|
+
entries.push({ hash, date: date ?? '', message: message ?? '', author: author ?? '', email: email ?? '' });
|
|
1014
|
+
}
|
|
1015
|
+
return entries;
|
|
847
1016
|
}
|
|
848
1017
|
/**
|
|
849
1018
|
* The commit DAG for the custom SVG commit graph (G2.3 / `GET /git/log`), WITH
|
|
@@ -987,7 +1156,7 @@ export class GitRunner {
|
|
|
987
1156
|
await this.#assertRepoInsideSource();
|
|
988
1157
|
let headSha;
|
|
989
1158
|
try {
|
|
990
|
-
headSha = (await this.#
|
|
1159
|
+
headSha = (await this.#spawnGit(['rev-parse', '--verify', '--quiet', 'HEAD'])).trim();
|
|
991
1160
|
}
|
|
992
1161
|
catch {
|
|
993
1162
|
return null;
|
|
@@ -996,7 +1165,7 @@ export class GitRunner {
|
|
|
996
1165
|
return null;
|
|
997
1166
|
let upstreamSha = null;
|
|
998
1167
|
try {
|
|
999
|
-
const up = (await this.#
|
|
1168
|
+
const up = (await this.#spawnGit(['rev-parse', '--verify', '--quiet', '@{u}'])).trim();
|
|
1000
1169
|
upstreamSha = up || null;
|
|
1001
1170
|
}
|
|
1002
1171
|
catch {
|
|
@@ -1066,12 +1235,12 @@ export class GitRunner {
|
|
|
1066
1235
|
}
|
|
1067
1236
|
async branchList() {
|
|
1068
1237
|
await this.#assertRepoInsideSource();
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1238
|
+
const out = await this.#spawnGit([
|
|
1239
|
+
'for-each-ref',
|
|
1240
|
+
`--format=%(HEAD)${GIT_LOG_FS}%(refname:short)${GIT_LOG_FS}%(objectname:short)`,
|
|
1241
|
+
'refs/heads',
|
|
1242
|
+
]);
|
|
1243
|
+
return parseBranchList(out);
|
|
1075
1244
|
}
|
|
1076
1245
|
/**
|
|
1077
1246
|
* The current branch name — CHEAP: `git rev-parse --abbrev-ref HEAD` reads
|
|
@@ -1084,7 +1253,7 @@ export class GitRunner {
|
|
|
1084
1253
|
async currentBranch() {
|
|
1085
1254
|
await this.#assertRepoInsideSource();
|
|
1086
1255
|
try {
|
|
1087
|
-
const out = (await this.#
|
|
1256
|
+
const out = (await this.#spawnGit(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
1088
1257
|
if (!out || out === 'HEAD')
|
|
1089
1258
|
return null;
|
|
1090
1259
|
return out;
|
|
@@ -1099,7 +1268,7 @@ export class GitRunner {
|
|
|
1099
1268
|
async add(paths) {
|
|
1100
1269
|
await this.#assertRepoInsideSource();
|
|
1101
1270
|
const safe = paths.map((p) => this.#guardPath(p));
|
|
1102
|
-
await this.#
|
|
1271
|
+
await this.#spawnGit(['add', '--', ...safe]);
|
|
1103
1272
|
}
|
|
1104
1273
|
/**
|
|
1105
1274
|
* Reset the index. Whole-tree mode/ref reset (`git reset [--mode] [ref]`) OR —
|
|
@@ -1111,7 +1280,7 @@ export class GitRunner {
|
|
|
1111
1280
|
await this.#assertRepoInsideSource();
|
|
1112
1281
|
if (opts.paths?.length) {
|
|
1113
1282
|
const safe = opts.paths.map((p) => this.#guardPath(p));
|
|
1114
|
-
await this.#
|
|
1283
|
+
await this.#spawnGit(['reset', '-q', '--', ...safe]);
|
|
1115
1284
|
return;
|
|
1116
1285
|
}
|
|
1117
1286
|
const args = [];
|
|
@@ -1119,14 +1288,14 @@ export class GitRunner {
|
|
|
1119
1288
|
args.push(`--${opts.mode}`);
|
|
1120
1289
|
if (opts.ref)
|
|
1121
1290
|
args.push(_a.#guardRef(opts.ref));
|
|
1122
|
-
await this.#
|
|
1291
|
+
await this.#spawnGit(['reset', ...args]);
|
|
1123
1292
|
}
|
|
1124
1293
|
async checkout(ref, paths) {
|
|
1125
1294
|
await this.#assertRepoInsideSource();
|
|
1126
1295
|
const args = [_a.#guardRef(ref)];
|
|
1127
1296
|
if (paths?.length)
|
|
1128
1297
|
args.push('--', ...paths.map((p) => this.#guardPath(p)));
|
|
1129
|
-
await this.#
|
|
1298
|
+
await this.#spawnGit(['checkout', ...args]);
|
|
1130
1299
|
}
|
|
1131
1300
|
/**
|
|
1132
1301
|
* Discard working-tree changes for `paths` by restoring them from the index
|
|
@@ -1140,15 +1309,15 @@ export class GitRunner {
|
|
|
1140
1309
|
if (!paths.length)
|
|
1141
1310
|
throw new GitArgumentError('restore requires at least one path');
|
|
1142
1311
|
const safe = paths.map((p) => this.#guardPath(p));
|
|
1143
|
-
await this.#
|
|
1312
|
+
await this.#spawnGit(['checkout', '-q', '--', ...safe]);
|
|
1144
1313
|
}
|
|
1145
1314
|
async commit(message, opts = {}) {
|
|
1146
1315
|
await this.#assertRepoInsideSource();
|
|
1147
1316
|
// `-m` consumes the message as its value → a leading-dash message is safe.
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
return
|
|
1317
|
+
if (typeof message !== 'string')
|
|
1318
|
+
throw new GitArgumentError('commit message must be a string');
|
|
1319
|
+
await this.#spawnGit(['commit', '-q', '-m', message, ...(opts.amend ? ['--amend'] : [])]);
|
|
1320
|
+
return (await this.#spawnGit(['rev-parse', '--short', 'HEAD'])).trim();
|
|
1152
1321
|
}
|
|
1153
1322
|
/**
|
|
1154
1323
|
* Apply a unified-diff PATCH fed on STDIN (`git apply`) — the G2.3 whole-hunk
|
|
@@ -1188,9 +1357,9 @@ export class GitRunner {
|
|
|
1188
1357
|
let sha = null;
|
|
1189
1358
|
try {
|
|
1190
1359
|
const target = opts.ref ? _a.#guardRef(opts.ref) : 'HEAD';
|
|
1191
|
-
const resolved = (await this.#
|
|
1360
|
+
const resolved = (await this.#spawnGit(['rev-parse', '--verify', '--quiet', target])).trim();
|
|
1192
1361
|
if (resolved) {
|
|
1193
|
-
await this.#
|
|
1362
|
+
await this.#spawnGit(['update-ref', ref, resolved]);
|
|
1194
1363
|
sha = resolved;
|
|
1195
1364
|
}
|
|
1196
1365
|
}
|
|
@@ -1200,9 +1369,9 @@ export class GitRunner {
|
|
|
1200
1369
|
// 2. Capture working-tree+index content WITHOUT mutating the tree (discard recovery).
|
|
1201
1370
|
let stash = null;
|
|
1202
1371
|
try {
|
|
1203
|
-
const created = (await this.#
|
|
1372
|
+
const created = (await this.#spawnGit(['stash', 'create', `nrs-rescue/${safe}`])).trim();
|
|
1204
1373
|
if (created) {
|
|
1205
|
-
await this.#
|
|
1374
|
+
await this.#spawnGit(['stash', 'store', '-m', `nrs-rescue/${safe}`, created]);
|
|
1206
1375
|
stash = created;
|
|
1207
1376
|
}
|
|
1208
1377
|
}
|
|
@@ -1227,7 +1396,7 @@ export class GitRunner {
|
|
|
1227
1396
|
*/
|
|
1228
1397
|
async restoreBranch(name, ref) {
|
|
1229
1398
|
await this.#assertRepoInsideSource();
|
|
1230
|
-
await this.#
|
|
1399
|
+
await this.#spawnGit(['branch', _a.#guardRef(name), _a.#guardRef(ref)]);
|
|
1231
1400
|
}
|
|
1232
1401
|
/**
|
|
1233
1402
|
* Re-materialize working-tree content discarded by `discard`, from its rescue
|
|
@@ -1237,23 +1406,23 @@ export class GitRunner {
|
|
|
1237
1406
|
*/
|
|
1238
1407
|
async restoreStash(stashRef) {
|
|
1239
1408
|
await this.#assertRepoInsideSource();
|
|
1240
|
-
await this.#
|
|
1409
|
+
await this.#spawnGit(['stash', 'apply', _a.#guardRef(stashRef)]);
|
|
1241
1410
|
}
|
|
1242
1411
|
async stash(args = ['push']) {
|
|
1243
1412
|
await this.#assertRepoInsideSource();
|
|
1244
1413
|
// Only the leading subcommand is caller-influenceable in G3; refs guarded there.
|
|
1245
|
-
await this.#
|
|
1414
|
+
await this.#spawnGit(['stash', ...args]);
|
|
1246
1415
|
}
|
|
1247
1416
|
async branchCreate(name, startPoint) {
|
|
1248
1417
|
await this.#assertRepoInsideSource();
|
|
1249
1418
|
const args = [_a.#guardRef(name)];
|
|
1250
1419
|
if (startPoint)
|
|
1251
1420
|
args.push(_a.#guardRef(startPoint));
|
|
1252
|
-
await this.#
|
|
1421
|
+
await this.#spawnGit(['branch', ...args]);
|
|
1253
1422
|
}
|
|
1254
1423
|
async branchDelete(name, force = false) {
|
|
1255
1424
|
await this.#assertRepoInsideSource();
|
|
1256
|
-
await this.#
|
|
1425
|
+
await this.#spawnGit(['branch', force ? '-D' : '-d', _a.#guardRef(name)]);
|
|
1257
1426
|
}
|
|
1258
1427
|
// --- Remote primitives (G4 keyring-kestrel Slice 2) ----------------------
|
|
1259
1428
|
// fetch / push / pull, each through `#spawnGitRemote` (raw child_process,
|
|
@@ -1357,8 +1526,12 @@ export class GitRunner {
|
|
|
1357
1526
|
}
|
|
1358
1527
|
}
|
|
1359
1528
|
async fetch(opts = {}) {
|
|
1360
|
-
await this.#assertRepoInsideSource();
|
|
1361
1529
|
const remote = opts.remote ?? 'origin';
|
|
1530
|
+
if (this.#device) {
|
|
1531
|
+
await this.#deviceOwnerRemote(['fetch', _a.#guardRemoteName(remote)], remote, false, opts.credential);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
await this.#assertRepoInsideSource();
|
|
1362
1535
|
if (opts.credential)
|
|
1363
1536
|
await this.#assertSafeRemoteHttpConfig(remote, opts.credential.host, false);
|
|
1364
1537
|
await this.#spawnGitRemote(['fetch', _a.#guardRemoteName(remote)], {
|
|
@@ -1366,14 +1539,78 @@ export class GitRunner {
|
|
|
1366
1539
|
...(opts.credential ? { credential: opts.credential } : {}),
|
|
1367
1540
|
});
|
|
1368
1541
|
}
|
|
1542
|
+
/**
|
|
1543
|
+
* fetch / push on a paired device's folder: its OWNER only, with the owner's
|
|
1544
|
+
* own credential helpers, the same hardening `-c` set, the same HTTPS-only
|
|
1545
|
+
* remote and the same GAP-HTTP probe as this machine's ops. A server
|
|
1546
|
+
* credential is refused before anything runs; a non-owner is refused before
|
|
1547
|
+
* the owner's config is read.
|
|
1548
|
+
*/
|
|
1549
|
+
async #deviceOwnerRemote(args, remote, push, credential) {
|
|
1550
|
+
if (credential)
|
|
1551
|
+
throw new GitRemoteOnDeviceError();
|
|
1552
|
+
if (!this.#device?.owner)
|
|
1553
|
+
throw new GitRemoteOwnerOnlyError();
|
|
1554
|
+
const host = await this.remoteHost(remote, { push });
|
|
1555
|
+
if (!host)
|
|
1556
|
+
throw new GitArgumentError('a device remote op needs an HTTPS remote');
|
|
1557
|
+
await this.#assertSafeRemoteHttpConfig(remote, host, push);
|
|
1558
|
+
const ownerHelpers = await this.#ownerCredentialHelpers();
|
|
1559
|
+
await this.#spawnGitRemote(args, { remoteName: remote, httpHost: host, ownerHelpers });
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* The device owner's own credential helpers — the ONE place the global and
|
|
1563
|
+
* system config neutralisation is lifted, and only for the
|
|
1564
|
+
* `credential[.<url>].helper` keys of those two scopes (`--system`, then
|
|
1565
|
+
* `--global`: git's own order). Every other key of the owner's config stays
|
|
1566
|
+
* unread; the op itself runs with both neutralised. The read is `-z` (key,
|
|
1567
|
+
* newline, value, NUL), so a value carrying a newline is refused WHOLE — a
|
|
1568
|
+
* line read would carry its first line as a truncated helper. A device that
|
|
1569
|
+
* cannot run the read refuses the op (`GitDeviceUnavailableError`); any other
|
|
1570
|
+
* failure of one scope carries nothing from it.
|
|
1571
|
+
*/
|
|
1572
|
+
async #ownerCredentialHelpers() {
|
|
1573
|
+
const seat = this.#device;
|
|
1574
|
+
if (!seat?.owner)
|
|
1575
|
+
throw new GitRemoteOwnerOnlyError();
|
|
1576
|
+
const helpers = [];
|
|
1577
|
+
for (const scope of ['--system', '--global']) {
|
|
1578
|
+
let out = '';
|
|
1579
|
+
try {
|
|
1580
|
+
out = await this.#execDevice(seat, [...hardeningConfig(this.#nullDevice).flatMap((c) => ['-c', c]), 'config', scope, '-z', '--get-regexp', '^credential(\\..+)?\\.helper$'], {}, { allowExitCodes: [1], ownerGlobalConfig: true, ownerRemote: true });
|
|
1581
|
+
}
|
|
1582
|
+
catch (error) {
|
|
1583
|
+
if (error instanceof GitDeviceUnavailableError)
|
|
1584
|
+
throw error;
|
|
1585
|
+
continue; // no such scope on this machine — nothing to carry
|
|
1586
|
+
}
|
|
1587
|
+
for (const entry of out.split('\0')) {
|
|
1588
|
+
const nl = entry.indexOf('\n');
|
|
1589
|
+
if (nl <= 0)
|
|
1590
|
+
continue;
|
|
1591
|
+
const key = entry.slice(0, nl);
|
|
1592
|
+
const value = entry.slice(nl + 1);
|
|
1593
|
+
if (!OWNER_HELPER_KEY_RE.test(key) || /[\0\r\n]/.test(value))
|
|
1594
|
+
continue;
|
|
1595
|
+
helpers.push(`${key}=${value}`);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
return helpers;
|
|
1599
|
+
}
|
|
1369
1600
|
/**
|
|
1370
1601
|
* Push the CURRENT branch to `remote` (`git push <remote> HEAD` — no refspec
|
|
1371
1602
|
* from the caller, no `--force`/`--force-with-lease`, D6). The token rides the
|
|
1372
1603
|
* child env (network-only invocation). `remote` defaults to `origin`, guarded.
|
|
1373
1604
|
*/
|
|
1374
1605
|
async push(opts) {
|
|
1375
|
-
await this.#assertRepoInsideSource();
|
|
1376
1606
|
const remote = opts.remote ?? 'origin';
|
|
1607
|
+
if (this.#device) {
|
|
1608
|
+
await this.#deviceOwnerRemote(['push', _a.#guardRemoteName(remote), 'HEAD'], remote, true, opts.credential);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
if (!opts.credential)
|
|
1612
|
+
throw new GitArgumentError('a push needs the connected remote credential');
|
|
1613
|
+
await this.#assertRepoInsideSource();
|
|
1377
1614
|
await this.#assertSafeRemoteHttpConfig(remote, opts.credential.host, true);
|
|
1378
1615
|
await this.#spawnGitRemote(['push', _a.#guardRemoteName(remote), 'HEAD'], {
|
|
1379
1616
|
remoteName: remote,
|
|
@@ -1398,6 +1635,8 @@ export class GitRunner {
|
|
|
1398
1635
|
* spawning a merge-driver / creating a merge commit silently.
|
|
1399
1636
|
*/
|
|
1400
1637
|
async mergeFetched() {
|
|
1638
|
+
if (this.#device && !this.#device.owner)
|
|
1639
|
+
throw new GitRemoteOwnerOnlyError();
|
|
1401
1640
|
await this.#assertRepoInsideSource();
|
|
1402
1641
|
await this.#spawnGitRemote(['merge', '--ff-only', 'FETCH_HEAD']);
|
|
1403
1642
|
}
|
|
@@ -1519,28 +1758,101 @@ export function parseHttpsRemoteHost(url) {
|
|
|
1519
1758
|
}
|
|
1520
1759
|
/** A syntactically-plausible remote host (defense-in-depth on the parse). */
|
|
1521
1760
|
const GIT_REMOTE_HOST_RE = /^[a-z0-9.-]{1,255}$/;
|
|
1522
|
-
/**
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1761
|
+
/** `.` (unmodified) in a porcelain-v2 XY pair reads as the v1 blank. */
|
|
1762
|
+
function xyChar(c) {
|
|
1763
|
+
return !c || c === '.' ? ' ' : c;
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Parse `git status --porcelain=v2 --branch --untracked-files=all -z` into our
|
|
1767
|
+
* staged/unstaged-explicit shape. Pure — unit-tested standalone, and the ONE
|
|
1768
|
+
* status reader on both transports.
|
|
1769
|
+
*
|
|
1770
|
+
* The `-z` stream is NUL-separated records: `# branch.head|upstream|ab` headers,
|
|
1771
|
+
* `1 XY sub mH mI mW hH hI <path>` (ordinary), `2 XY … X<score> <path>` FOLLOWED
|
|
1772
|
+
* BY one more record, the ORIGINAL path (rename/copy — the row carries the NEW
|
|
1773
|
+
* path, as before), `u XY sub m1 m2 m3 mW h1 h2 h3 <path>` (unmerged), `? <path>`
|
|
1774
|
+
* (untracked) and `! <path>` (ignored — never listed). A path may contain
|
|
1775
|
+
* spaces, so the fixed fields are taken by position and the rest is the path.
|
|
1776
|
+
* `branch` is `null` for a detached HEAD; `upstream`/`ahead`/`behind` only when
|
|
1777
|
+
* git reported an upstream.
|
|
1778
|
+
*/
|
|
1779
|
+
export function parseStatusPorcelainV2(out) {
|
|
1780
|
+
let branch = null;
|
|
1781
|
+
let upstream = null;
|
|
1782
|
+
let ahead = 0;
|
|
1783
|
+
let behind = 0;
|
|
1784
|
+
const entries = [];
|
|
1785
|
+
const records = out.split('\0');
|
|
1786
|
+
const push = (xy, filePath) => {
|
|
1787
|
+
const index = xyChar(xy[0]);
|
|
1788
|
+
const workingTree = xyChar(xy[1]);
|
|
1789
|
+
entries.push({
|
|
1790
|
+
path: filePath,
|
|
1530
1791
|
index,
|
|
1531
1792
|
workingTree,
|
|
1532
|
-
staged:
|
|
1533
|
-
unstaged:
|
|
1534
|
-
untracked,
|
|
1535
|
-
};
|
|
1536
|
-
}
|
|
1793
|
+
staged: index !== ' ',
|
|
1794
|
+
unstaged: workingTree !== ' ',
|
|
1795
|
+
untracked: false,
|
|
1796
|
+
});
|
|
1797
|
+
};
|
|
1798
|
+
for (let i = 0; i < records.length; i++) {
|
|
1799
|
+
const record = records[i];
|
|
1800
|
+
if (!record)
|
|
1801
|
+
continue;
|
|
1802
|
+
if (record.startsWith('# ')) {
|
|
1803
|
+
const [key, ...rest] = record.slice(2).split(' ');
|
|
1804
|
+
const value = rest.join(' ');
|
|
1805
|
+
if (key === 'branch.head')
|
|
1806
|
+
branch = value === '(detached)' ? null : value;
|
|
1807
|
+
else if (key === 'branch.upstream')
|
|
1808
|
+
upstream = value;
|
|
1809
|
+
else if (key === 'branch.ab') {
|
|
1810
|
+
const m = /^\+(\d+) -(\d+)$/.exec(value);
|
|
1811
|
+
if (m) {
|
|
1812
|
+
ahead = Number(m[1]);
|
|
1813
|
+
behind = Number(m[2]);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
continue;
|
|
1817
|
+
}
|
|
1818
|
+
const kind = record[0];
|
|
1819
|
+
const fields = record.split(' ');
|
|
1820
|
+
if (kind === '1')
|
|
1821
|
+
push(fields[1] ?? '..', fields.slice(8).join(' '));
|
|
1822
|
+
else if (kind === '2') {
|
|
1823
|
+
push(fields[1] ?? '..', fields.slice(9).join(' '));
|
|
1824
|
+
i += 1; // the next record is the rename/copy SOURCE path
|
|
1825
|
+
}
|
|
1826
|
+
else if (kind === 'u')
|
|
1827
|
+
push(fields[1] ?? '..', fields.slice(10).join(' '));
|
|
1828
|
+
else if (kind === '?') {
|
|
1829
|
+
entries.push({ path: record.slice(2), index: '?', workingTree: '?', staged: false, unstaged: false, untracked: true });
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1537
1832
|
return {
|
|
1538
|
-
branch
|
|
1539
|
-
upstream
|
|
1540
|
-
ahead:
|
|
1541
|
-
behind:
|
|
1833
|
+
branch,
|
|
1834
|
+
upstream,
|
|
1835
|
+
ahead: upstream ? ahead : 0,
|
|
1836
|
+
behind: upstream ? behind : 0,
|
|
1542
1837
|
entries,
|
|
1543
1838
|
clean: entries.length === 0,
|
|
1544
1839
|
};
|
|
1545
1840
|
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Parse the `for-each-ref --format=%(HEAD)<US>%(refname:short)<US>%(objectname:short)`
|
|
1843
|
+
* listing of `refs/heads` (one branch per line; `*` marks the checked-out one).
|
|
1844
|
+
* Pure — unit-tested standalone.
|
|
1845
|
+
*/
|
|
1846
|
+
export function parseBranchList(out) {
|
|
1847
|
+
const branches = [];
|
|
1848
|
+
for (const line of out.split(/\r?\n/)) {
|
|
1849
|
+
if (!line)
|
|
1850
|
+
continue;
|
|
1851
|
+
const [head, name, commit] = line.split(GIT_LOG_FS);
|
|
1852
|
+
if (!name)
|
|
1853
|
+
continue;
|
|
1854
|
+
branches.push({ name, current: head === '*', commit: commit ?? '' });
|
|
1855
|
+
}
|
|
1856
|
+
return branches;
|
|
1857
|
+
}
|
|
1546
1858
|
//# sourceMappingURL=GitRunner.js.map
|