diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml new file mode 100644 index 0000000000..ee913d24c7 --- /dev/null +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -0,0 +1,112 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Gitoxide helper admission + +on: + pull_request: + paths: + - '.github/workflows/gitoxide-helper-admission.yml' + - 'native/gitoxide-helper/**' + - 'packages/runtime-host/src/server/gitoxide-helper-*.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-helper-*.test.ts' + - 'packages/runtime-host/src/server/gitoxide-managed-inspection.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts' + - 'packages/runtime-host/src/server/gitoxide-managed-*.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-managed-*.test.ts' + - 'packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts' + - 'packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts' + - 'scripts/prepare-gitoxide-helper*' + - 'scripts/generate-gitoxide-cargo-notices.mjs' + - 'apps/desktop/electron-builder.config.mjs' + - 'docs/architecture/gitoxide-*.md' + push: + branches: + - main + paths: + - '.github/workflows/gitoxide-helper-admission.yml' + - 'native/gitoxide-helper/**' + - 'packages/runtime-host/src/server/gitoxide-helper-*.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-helper-*.test.ts' + - 'packages/runtime-host/src/server/gitoxide-managed-inspection.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts' + - 'packages/runtime-host/src/server/gitoxide-managed-*.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-managed-*.test.ts' + - 'packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts' + - 'packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts' + - 'scripts/prepare-gitoxide-helper*' + - 'scripts/generate-gitoxide-cargo-notices.mjs' + - 'apps/desktop/electron-builder.config.mjs' + - 'docs/architecture/gitoxide-*.md' + +permissions: + contents: read + +concurrency: + group: gitoxide-helper-admission-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.19.0 + cache: npm + - name: Check Rust formatting + working-directory: native/gitoxide-helper + run: cargo fmt --check + - name: Test the short-lived Gitoxide helper + working-directory: native/gitoxide-helper + run: cargo test --locked + - name: Build the release helper + run: npm run build:gitoxide-helper + - name: Install JavaScript dependencies without packaging hooks + run: npm ci --ignore-scripts + - name: Prepare and license the packaged helper + run: >- + npm run prepare:gitoxide-helper && + npm run generate:gitoxide-cargo-notices && + node --test scripts/prepare-gitoxide-helper.test.mjs + - name: Build the helper invocation owner + run: >- + npm --workspace @maka/core run build && + npm --workspace @maka/storage run build && + npm --workspace @maka/runtime run build && + npm --workspace @maka/runtime-host run build + - name: Test the real helper invocation contract + env: + MAKA_GITOXIDE_HELPER_PATH: ${{ github.workspace }}/native/gitoxide-helper/target/debug/maka-gitoxide-helper${{ runner.os == 'Windows' && '.exe' || '' }} + run: >- + node --test + packages/runtime-host/dist/__tests__/gitoxide-helper-artifact-authority-internal.test.js + packages/runtime-host/dist/__tests__/gitoxide-helper-invocation-internal.test.js + packages/runtime-host/dist/__tests__/gitoxide-helper-mutation-candidate-authority-internal.test.js + packages/runtime-host/dist/__tests__/gitoxide-repository-admission-authority-internal.test.js + packages/runtime-host/dist/__tests__/gitoxide-managed-inspection.test.js + packages/runtime-host/dist/__tests__/gitoxide-managed-mutation-admission.test.js + packages/runtime-host/dist/__tests__/gitoxide-managed-mutation-session.test.js diff --git a/.gitignore b/.gitignore index 0f6f738912..7de19d4ddc 100644 --- a/.gitignore +++ b/.gitignore @@ -24,11 +24,15 @@ docs/assets/ apps/desktop/tests/real-window-smoke/ deepseek.key +# Built only by the dedicated Gitoxide helper lane; normal workspace tests do not use Cargo. +/native/gitoxide-helper/target/ + # Generated Computer Use executor binary; provenance metadata stays tracked. apps/desktop/resources/bin/ # Rebuilt from experiments/windows-sandbox by scripts/package-windows-x64.mjs. apps/desktop/resources/windows-sandbox/ apps/desktop/bundled-git.json +apps/desktop/.generated/bundled-npm/ # Generated desktop release inputs and outputs. apps/desktop/resources/tools/ diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 321216a77a..c0e253c074 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -66,6 +66,26 @@ export default { 'dist/renderer/computer-use-overlay/**', ], extraResources: [ + { + from: '.generated/bundled-npm/npm', + to: 'npm', + }, + { + from: '.generated/bundled-npm/bundled-npm.json', + to: 'bundled-npm.json', + }, + { + from: '.generated/gitoxide-helper/gitoxide', + to: 'gitoxide', + }, + { + from: '.generated/gitoxide-helper/gitoxide-helper.json', + to: 'gitoxide-helper.json', + }, + { + from: '.generated/gitoxide-helper/THIRD_PARTY_NOTICES.txt', + to: 'licenses/gitoxide-helper/THIRD_PARTY_NOTICES.txt', + }, { from: 'bundled-tools.json', to: 'bundled-tools.json', @@ -105,6 +125,10 @@ export default { from: '../../LICENSE', to: 'licenses/maka/LICENSE', }, + { + from: '../../node_modules/npm/LICENSE', + to: 'licenses/npm-cli/LICENSE', + }, { from: '../../NOTICE', to: 'licenses/maka/NOTICE', diff --git a/docs/architecture/bundled-npm-runtime-attestation-v1.zh-CN.md b/docs/architecture/bundled-npm-runtime-attestation-v1.zh-CN.md new file mode 100644 index 0000000000..a3f349fead --- /dev/null +++ b/docs/architecture/bundled-npm-runtime-attestation-v1.zh-CN.md @@ -0,0 +1,148 @@ + + +--- +document_status: implementation-contract +status: draft-stacked-foundation +date: 2026-08-24 +milestone: M1.3 +stack_base: codex/m1-3-managed-npm-producer-rebuild@53721ce09 +--- + +# Bundled npm Runtime Attestation v1 + +## 1. 本 PR 只证明一个主要不变量 + +> 在调用方已经取得“来自 Maka 已签名发布物”的 resources-root authority 后,固定 npm producer 只能使用其中完整清单验证通过、且绑定当前受支持 Host Node 的 npm 运行闭包;调用者不能通过伪造结构体、传入任意 executable 或在签发后替换 npm 文件来取得执行权。 + +本 PR 的 owner 是 Runtime Host package 内部的 bundled npm attestation 模块。它拥有 npm 运行树 manifest 的解码、完整文件清单校验、Host Node 版本与 executable identity、不可伪造 capability 的签发和每次调用前的重新验证。 + +这里必须区分两层证明:外层应用签名与平台发布链提供 **provenance trust**,本模块的 manifest 提供 **runtime integrity**。manifest 与 npm tree 位于同一资源目录,攻击者若能同时替换二者并重算摘要,本模块本身无法识别;它绝不是自足的密码学信任根。PR 3 的 API 合同因此有一个显式前置条件:`resourcesRoot` 必须已经由后续 packaged-process owner 认证。本 PR 单独只能证明“受权目录在 admission 与每次 invocation 时没有发生未声明变化”,不能证明任意目录来自 Maka。 + +本 PR 不包含 Desktop/CLI/Runtime Host composition 的生产 consumer。attestation resolver、capability issuer 和固定 npm provision 入口不通过 `@maka/runtime-host/server` 公共 barrel 暴露;后续 Gitoxide product composition 必须在固定 `resourcesPath` owner 中把三者接通。因此本 PR 保持 Draft,不能单独宣称 M1.3 已可用。 + +## 2. 为什么只 bundled npm,不再 bundled 一份 Node + +Maka 已经由 Electron 或当前受控 Runtime Host 携带 Node。再打包第二份 Node 会增加包体、补丁与许可证维护面,并制造“两套 Node authority”。v1 直接绑定当前 Host runtime: + +```text +process.execPath canonical path + SHA-256 ++ process.versions.node ++ process.versions.modules (ABI) ++ platform / arch ++ 完整 npm tree manifest += ManagedNpmRuntimeCapability +``` + +Node 支持范围采用有限 allowlist;未知未来 major 默认拒绝,必须经过 permission-model 兼容验证后显式加入。当前允许: + +- Node 22.22.2 及同 major 后续版本; +- Node 24.15.0 及同 major 后续版本; +- Node 26.x; +- 其他 major 全部拒绝。 + +## 3. 发布闭包与供应链 + +发布准备从锁定的 `npm@12.0.2` 生成一个 Maka-owned runtime tree,并替换 npm 自带闭包中的四个已知脆弱版本: + +| package | npm 原版本 | 发布版本 | 证据 | +| --- | --- | --- | --- | +| `tar` | 7.5.19 | 7.5.22 | GHSA-r292-9mhp-454m | +| `brace-expansion` | 5.0.7 | 5.0.9 | GHSA-mh99-v99m-4gvg;GHSA-rgw5-rvv9-x895 | +| `ip-address` | 10.2.0 | 10.4.0 | 三条 manifest 中固定的 GHSA | +| `undici` | 6.27.0 | 6.28.0 | 三条 manifest 中固定的 GHSA | + +准备过程拒绝 symlink/junction,只接受 regular file/directory,输出: + +```text +apps/desktop/.generated/bundled-npm/ + npm/** 完整 npm runtime tree + bundled-npm.json 每个文件的 path、bytes、sha256 + audit/package-lock.json 独立 production audit 视图 +``` + +release gate 对独立 audit lock 执行 `npm audit --omit=dev --audit-level=high`。当前实际生成闭包约 14.6 MB,audit 为 0 vulnerabilities。生成目录不进入 Git;每次打包重新生成并验证。 + +## 4. 权限边界 + +`ManagedNpmRuntimeCapability` 的 TypeScript 形状不是权限。真实权限由 Runtime Host 模块内的 `WeakMap` 记录:只有 internal issuer 产生的对象才能通过消费 gate。结构相同的普通对象必须被拒绝。 + +签发器同样不属于公共 package API。否则任意调用者可以为自建目录生成“合法” capability,变成自认证。PR 4 的 composition owner 只能以打包应用的固定 resources root 调用 internal resolver,不能接受用户或 operation 传入的路径。 + +每次 npm invocation 前必须重新验证: + +1. Host executable canonical path 未变; +2. Host executable digest 未变; +3. npm runtime 仍只含 regular files/directories; +4. 实际文件集合、大小与 SHA-256 完全匹配 manifest; +5. npm `package.json` 仍是 `npm@12.0.2`、`Artistic-2.0`。 + +任一项失败都在 spawn 前 fail closed。 + +## 5. 原子性、失败状态与回滚 + +本 PR 不写用户 workspace,也不产生 durable T1/T2。它的原子边界是“通过全部验证后签发 capability”;验证中途失败不产生 capability。 + +稳定失败分类: + +- `bundled_npm_unavailable`:资源或 Host executable 不可读; +- `bundled_npm_manifest_invalid`:manifest 形状、路径或范围非法; +- `bundled_npm_platform_mismatch`:platform/arch 不匹配; +- `bundled_npm_integrity_mismatch`:文件集合、内容、版本或许可证不匹配; +- `bundled_npm_node_unsupported`:Host Node 不在验证 allowlist。 + +回滚本 PR 只需移除 npm release resources、manifest preparation 与 internal attestation 模块;PR 1 storage authority 和 PR 2 producer lifecycle 不需要回滚。没有兼容旧 manifest 的承诺:本能力尚无生产 consumer,格式变化应明确断代而不是建设迁移层。 + +## 6. 平台能力矩阵 + +| 能力 | Linux | macOS | Windows | +| --- | --- | --- | --- | +| regular-file tree inventory | 支持 | 支持 | 支持 | +| symlink/reparse input | 拒绝 | 拒绝 | 拒绝 junction/reparse | +| Host executable digest binding | 支持 | 支持;签名仍由外层 app 发布链保证 | 支持;Authenticode 仍由外层 app 发布链保证 | +| 每 invocation tree revalidation | 支持 | 支持 | 支持 | +| npm producer permission profile | Node permission model | Node permission model | Node permission model | + +manifest 与 npm tree 一起受最终应用签名/发布物保护。macOS 的 trust root 是通过 Gatekeeper/代码签名发布的 app bundle;Windows 的 trust root 是 Authenticode 签名的安装包与已安装应用。Linux v1 没有统一的平台签名验证 API,因此只承诺由官方发布/更新链安装后的完整性检查,不把任意本机目录提升为可信发布物。manifest hash 本身不是独立信任根;如果恶意本机进程已经能替换已安装应用资源、伪造父进程或直接运行修改后的 Maka 代码,本层不声称独立抵抗该攻击。 + +同理,后续父子进程 bootstrap 只负责把已经取得的 application authority 传给 detached Host,防止普通 CLI 参数或 ambient path 被误当成发布资源;它不是 macOS code-signing/Windows Authenticode 的替代物,也不抵御能够任意创建 Electron 父进程和 fd channel 的同用户恶意进程。若产品威胁模型将该攻击者纳入边界,必须另行引入平台签名验证 owner,不能继续给 bootstrap 增加可伪造字段。 + +## 7. Crash / tamper matrix + +| 时点 | 结果 | +| --- | --- | +| 准备 runtime tree 中途退出 | 生成目录不进入发布物;下一次 preparation 全量重建 | +| manifest 写入前退出 | release verifier 因 manifest 缺失失败 | +| manifest 与 tree 不一致 | runtime admission 拒绝 | +| capability 签发后 npm 文件被修改 | 下一次 invocation 在 spawn 前拒绝 | +| capability 被结构化伪造 | WeakMap gate 拒绝 | +| Host Node 被替换 | canonical path/digest revalidation 拒绝 | +| platform/arch 不一致 | admission 拒绝 | + +## 8. Gitoxide product composition 的硬前置 + +后续 composition 才能增加首个生产 consumer,并必须同时证明: + +1. Desktop/Runtime Host 只从固定 packaged `resourcesPath` 解析 npm; +2. Gitoxide admission/import/projection capability、storage authority、producer owner、runtime capability 由同一 composition 生命周期持有,且不得恢复 Git CLI owner; +3. production-shaped 测试使用实际生成的 npm tree,从 hermetic loopback registry 安装一个真实 tarball package,验证解包与 `.bin` 生成后再完成依赖环境 acquire; +4. runtime identity 写入 dependency environment identity,不能由调用者自报; +5. shutdown 顺序先停止新 acquire,再 drain producer,最后关闭 storage authority。 + +在这五项完成以前,PR 1–3 都只是可独立审查的 stacked foundation,不是用户能力。 diff --git a/docs/architecture/gitoxide-fresh-projection-data-plane-v1.zh-CN.md b/docs/architecture/gitoxide-fresh-projection-data-plane-v1.zh-CN.md new file mode 100644 index 0000000000..97ee11d1d4 --- /dev/null +++ b/docs/architecture/gitoxide-fresh-projection-data-plane-v1.zh-CN.md @@ -0,0 +1,54 @@ + + +# Gitoxide fresh projection 数据面 v1 + +状态:API-only stacked Draft。该切片不做 canonical-path rotation、quarantine、Desktop/CLI 接线或 Write/Edit 恢复。 + +## 主要不变量 + +只有 owner-bound managed-repository capability 可以把其绑定的 exact accepted commit 物化到此前不存在的 staging 目录;只有物化成功后签发的 projection capability 可以重新观察该路径。clean 只表示 projection 中的全部路径、类型、内容和 POSIX executable bit 与 immutable Git tree 完全一致。 + +## Owner 与原子性边界 + +- caller 只能选择 owner 管理下的 fresh destination;repository path、commit 与 tree 来自 capability,不能重新提交; +- Gitoxide helper 以 `create_dir` 获得 fresh-root 线性化点,文件使用 `create_new`,不写 `.git`,不创建 linked worktree; +- source import 已拒绝 symlink/submodule、非 UTF-8、大小写/NFC collision、`.git` 与 `.gitattributes`,projection 再次 fail closed 校验这些 entry; +- 每个普通文件有 64 MiB 上限,整棵 tree 有 2 GiB/200k 文件上限; +- observer 使用 bounded read 和 Git blob identity,拒绝缺失、额外、类型变化、内容变化与 POSIX executable-mode drift;打开普通文件时使用 no-follow 标志; +- helper 在响应丢失后遇到已存在目录,只在它仍精确等于 accepted tree 时返回相同 materialization response;partial/drifted 目录不会被静默覆盖。 + +## 失败状态与回滚 + +- materialization 中进程退出:staging 可能部分存在,但没有 `.git` 能力、没有 accepted-head 变化;composition owner 必须隔离或删除其私有 staging 后重试; +- destination 已存在且不精确:`projection_destination_not_fresh`;helper 不删除任何用户路径; +- projection drift:返回结构化 `projection_drifted` 与首个确定性 reason/path;不修改 projection; +- power-loss durability 不在 v1 合同内;v1 只证明正常完成和 process-crash 后的 fail-closed/retry 边界。 + +## 平台能力矩阵 + +| 平台 | v1 承诺 | +| --- | --- | +| Linux | exact materialization/observation;POSIX executable mode;`O_NOFOLLOW` | +| macOS | 同 Linux;普通 fsync 不提升为 power-loss 承诺 | +| Windows | exact content/path/type;Git executable bit 不映射为 NTFS ACL;reparse-point 路径不作为普通文件读取 | + +## 后续消费 + +M1.3 product composition 将拥有 storage-root、staging 命名、partial staging cleanup、canonical projection publication 与 lifecycle。M2.2/M2.4 只在这套 Gitoxide 数据面完成后重建,不再依赖 system/bundled Git CLI。 diff --git a/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md b/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md new file mode 100644 index 0000000000..2e21c7c47b --- /dev/null +++ b/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md @@ -0,0 +1,116 @@ + + +# Gitoxide helper artifact authority v1 + +状态:stacked 验证切片;尚无正式 release issuer、Desktop/CLI/Runtime Host 生产消费者,必须保持 +Draft。 + +## 1. 主要不变量 + +本切片只证明: + +> 普通 caller 不能用自报的 executable path 或 SHA-256 获得 Gitoxide helper 调用资格;只有内部 +> release owner 签发、与 owner token 绑定的 artifact claim,在 exact platform、architecture、 +> protocol、size 与 SHA-256 校验通过后,才能转换为另一个指定 owner 可消费的 opaque invocation +> capability。artifact 在 admission 后变化时,调用前重验必须 fail closed。 + +它不证明平台签名、安装目录保护、helper spawn、repository observation、T1 admission、managed +workspace 或 crash recovery。 + +## 2. Owner 与 API 权限 + +```text +未来的 packaged-release owner + └─ issueGitoxideHelperReleaseArtifactClaimInternal(ownerToken, exact artifact identity) + ↓ opaque release claim +artifact authority + └─ exact file/platform/protocol verification + ↓ opaque invocation capability +未来的 invocation owner + └─ verifyGitoxideHelperArtifactForInvocationInternal(ownerToken, capability) +``` + +- claim 与 capability 的状态存放在模块私有 `WeakMap` 中;对象表面不包含 path、digest 或 size。 +- claim 必须由相同的 release owner token 消费;capability 必须由签发时指定的 invocation owner token + 消费。 +- 相关 internal API 不从 `@maka/runtime-host/server` 导出。 +- 旧的 caller-provided `{ executablePath, expectedSha256 }` 不能成为这条链的 authority。 + +当前没有 production release owner。`issueGitoxideHelperReleaseArtifactClaimInternal()` 只是未来受信 +packaging owner 的接缝,不是签名信任根;在该 owner 落地前,本切片不能转 Ready。 + +## 3. 校验边界 + +一次 artifact 校验包含: + +1. 输入 claim 的 protocol/platform/architecture/size/digest 形状检查; +2. 拒绝 claimed path 任意组件中的 symlink 或 Windows junction; +3. 打开 canonical regular file,并限制 helper artifact 最大为 256 MiB; +4. 在同一 handle 上进行 64 KiB 有界缓冲的 SHA-256 流式读取; +5. 比较读取前后 handle identity/size/timestamps; +6. 比较读取后 path identity 与已打开 handle; +7. 比较 exact byte count 与 digest。 + +admission 与每次 invocation resolve 都执行这套校验。它可以识别校验之前或校验期间的替换,不会把 +相邻 manifest 当作自证信任根。 + +## 4. 原子性、失败状态与回滚 + +| 项目 | v1 合同 | +| --- | --- | +| owner | Runtime Host 内部 artifact authority | +| 原子性边界 | 单个打开 file handle 的一次 identity + streaming digest observation | +| durable state | 无;claim/capability 仅存在于进程内 | +| 非法/伪造 claim | `gitoxide_helper_release_claim_invalid` | +| 平台或架构不匹配 | `gitoxide_helper_release_claim_unsupported` | +| path/symlink/读取失败 | `gitoxide_helper_artifact_invalid` | +| size/digest/identity 漂移 | `gitoxide_helper_artifact_identity_mismatch` | +| 错误 owner/伪造 capability | `gitoxide_helper_invocation_capability_invalid` | +| rollback | 只读校验,无副作用,无需回滚 | + +## 5. 明确不承诺的威胁模型 + +本切片没有声称抵抗拥有同一 OS 用户文件写权限的主动攻击者。特别是: + +- 它尚未验证 macOS code signature、Windows Authenticode 或 Linux 发布清单的受信签名; +- 它尚未把 helper 放进由正式安装器保护的只读目录; +- 它尚未拥有 spawn,因此不声称消除了“最后一次 path 校验完成后、未来 spawn 开始前”的替换窗口。 + +下一切片在接入 spawn 前,必须由正式 packaged-release owner 提供信任根,并明确三平台安装目录与 +签名能力。不能通过给本 API 再传一个裸 expected digest 来绕过这一门槛。 + +## 6. 平台能力矩阵 + +| 平台 | 当前持续验证 | 尚未承诺 | +| --- | --- | --- | +| Linux | regular-file identity、digest、symlink path rejection | package signature、protected install root、spawn identity | +| macOS | 同 Linux | code-sign verification、notarized artifact binding、spawn identity | +| Windows | regular-file identity、digest、junction path rejection | Authenticode binding、ACL-protected install root、spawn identity | + +## 7. 后续切片 + +后续只能按下面顺序推进: + +1. 发布/安装 owner 把受信 helper identity 绑定到 signed product artifact; +2. 短生命周期 invocation owner 消费 opaque capability 并运行 strict helper protocol;该 stacked + Draft 的合同见 `gitoxide-helper-invocation-owner-v1.zh-CN.md`; +3. repository observation 再转换为 T1 前的 opaque admission capability。 + +在第 1 项完成以前,不接 Desktop/CLI,也不恢复旧 Git CLI adapter。 diff --git a/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md b/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md new file mode 100644 index 0000000000..f68359a811 --- /dev/null +++ b/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md @@ -0,0 +1,94 @@ + + +# Gitoxide short-lived invocation owner v1 + +状态:stacked Draft;真实 Rust helper 的三平台 contract 进入 CI,但仍无正式 release issuer、 +Desktop/CLI/managed-workspace 生产消费者。 + +## 1. 主要不变量 + +本切片只证明: + +> Runtime Host 只能通过 owner-bound opaque artifact capability 启动一次 exact Gitoxide helper; +> invocation 使用固定 strict JSON request、最小环境、有界 stdin/stdout/stderr、固定超时与取消边界; +> exit 0/1/2 必须分别匹配 inspected/operational failure/policy rejection 的 exact response shape,任意 +> 不一致均 fail closed。 + +它不签发 repository admission capability,不写 SQLite/T1,不创建 Git artifact,也不接 Desktop/CLI。 + +## 2. Owner 与调用链 + +```text +opaque GitoxideHelperInvocationCapability + ↓ invocation owner token 验证 + artifact bytes 重验 +fixed argv [] / minimal env / no shell + ↓ 64 KiB strict JSON request +one short-lived Rust helper + ↓ bounded stdout/stderr + exact exit/response decoder +typed observation | typed policy rejection | stable error +``` + +caller 不能提供 executable path、argv、environment、protocol version、timeout 或 output limit。唯一可变 +输入是 absolute repository path 与 AbortSignal;repository path 在 spawn 前 canonicalize。 + +## 3. 原子性、失败状态与回滚 + +| 项目 | v1 合同 | +| --- | --- | +| owner | 单次 Runtime Host invocation owner | +| 原子性边界 | artifact revalidation 后启动的一个 helper process 与其 exact response | +| 成功 | exit 0 + exact SHA-1 `repository_inspected` | +| policy rejection | exit 2 + exact `unsupported_object_format` | +| repository/helper failure | exit 1 + allowlisted stable helper reason | +| timeout | 5 秒后 force-kill process tree,`gitoxide_helper_invocation_timed_out` | +| cancellation | preflight 或运行中 fail closed,`gitoxide_helper_invocation_aborted` | +| resource failure | stdout 64 KiB、stderr 16 KiB,超限 force-kill | +| malformed protocol | exit code、JSON shape、OID 或字段不一致均拒绝 | +| rollback | helper 是只读 observation,无 durable side effect | + +Rust helper v1 不启动 descendants;Runtime 仍使用共享 process-tree terminator 处理 timeout、abort 和 +output overflow,不允许常驻或 detached helper。 + +## 4. 配置与数据边界 + +- argv 固定为空,禁止 caller 注入 helper option; +- `shell: false`,不会经过 shell parsing; +- child `PATH` 为空,只保留 Windows loader 与临时目录所需的最少环境变量; +- Rust 侧仍使用 `gix::open::Options::isolated()` 与 `strict_config(true)`; +- request 最大 64 KiB;stdout 最大 64 KiB;stderr 最大 16 KiB; +- SHA-1 OID 必须是 40 位小写十六进制;SHA-256/未知格式只返回 rejection,禁止 fallback。 + +## 5. 平台证据 + +同一个 workflow 在 Linux、macOS、Windows 上: + +1. 编译并测试 Rust helper; +2. 构建 Runtime Host; +3. 通过真实 helper executable 验证 SHA-1 success、SHA-256 rejection、unborn SHA-1 failure。 + +该证据只覆盖进程崩溃/终止和只读协议,不包含平台安装签名或恶意同用户替换;后者仍属于正式 +packaged-release trust root。 + +## 6. 下一切片 + +下一步只把 exact repository observation 转换成 T1 前可消费的 owner-bound opaque admission +capability,并绑定 canonical repository path、object format、HEAD commit/tree 与 observation protocol; +合同见 `gitoxide-repository-admission-capability-v1.zh-CN.md`。不在该切片中实现 source import、 +worktree projection、candidate 或 ref CAS。 diff --git a/docs/architecture/gitoxide-managed-inspection-product-composition-v1.zh-CN.md b/docs/architecture/gitoxide-managed-inspection-product-composition-v1.zh-CN.md new file mode 100644 index 0000000000..4a412683f6 --- /dev/null +++ b/docs/architecture/gitoxide-managed-inspection-product-composition-v1.zh-CN.md @@ -0,0 +1,88 @@ + + +# Gitoxide managed inspection 产品组合 v1 + +状态:M1.3 integration Draft。前置数据面合并后从最新 `main` 重建最终 PR。 + +## 主要不变量 + +> `ManagedWorkspaceInspect` 的源码视图只能来自 exact accepted Git tree 的 fresh projection;依赖视图只能来自同一 accepted tree 中 `package.json`、`package-lock.json` 所确定的 attested npm environment lease。任何一步不可用时都必须在工具调用发布结果前失败,禁止回退 attached checkout、系统 Git、`PATH` Git 或 source checkout 的 `node_modules`。 + +Owner 划分: + +- packaged release owner:Gitoxide helper 与 npm runtime artifact; +- Gitoxide admission/import owner:source HEAD 与 immutable managed tree; +- dependency authority:manifest/lockfile identity、publication receipt 与 active lease; +- filesystem worker:单次只读 `Read/Glob`; +- Runtime Host composition:工具可见性、操作路由、取消、drain 与最终清理。 + +## 调用顺序 + +```text +canonical source cwd + -> Gitoxide admission(冻结 source HEAD/tree) + -> fresh Maka-owned bare import + -> exact tree read: package.json + package-lock.json + -> dependency identity + attested npm lease + -> fresh accepted-tree projection + -> Read/Glob routed to projection or leased node_modules + -> projection re-observation + -> bounded provider result + -> release lease + remove ephemeral import/projection +``` + +`node_modules` 路由发生在完整 canonical path 校验之后。含空段、`.`、`..`、反斜杠、盘符或绝对路径的输入在任何 Git/npm 副作用前拒绝;Windows 的 `NODE_MODULES` 等大小写别名同样进入 dependency lease,不能落回 projection。 + +## 原子边界、失败与回滚 + +本切片不声称跨 Git/npm/filesystem 的单一事务,也不写 managed mutation T1。每次调用使用 fresh 随机 import/projection root;只有 dependency authority receipt 是可复用 durable artifact。 + +| 状态 | 处理 | +| --- | --- | +| helper/npm/worker 缺失 | 工具不进入 Host tool surface;普通 Host 功能继续可用 | +| source HEAD 在 admission/import 间变化 | import 前 fail closed | +| package manifest/lockfile 缺失或不合法 | 不启动 read worker,不回退 source bytes | +| provisioning/worker 取消 | signal 贯穿 helper、npm authority 与 worker | +| projection drift | 丢弃结果;不向 provider 发布 | +| 进程崩溃 | 不自动 replay;下一次显式调用使用 fresh root,旧 staging 等待后续 GC | + +工具的 recovery mode 固定为 `never_auto_retry`。这是有意的产品边界:M1.3 证明可用的隔离读取,不冒充 M2 尚未完成的 durable Write/Edit 或 crash replay。 + +## 权限与产品入口 + +- 工具类别为 `custom_tool`,因此不会进入默认只读 Plan Mode; +- 工具可能联网下载依赖并写最多由 dependency authority 限制的 cache; +- 只有 Electron packaged Runtime Host 同时解析到严格 Gitoxide/npm manifest 和 sandboxed filesystem worker 时才暴露工具; +- CLI、开发态 Electron、缺少资源或完整性校验失败时都不会发现系统 Git 或静默降级。 + +外层已签名应用包是 v1 release trust root;本合同不抵抗能够改写整个已安装应用及其 manifest 的同用户恶意进程。 + +## 平台矩阵 + +Linux、macOS、Windows 使用相同 helper 协议、tree read、dependency identity 与路由规则。三平台 Gitoxide CI 执行真实 helper 的产品组合测试。Windows sandbox 目前只承诺 `Read/Glob`;本工具 v1 不暴露 Grep。 + +## 后续 M2 + +数据面和本产品入口稳定后: + +1. 从最新 `main` 刷新 M2.1 accepted-head SQLite authority; +2. 从最新 `main` 刷新 M2.3 durable reservation 与 Runtime settlement; +3. 再用 Gitoxide successor/ref CAS 重建 M2.2 candidate owner; +4. 最后重建 M2.4 Write/Edit consumer。M2.4 不得恢复 Git CLI worktree rotation,也不得把 M1.3 的 ephemeral staging 当 canonical truth。 diff --git a/docs/architecture/gitoxide-mutation-candidate-owner-v1.zh-CN.md b/docs/architecture/gitoxide-mutation-candidate-owner-v1.zh-CN.md new file mode 100644 index 0000000000..1aee5b8954 --- /dev/null +++ b/docs/architecture/gitoxide-mutation-candidate-owner-v1.zh-CN.md @@ -0,0 +1,112 @@ + + +# Gitoxide mutation candidate owner v1 + +状态:M2.2 stacked Draft。尚未接入 Write/Edit;M2.4 是首个生产消费者。 + +## 1. 主要不变量 + +> M2.2 只能从精确 accepted base 生成 operation-bound immutable candidate;发布 candidate 不得推进 +> accepted ref,也不得写 SQLite accepted truth。 + +- owner:Runtime Host 内部的 Gitoxide candidate authority; +- owner 前置能力:创建 authority 必须消费仍有效的 `interactive/write` storage-root lease;初始化、 + receipt 锁与每次 capture 都在该 lease 内运行,不能由裸路径自行声明 storage root; +- 原子边界:Gitoxide 对确定性 `refs/maka/candidates/` 执行 exact-base ref CAS; +- 失败状态:accepted base 漂移、candidate ref 冲突、receipt 损坏或身份不一致均 fail closed; +- 回滚:ref 已发布、receipt 未写时,重启通过 exact retry 补齐同一 receipt;未被 M2.1 接受的 candidate + 保持 orphan,后续由受证明的 discard/GC owner 回收。 + +## 2. Owner 与事实权威 + +```text +SQLite RuntimeEvents / workspace head (M2.1) 唯一 accepted truth + │ exact base + ▼ +Gitoxide managed repository capability + │ + ▼ +prepare_candidate(base, path, content) + │ + ├─ refs/maka/accepted 不变 + └─ refs/maka/candidates/* immutable candidate + │ + ▼ + durable receipt(派生证据) +``` + +receipt 不是第二事实源。新进程不会仅凭 JSON 恢复 capability,而会: + +1. 从 storage-root 与 workspace identity 派生 repository/receipt 路径; +2. 用短生命周期 helper 重验 accepted ref 的 exact commit/tree; +3. 对相同 operation 执行 deterministic exact retry; +4. 将 ref/object observation 与 strict receipt 全字段比较; +5. 比较成功后才签发新的 owner-bound opaque candidate capability。 + +## 3. Crash / concurrency matrix + +| 故障点 | Durable 状态 | 恢复 | +|---|---|---| +| candidate object 写入前 | accepted ref 不变,无 receipt | 重新计算 | +| candidate ref 发布前 | accepted ref 不变,无 receipt | 重新发布同一 ref | +| candidate ref 后、receipt 前 | candidate ref 存在 | exact retry 后补 receipt | +| receipt temp 写入中 | ref 存在,临时文件可能存在 | 忽略非权威 temp,重写 receipt | +| receipt rename 后响应丢失 | ref + receipt 完整 | 全字段重验并返回新 capability | +| 两进程同时 capture | 同一 receipt OS lock + ref CAS | 一个发布,另一方 exact retry | +| receipt 被篡改 | ref/object 与 receipt 不一致 | fail closed | +| accepted ref 已推进 | 旧 base 不再匹配 | 不创建/接受 candidate | + +## 4. Artifact lifecycle 与 GC owner + +M2.2 只创建 candidate ref 与派生 receipt,不在尚未观察 SQLite terminal truth 时自行删除。后续 GC +必须由持有同一 storage-root write lease、并能读取 M2.1/M2.3 durable truth 的 owner执行,并区分: + +1. 已由 SQLite successor 接受且已提升为 accepted ref 的 candidate; +2. 已提交 terminal no-effect 的 operation; +3. 明确 abandoned 且超过审计保留期的 operation; +4. orphan ref 无 receipt; +5. receipt 无对应 T1 或其身份与 RuntimeEvent 不一致。 + +任何无法从 immutable RuntimeEvents、accepted head 与 Git ref 联合证明的对象都只允许保留或 +quarantine,不允许猜测删除。GC 的保留期、配额和审计记录属于独立 lifecycle PR;在其落地前, +candidate artifact 可以增长,但不得被本 authority 静默回收。 + +## 5. 平台能力矩阵 + +| 平台 | 当前承诺 | +|---|---| +| Linux | 短生命周期 helper、ref CAS、process-kill/reopen、receipt fsync/rename | +| macOS | 与 Linux 相同的 process-crash 收敛;不声称断电永久存储保证 | +| Windows | helper/ref CAS 与 process-kill/reopen;目录 fsync 为平台 no-op,不声称断电收敛 | + +三平台由同一 Gitoxide helper workflow 执行 Rust contract、Runtime Host contract 和真实子进程 crash +用例。CI 通过只证明已列出的状态,不替代未实现的 discard/GC 与 M2.4 组合证明。 + +## 6. 不属于本切片 + +- 不提交 T2、workspace successor 或 canonical head; +- 不推进 `refs/maka/accepted`; +- 不执行 Write/Edit; +- 不创建 Desktop/CLI mode; +- 不处理 projection rotation/quarantine; +- 不声称 M3 continuation 已可使用。 + +M2.4 必须消费本 owner 的 opaque proof,并在 M2.1 SQLite transaction 成功后才允许推进 Git accepted +projection。任何失败都禁止退回 attached checkout 或旧 Git CLI 路径。 diff --git a/docs/architecture/gitoxide-packaged-helper-authority-v1.zh-CN.md b/docs/architecture/gitoxide-packaged-helper-authority-v1.zh-CN.md new file mode 100644 index 0000000000..d400e79d9f --- /dev/null +++ b/docs/architecture/gitoxide-packaged-helper-authority-v1.zh-CN.md @@ -0,0 +1,80 @@ +--- +title: Gitoxide packaged helper authority v1 +status: Draft +milestone: M1.3 +--- + + +# Gitoxide packaged helper authority v1 + +## 1. 主要不变量 + +本切片只证明一件事: + +> 只有当前 Maka 发布流程构建、清单绑定并随应用资源一起交付的 exact Gitoxide helper,才能被转换成 Runtime Host 内部的调用 capability;普通 caller 不能用裸路径、PATH 发现或自报摘要获得执行权。 + +它不负责 source import、candidate、projection 或 Desktop managed task。这些能力消费本切片签发的 opaque capability,不能重新接受 executable path。 + +## 2. Owner 与权限边界 + +- release build owner:使用锁定的 `native/gitoxide-helper/Cargo.lock` 构建 release binary; +- preparation owner:复制到 fresh `.generated/gitoxide-helper`,计算 bytes/SHA-256,并写 `maka_gitoxide_helper_release_v1`; +- legal owner:从同一个 Cargo.lock graph 生成随包交付的 crate license/notice; +- packaged-resource owner:Electron 只携带 helper、manifest 和 notice; +- Runtime Host release owner:从平台应用已经授予的 `resourcesRoot` 读取严格 manifest,签发 release claim; +- invocation owner:每次调用前重新验证 canonical path、file identity、bytes 和 digest。 + +manifest 是发布资源的完整性声明,不是独立密码学签名。v1 的外层 trust root 是操作系统认可的应用发布/签名边界;同一用户权限下能同时改写已安装应用和 manifest 的攻击者不在本切片单独抵抗的威胁模型内。后续产品接线只能传递 Desktop 已持有的 packaged-resource authority,不能把公开 CLI 路径参数当成 authority。 + +## 3. 原子性与失败状态 + +preparation 先写 fresh helper 目录,再用临时 manifest rename 发布声明。生成失败时整个 `.generated` 输出不是发布输入,打包必须停止。 + +Runtime admission 只有两种结果: + +- exact manifest 与 artifact 匹配:签发 owner-bound invocation capability; +- manifest、平台、路径、类型、大小或摘要任一不匹配:fail closed,不发现 system Git,也不尝试旧 bundled Git。 + +它没有 T1,也不写 durable state;rollback 是丢弃 `.generated/gitoxide-helper` 并重新构建。 + +## 4. 平台能力矩阵 + +| 平台 | 构建/资源 | 运行时校验 | 当前证据 | +| --- | --- | --- | --- | +| Linux x64 | CI release helper | non-symlink regular file、identity、bytes、SHA-256 | Gitoxide workflow | +| macOS arm64 | release helper 随 app 签名 | 同上;外层 trust root 为已签名 app | Gitoxide workflow;正式 notarized artifact 仍由 release lane 验证 | +| Windows x64 | release helper 随安装包 | 拒绝 symlink/junction path,校验 identity、bytes、SHA-256 | Gitoxide workflow | + +开发态不会从 PATH、system Git 或任意 `resourcesPath` 自动启用 managed Git。没有经过明确测试 authority 注入时,Gitoxide managed profile 必须报告 unavailable。 + +## 5. 许可证与包体 + +Gitoxide helper 是单个短生命周期 Rust binary,不携带 Rust 工具链。Cargo notices 从 exact lock graph 在发布时生成并放入 `licenses/gitoxide-helper/THIRD_PARTY_NOTICES.txt`。普通 TypeScript 开发和非 Gitoxide 测试不需要安装 Rust;只有修改 helper、运行其三平台 CI 或构建正式安装包时需要锁定 Rust toolchain。 + +## 6. 后续产品接线 + +下一切片必须由同一个 Runtime Host composition 生命周期持有: + +1. packaged-resource authority; +2. Gitoxide invocation/admission/import/projection capability; +3. bundled npm capability 与 dependency storage authority; +4. 专用 managed task consumer。 + +Host handshake 必须声明 exact managed profile。CLI 启动的无 packaged-resource Host 不能被 Desktop 静默复用为支持该 profile 的 Host;不匹配只能显式拒绝或安全替换,禁止 fallback。 diff --git a/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md new file mode 100644 index 0000000000..1aa81dd329 --- /dev/null +++ b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md @@ -0,0 +1,92 @@ + + +# Gitoxide repository admission capability v1 + +状态:Gitoxide control-plane admission Draft;source import data plane 作为下一独立切片消费该 capability。 + +## 1. 主要不变量 + +本切片只证明: + +> caller 不能用裸 repository path、object format、commit OID 或 tree OID 自证 source identity。 +> 只有 owner-bound helper capability 的一次真实、严格 SHA-1 observation,才能签发指定 admission +> owner 可解析的 opaque capability;SHA-256/未知格式只返回 policy rejection,不产生 capability。 + +## 2. Owner 与事实流 + +```text +helper invocation owner + └─ exact repository_inspected response + ↓ +repository admission authority + ├─ canonical repository path + ├─ protocol/object format + ├─ exact HEAD commit OID + └─ exact HEAD tree OID + ↓ private WeakMap +opaque GitoxideRepositoryAdmissionCapability + ↓ only the designated admission owner may resolve +immutable admission state +``` + +认证元数据与可返回 observation state 分开存储;解析 capability 不会泄漏 owner token。相关 API 不从 +`@maka/runtime-host/server` 导出。 + +## 3. 原子性、失败状态与回滚 + +| 项目 | v1 合同 | +| --- | --- | +| observation owner | short-lived invocation owner | +| capability owner | repository admission authority | +| 原子性边界 | 一次 canonical path observation + 一次 exact helper response + 进程内 capability 签发 | +| accepted | SHA-1 exact commit/tree,签发 opaque capability | +| policy rejected | SHA-256/未知 format,返回 rejection,不签发 capability | +| helper/路径失败 | 沿用 invocation owner 的稳定 fail-closed error | +| forged/wrong-owner capability | `gitoxide_repository_admission_capability_invalid` | +| durable state | 无;该 capability 必须在 T1 前消费 | +| rollback | 只读 observation,无副作用 | + +## 4. Freshness 与未来 T1 + +capability 表示一次明确线性化点上的 immutable Git commit/tree snapshot,不承诺 source branch 在随后 +保持不变。未来 T1 owner 应把 exact commit/tree 写入 durable admission,并从该 immutable commit +导入 source;不得在 T1 后重新解释“当前 HEAD”,也不得 fallback 到 caller 提供的 OID。 + +如果产品需要“必须采用用户按下执行按钮那一刻的最新 HEAD”,该策略必须在未来 T1 owner 内重新 +观察并比较;不能让本 capability 变成可变 branch lease。 + +## 5. 当前完成度 + +到本切片为止,Gitoxide 验证栈已具备: + +1. Rust helper 的 isolated SHA-1 observation / SHA-256 rejection; +2. exact helper artifact → opaque invocation capability; +3. bounded short-lived process owner 与 strict response decoder; +4. exact repository observation → opaque admission capability; +5. Linux、macOS、Windows 的真实 helper contract workflow。 + +仍未完成、也没有伪装完成: + +- signed packaged-release trust root 与受保护安装路径; +- Desktop/CLI 消费者; +- T1 durable admission、projection、candidate 与 ref CAS;source import 由后续独立 Draft 实现。 + +因此这些 PR 可以作为 Gitoxide backend 的验证栈审查,但在正式 release owner 和生产消费者接入前 +继续保持 Draft。 diff --git a/docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md b/docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md new file mode 100644 index 0000000000..9af0a37afa --- /dev/null +++ b/docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md @@ -0,0 +1,102 @@ + + +# Gitoxide short-lived helper:repository admission v1 + +状态:验证切片;尚无 Desktop、CLI、Runtime Host 或 M2 生产消费者,只能保持 Draft。 + +## 1. 主要不变量 + +本切片只证明: + +> 在选择 managed-workspace durable mode 或写入 T1 以前,Git backend owner 可以通过一个 +> 短生命周期、隔离配置的 Gitoxide helper 观察 repository object format 和 exact HEAD identity; +> 只有 SHA-1 repository 返回 observation,SHA-256 与未知格式稳定 fail closed,且不得调用或 +> 回退到系统 Git。 + +它不证明 source import、clone、fetch、worktree、candidate、projection、ref CAS、Write/Edit 或 +resume。现有 dormant `GitWorkspaceService` 也没有切换到该 helper。 + +## 2. 为什么是 helper,不是常驻 broker + +`maka-gitoxide-helper` 每次启动只执行以下协议: + +```text +stdin: 一个最大 64 KiB 的 strict JSON request + ↓ +Gitoxide isolated repository observation + ↓ +stdout: 一个 JSON response + ↓ +process exit +``` + +进程不监听 socket、不复用 repository handle、不保存 caller identity,也不拥有跨请求锁或可恢复 +状态。因此它不是新的常驻 authority;durable ownership 仍必须由未来的 Storage/Runtime owner +通过 SQLite、artifact receipt 与 scoped capability 建立。 + +## 3. Owner、原子边界与失败状态 + +| 项目 | v1 合同 | +| --- | --- | +| operation owner | 单次 `maka-gitoxide-helper` 子进程 | +| 输入 | `inspect_repository` strict JSON,最大 64 KiB | +| 配置边界 | `gix::open::Options::isolated()` + `strict_config(true)` | +| 成功 | exit 0;SHA-1 + exact HEAD commit/tree OID | +| policy rejection | exit 2;`unsupported_object_format` | +| operational failure | exit 1;稳定 `helper_error.reason` | +| 原子性边界 | 单个 repository handle 的一次只读 observation;无跨介质事务 | +| rollback | 只读操作,不需要回滚 | + +当前 response 中的 observation 不是不可伪造的进程外 capability。未来 Node/Runtime adapter 必须先 +验证 helper binary/release identity、绑定 invocation input,并把 observation 转换为 owner-issued +opaque capability;不能让 caller 直接提交裸 OID 或 object format。 + +## 4. SHA-256 策略 + +Cargo 编译 `sha256` feature 只用于识别并给出稳定拒绝,不代表 Maka 已支持 SHA-256 repository。 +v1 的 `supportedObjectFormats` 固定为 `["sha1"]`。未来支持必须显式升级 backend capability 与 +协议测试,禁止静默 fallback。 + +## 5. 测试与工具链 + +- 普通 `npm test`、TypeScript workspace 测试和最终用户运行不要求 Rust 工具链。 +- 修改 helper 时运行 `npm run test:gitoxide-helper`。 +- `Cargo.lock` 是 source/build identity 的一部分并进入版本控制。 +- 三平台独立 CI 构建同一源码并运行协议测试。 +- 测试使用 Git CLI 预先构造真实 fixture;启动 helper 后清空 `PATH` 并注入恶意 Git config 环境。 + 如果 helper 尝试使用系统 Git 或 caller config,测试会失败。 + +## 6. 平台能力矩阵 + +| 平台 | 当前验证目标 | 尚未承诺 | +| --- | --- | --- | +| Linux | SHA-1 inspect;SHA-256 reject;无 system-Git fallback | packaging、sandbox、crash recovery | +| macOS | 同 Linux | signing、notarization、production packaging | +| Windows | 同 Linux | Authenticode、job owner、production packaging | + +只有三个 CI lane 都建立证据后,才能把“当前验证目标”升级为持续平台承诺。 + +## 7. 下一切片 + +后续 stacked Draft 先建立 helper artifact claim → opaque invocation capability 的内部边界,并明确 +正式 packaged-release trust root 尚未接入;详见 +`gitoxide-helper-artifact-authority-v1.zh-CN.md`。再后续才把一次 repository observation 转换成 +T1 前可消费的 opaque admission capability。source import、fresh projection 与 candidate ref CAS +继续分别验证,不能在 admission PR 中顺手恢复旧 Git CLI adapter。 diff --git a/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md b/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md new file mode 100644 index 0000000000..176ee4a09a --- /dev/null +++ b/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md @@ -0,0 +1,69 @@ + + +# Gitoxide source import data plane v1 + +状态:堆叠在 repository admission capability 之后的 API-only Draft;没有 Desktop/CLI 消费者。 + +## 1. 主要不变量 + +本切片只证明: + +> source import 只能消费 owner-bound repository admission capability 中冻结的 exact SHA-1 HEAD;helper +> 只把该 commit 的 reachable tree/blob 导入此前不存在的 Maka-owned bare repository,并以确定性零父 +> baseline commit 发布 `refs/maka/*`。caller 不能重新提交 source path、HEAD 或 tree identity。 + +后续的 exact-base successor/ref CAS 由 +`gitoxide-successor-ref-cas-data-plane-v1.zh-CN.md` 单独证明;本切片不创建 projection,也不推进 SQLite canonical head。 + +## 2. Owner 与原子性边界 + +- repository admission authority 拥有 source path、commit 与 tree identity; +- invocation owner 在每次调用前重新验证 helper artifact; +- short-lived helper 拥有 object copy 与 baseline ref publication; +- fresh destination 整体是 artifact 边界,不尝试跨 source/destination/SQLite 伪造事务。 + +线性化点是 fresh destination 内 `refs/maka/*` 从不存在到 baseline commit 的 ref publication。ref 发布前 +的 objects 不具有 canonical 意义;完整 response 返回前,destination 不能被上层接受。 + +## 3. 失败与回滚 + +| 状态 | 处理 | +| --- | --- | +| source HEAD 与 admission 不一致 | 创建 destination 前失败 | +| destination 已存在、是文件或 symlink | 拒绝接管,不修改原内容 | +| path/type/quota/object copy 失败 | destination 是 untrusted partial artifact,整体删除 | +| helper 进程中断或响应丢失 | 不推断成功;整体删除 fresh destination 后用新路径重试 | +| SHA-256/未知 object format | policy reject;不 fallback 到系统 Git | + +v1 不复制 source commit/history,不创建 alternates,不执行 hook/filter/submodule/LFS,也不接入 T1/T2。 + +## 4. 平台与资源边界 + +- 单文件最多 64 MiB;总计最多 2 GiB;最多 200,000 个普通文件; +- 只接受 tree、`100644` blob 与 `100755` executable blob; +- 拒绝 symlink、submodule、`.git`、`.gitattributes`、非 UTF-8 与 NFC/大小写 collision; +- Linux/macOS/Windows 运行同一 locked Cargo suite;只承诺 process-crash discard/retry,不承诺断电; +- Windows 保留 Git tree 中的 executable bit,不把它映射成 ACL 权威。 + +## 5. 后续依赖 + +下一切片是 Gitoxide candidate/ref CAS。M2.1 与 M2.3 可以并行从最新 main 重建;M2.2/M2.4 必须等 +candidate/ref authority 完成后再重建。M1.3 production composition 只能消费本切片签发的 baseline +artifact,不能恢复旧 Git CLI adapter 或 PATH discovery。 diff --git a/docs/architecture/gitoxide-successor-ref-cas-data-plane-v1.zh-CN.md b/docs/architecture/gitoxide-successor-ref-cas-data-plane-v1.zh-CN.md new file mode 100644 index 0000000000..1276a9c66b --- /dev/null +++ b/docs/architecture/gitoxide-successor-ref-cas-data-plane-v1.zh-CN.md @@ -0,0 +1,59 @@ + + +# Gitoxide successor/ref CAS 数据面 v1 + +状态:API-only Draft。该切片不接 Desktop/CLI,不实现 projection,也不宣称 Write/Edit 已经恢复闭环。 + +## 主要不变量 + +一个 owner-bound managed-repository capability 只能从它绑定的 exact base commit 构造确定性的单路径 successor;`refs/maka/*` 只有在当前值仍等于 exact base 时才可通过 CAS 前进。调用者不能重新提交 repository path、base commit 或 target ref。 + +## Owner 与原子性边界 + +- source-import authority 在成功导入后签发 opaque managed-repository capability,内部绑定 Maka-owned bare repository、accepted ref、base commit 与 base tree; +- 短生命周期 Gitoxide helper 只接受 SHA-1 repository、canonical UTF-8 `/` 路径和不超过 64 MiB 的文本内容;SHA-256 仍在 admission 阶段 fail closed; +- helper 从 immutable base tree 写入 blob、tree 与确定性单父 commit;这些对象在 ref 发布前都不是 accepted truth; +- 唯一线性化点是 `PreviousValue::MustExistAndMatch(base)` 的 ref transaction;CAS 失败不会移动 accepted ref; +- 若响应丢失,而 ref 已等于本次请求确定性计算出的 successor,精确重试返回相同 response,不会再生成一代 successor; +- 成功结果签发下一代 capability,旧 capability 只可用于同一请求的精确重试,不能基于过期 base 发布另一项修改。 + +## 失败状态与回滚 + +- ref 已由其他 successor 前进:返回 `base_commit_mismatch`,不覆盖当前 ref; +- helper/config/object/path/content 不满足协议:fail closed,不调用 system Git,不从 `PATH` fallback; +- CAS 前进程退出:新对象可能成为不可达对象,accepted ref 不变,可由后续 GC 回收; +- CAS 后响应丢失:相同请求通过确定性 successor identity 收敛; +- SQLite accepted-head、candidate receipt、projection 与 quarantine 不属于本切片,分别由重建后的 M2.1、M2.2/M2.4 和后续 projection owner 承担。 + +## 平台能力矩阵 + +| 平台 | v1 承诺 | +| --- | --- | +| Linux | 短生命周期 helper、exact-base CAS、精确重试;由三平台 workflow 验证 | +| macOS | 同 Linux;不依赖系统 Git 作为生产数据面 | +| Windows | 同 Linux;路径协议统一使用 canonical `/`,反斜杠输入在 helper 前拒绝 | + +这里不承诺对同一用户恶意替换 Maka 私有 storage root 的安全隔离;storage-root ownership 与进程级锁由产品 composition 切片负责。 + +## 后续依赖 + +1. Gitoxide fresh projection materialization/observation(见 `gitoxide-fresh-projection-data-plane-v1.zh-CN.md`); +2. M1.3 product composition 消费 admission/import/candidate/projection capabilities; +3. 数据面完成后,从最新 `main` 重建 M2.2 candidate durable owner 与 M2.4 Write/Edit 生产闭环。 diff --git a/docs/architecture/gitoxide-tree-file-read-data-plane-v1.zh-CN.md b/docs/architecture/gitoxide-tree-file-read-data-plane-v1.zh-CN.md new file mode 100644 index 0000000000..ed39351d2c --- /dev/null +++ b/docs/architecture/gitoxide-tree-file-read-data-plane-v1.zh-CN.md @@ -0,0 +1,47 @@ + + +# Gitoxide accepted-tree file read 数据面 v1 + +状态:M1.3 product composition 前置 Draft。 + +## 主要不变量 + +> dependency manifest 与 lockfile 必须直接读取自 owner-bound managed repository 的 exact accepted commit/tree;不得从可变 projection、attached checkout 或 caller 路径读取后再冒充 immutable 输入。 + +## Owner 与边界 + +- Gitoxide managed-repository capability 冻结 repository path、accepted ref、commit 和 tree; +- caller 只能提交 canonical UTF-8 `/` path;不能提交 commit、tree 或 repository path; +- short-lived helper 从 exact commit tree 查找 regular blob,拒绝 tree、symlink、缺失路径、非 UTF-8 和超过 8 MiB 的文件; +- response 同时返回 commit、tree、blob OID、path、content 与 byte count;Runtime Host 对完整 envelope 严格校验,并再次与 capability identity 比较。 + +该操作只读 Git object database,不物化文件,不写 durable state,也没有 T1。失败时 fail closed;没有 projection fallback。 + +## 为什么不是“物化后 read + 再观察” + +projection 是执行视图,不是 accepted truth。即使读取前后各做一次 drift observation,外部写入仍能发生在最后一次观察后,或者 manifest/lockfile 两次读取之间。直接从 immutable tree 读取把线性化点放回 Git object identity,也让 dependency environment identity 真正绑定 accepted source bytes。 + +## 平台与资源上限 + +Linux、macOS、Windows 使用同一 helper 协议与 8 MiB/file 上限。helper stdout owner 同步提供有限上界;超大、非 UTF-8 或非普通 blob 全部拒绝。三平台真实 Rust helper test 由 Gitoxide workflow 执行。 + +## 后续 + +M1.3 composition 只允许用本 capability 读取 `package.json` 与 `package-lock.json`,随后计算 dependency environment identity。M2.2/M2.4 仍等待 product composition 完成后从最新 main 重建。 diff --git a/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md b/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md new file mode 100644 index 0000000000..afadbb7f93 --- /dev/null +++ b/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md @@ -0,0 +1,94 @@ +# Gitoxide Write/Edit 接受链 v1 + +## 状态 + +stacked Draft。Runtime Host 已能消费持久化的 `managed-coding-v1` Session profile;Desktop/CLI +尚未提供创建该 profile 的产品入口,因此不能视为默认开放 managed Write/Edit。 + +## 主要不变量 + +一次 managed Write/Edit 的内容只能由以下链路产生: + +```text +immutable accepted tree + -> pure Write/Edit transform + -> immutable candidate ref + durable receipt + -> SQLite accepted successor transaction + -> accepted-ref compare-and-swap projection +``` + +- transform owner 是 Runtime;它复用生产 Edit matcher,但不读取或写入 checkout。 +- candidate owner 是 Runtime Host 的 Gitoxide helper authority。 +- accepted truth owner 是 SQLite RuntimeEvents;candidate ref 不是 accepted truth。 +- projection owner 只有在 SQLite successor 已提交后才能调用 `promote_candidate`。 + +T1 后只有四种互斥终态: + +- `workspace_successor_committed`:成功且产生新 Git successor; +- `no_workspace_change_committed`:成功但结果内容与 base 相同; +- `operation_failed_no_effect_committed`:纯转换失败,或 helper 以稳定 policy reason 证明 + candidate ref 尚未发布; +- `unsettled`:无法证明以上任一终态,保留 reservation 并 fail-stop。 + +前两种 no-effect terminal 由同一个 SQLite writer 原子提交 exact T2、terminal fact 并释放 +reservation;generic T2 writer 在数据库层拒绝 managed mutation。 + +## 原子性与恢复 + +`promote_candidate` 的线性化点是 accepted ref 的 compare-and-swap: + +- `accepted == base`:推进到 exact candidate; +- `accepted == candidate`:视为精确重试并成功收敛; +- 其他状态:fail closed,不覆盖、不 fallback。 + +因此 SQLite 提交后、ref 推进前崩溃时,只重放 ref projection,不重新执行 Write/Edit。 + +Host 重启时,SessionManager 在 generic `app_restarted` terminal 之前调用 managed mutation gate: + +- 没有 active reservation 才允许 generic recovery 继续; +- active T1 已由 Gitoxide/SQLite owner 收敛后才允许 Run 封口; +- helper、receipt、ref 或 storage 暂时不可判定时返回 `parked`,Run 保持未封口,等待下一次权威恢复。 + +这使 RuntimeEvent terminal seal 不会抢在 managed T2/successor 前落盘。 + +baseline intent 会绑定签发时的 exact Gitoxide helper artifact SHA-256。重开时若 packaged helper +已经升级,当前 v1 不会把新二进制静默接入旧 workspace epoch,而是 fail closed;由后续显式 +rebaseline/新 epoch 流程选择升级。这里绑定的是 materialization artifact identity,不把二进制摘要 +冒充稳定的语义 execution profile。 + +本切片对 baseline intent/receipt 的 durable JSON 只承诺进程崩溃后的收敛;它不声明断电持久性。 +若后续需要 power-loss contract,文件内容、父目录与 SQLite 提交顺序必须由同一个平台 durability +设计证明,不能从当前 atomic rename 推导出来。 + +## 失败状态与回滚 + +- helper 的稳定 tree/content policy rejection:证明 ref 未发布后提交固定、Runtime-owned error outcome, + 原子释放 reservation; +- helper timeout、abort、协议错误、receipt I/O 或 ref publication 附近失败:状态视为 + `publication_indeterminate`,保留 reservation,禁止把它降级成 no-effect; +- no-op / 确定性转换失败:不创建 candidate;SQLite 原子提交 no-effect terminal 并释放 reservation。 +- SQLite successor 未提交:禁止推进 accepted ref。 +- accepted ref CAS 冲突:park;SQLite accepted truth 保留,等待显式 reconciliation。 +- projection 失败:不得回滚 SQLite 事实,也不得重跑工具。 + +Write/Edit 的 provider result 继续使用生产 `createUnifiedDiff`,其输入和输出分别受 32 KiB 上限; +超限内容自动降级为固定大小的 `file_write`/Edit summary。因而 live 与 recovery 都由同一个纯 transform +生成严格 JSON 结果,不把文件正文写进 durable outcome。 + +## 平台能力矩阵 + +| 平台 | 当前承诺 | +| --- | --- | +| Linux | Gitoxide helper 的 candidate CAS 与精确重试;CI 必须运行真实 helper。 | +| macOS | 与 Linux 相同;不在本切片声明断电持久性。 | +| Windows | 与 Linux 相同;不依赖 POSIX rename 或系统 Git。 | + +## 后续闭环 + +Runtime-owned outcome、SQLite successor writer、baseline session owner 与 ref projection 已串入 +`managed-coding-v1` Host backend。三平台打包 helper lane 负责证明 Rust import exact retry; +production-shaped 子进程测试会在 SQLite 已提交、projection 尚未推进时杀死执行进程,再由新 owner +重开同一 Session、只推进 exact candidate ref,并验证第二次重开仍停留在同一 revision。 + +当前仍保持 Draft:Windows 完整证据必须由 CI 实际通过,Desktop/CLI 产品入口尚未开放,M3 也只能在 +该 crash seam 稳定后开始绑定 continuation boundary。 diff --git a/docs/architecture/managed-dependency-producer-boundary-v1.zh-CN.md b/docs/architecture/managed-dependency-producer-boundary-v1.zh-CN.md new file mode 100644 index 0000000000..dfe56902f2 --- /dev/null +++ b/docs/architecture/managed-dependency-producer-boundary-v1.zh-CN.md @@ -0,0 +1,176 @@ + + +--- +document_status: implementation-contract +status: draft-stacked-foundation +date: 2026-08-24 +milestone: M1.3 +base: upstream/main@2c9140354 +--- + +# Managed Dependency Producer Boundary v1 + +## 1. 本 PR 只证明一个主要不变量 + +> 在 storage authority 复制 producer 输出以前,固定 npm producer 必须只在一次性 Maka-owned staging 中运行;它不能继承 host secrets、不能创建 child process、不能执行 lifecycle script,并且只有在根进程退出、输出 drain、最终 inventory 与 observed-limit 验证全部完成后才允许 `provision()` resolve。 + +本 PR 的候选 owner 是 Runtime Host 模块内部的 `runManagedNpmDependencyProvision()`。它拥有固定 npm argv、hermetic environment、Node permission profile、staging project layout、manifest/lockfile admission、timeout/abort、process lifecycle、bounded diagnostics,以及运行中和终态 filesystem inventory。 + +`runManagedNpmDependencyProvision()` 与低层 `runManagedDependencyProducerProcessInternal()` 均不通过 Runtime Host server barrel 暴露。PR 2 没有 runtime attestation owner,因此任何 raw executable path 都不能成为 production 输入。PR 3 必须先把经过 manifest/digest 验证的、有限 Node major allowlist 内的 bundled runtime capability 与该模块共同落地,之后才能公开固定 npm 入口。后续 PR 不能绕过该入口自行 spawn package manager。 + +本 PR 不包含: + +- bundled npm/Node 文件树、manifest、digest、license 或 release packaging; +- Desktop、CLI、Gitoxide managed-workspace composition 接线; +- dependency lease 到 Read/Glob/Grep worker 的 logical binding; +- Shell/Build、Write/Edit 或 workspace mutation; +- 用户 PATH 上的 npm fallback; +- production network broker。 + +因此本 PR 必须保持 Draft。它与 PR 1、PR 3 一样没有独立用户能力;PR 3 完成 runtime attestation 前,当前固定 npm 入口也不能成为生产 API。只有 PR 4 的 production consumer 和端到端测试成立后,整个 stack 才能按顺序转 Ready。 + +## 2. 固定 producer 协议 + +v1 只接受: + +```text +package manager: npm 12.0.2 +manifest: packageManager == npm@12.0.2 +lockfile: non-workspace package-lock v3 +resolved URL: https://registry.npmjs.org/** +integrity: sha1/sha256/sha384/sha512 SRI +link dependency: rejected +hasInstallScript: rejected +package entries: <= 25,000 +manifest bytes: <= 1 MiB +lockfile bytes: <= 64 MiB +``` + +固定 invocation: + +```text +verified-node + --permission + --allow-fs-read= + --allow-fs-read= + --allow-fs-write= + + ci + --ignore-scripts + --no-audit + --no-fund + --package-lock=true + --cache= + --userconfig= + --globalconfig= +``` + +没有 `--allow-child-process`,因此 production npm root process 不能创建 descendant。`PATH`、`NODE_OPTIONS`、proxy、credential、registry token 和任意 host environment 都不继承;HOME、npm config、temp 与 Node compile cache 全部指向同一次 staging 的 scratch。 + +PR 3 必须提供经过完整 manifest/digest 验证的 Node executable、npm runtime root 与 npm CLI,并使用有限 Node major allowlist。它必须在同一 Runtime Host package 内完成不可伪造 capability 的发行与消费,再由 server barrel 公开组合后的入口;不能仅把 raw path 验证留给调用者。它也不能覆盖 argv、env、timeout 或 observed limit。 + +## 3. Owner、时序和失败状态 + +```text +validate identity + manifest + lockfile + -> canonicalize exact output/scratch/runtime paths + -> create owned scratch children with exclusive creation + -> write exact npm configs + manifest + lockfile + -> spawn one detached root process with no child-process capability + -> monitor whole staging project every 100 ms + -> abort / timeout / invalid tree / observed limit: terminate process tree and await exit + I/O drain + -> normal root exit + -> await stdout/stderr drain + -> final complete inventory + -> exit code == 0 + -> provision resolves + -> PR 1 storage authority may deep-copy producer output +``` + +稳定失败原因: + +```text +aborted +timeout +filesystem_limit_exceeded +filesystem_invalid +output_drain_incomplete +process_failed +``` + +失败不会发布 artifact 或 receipt。transaction root 仍由 PR 1 storage authority 拥有并在 producer rejection 后删除;PR 2 不新增第二个 durable owner、数据库或 cleanup journal。 + +## 4. Filesystem inventory 与 `.bin` + +默认 soft observed limit 固定为 2 GiB 和 250,000 entries,不能由 PR 3/4 调高。它通过 100 ms polling 与最终 inventory 保证“超限结果绝不进入 artifact publication”,但不是 OS/filesystem 强制的峰值磁盘 quota;producer 可在相邻 observation 之间短暂超写。若产品要求防止磁盘被瞬时写满,必须另建 OS quota、受控写入 broker 或等价平台 owner,不能把 polling 描述为 disk-safety boundary。 + +空文件、目录、普通文件和合法 symlink 都计入 observed entry limit;普通文件 size 与 symlink target bytes 计入 observed byte limit。进程退出后必须再做一次完整终检,避免短命 producer 在 monitor tick 前超限后退出。 + +Linux/macOS 允许 npm 的典型相对 `.bin` symlink,例如: + +```text +node_modules/.bin/tool -> ../package/bin/tool.js +``` + +target 必须按 link 所在目录解析后仍位于 staging project 内。absolute/escaping symlink fail closed。Windows 的 npm shim 应为普通 `.cmd/.ps1` 文件;symlink、junction/reparse point 在 inventory 或后续 PR 1 artifact seal 中拒绝。 + +## 5. 平台能力矩阵 + +| 平台 | child process | timeout/abort | `.bin` | 保证 | +|---|---|---|---|---| +| Linux | PR 3 attested Node permission 禁止;异常终止用 process group + descendant scan | root 存活时先收割树,再 reject | contained relative symlink | production-shaped POSIX 测试必须执行 | +| macOS | PR 3 attested Node permission 禁止;异常终止用 process group + descendant scan | root 存活时先收割树,再 reject | contained relative symlink | `/var` alias 由 canonical path 处理 | +| Windows | PR 3 attested Node permission 禁止;异常终止用 `taskkill /T /F` | root 存活时先收割树,再 reject | 普通 npm shim;reparse 拒绝 | 无 Job Object;禁止任意可生 descendant 的 producer | + +Node permission model 与 PR 3 runtime attestation 必须共同存在,才构成 production root-only 证明;二者都不是可选 hardening。内部任意 argv primitive 不承诺在 root 已退出后回收 detached descendant,且不得成为 production consumer。若未来 package manager 必须启动 child process,必须定义新的 capability/policy identity,并为 POSIX 引入 cgroup/subreaper 或为 Windows 引入 Job Object 等真实平台 owner;不能在 `hermetic_dependency_builder_v1` 下静默加入 `--allow-child-process`。 + +## 6. Network 边界与尚未闭环的证明 + +本 PR 固定官方 registry config,并在 spawn 前拒绝非官方 `resolved` URL;无 lifecycle script、无 child process、无 host proxy/credential 环境。它没有单独提供一个 OS 级 host allowlist。 + +因此 `registry_https_only` 的完整证明依赖后续 attestation 切片对 npm runtime tree 的不可变验证,以及 Gitoxide product composition/egress 决策。本 PR 不能单独被描述为已经提供强网络 sandbox。若产品要求网络层也成为强制 host allowlist,应增加 host-owned registry fetch broker 或等价执行边界,并产生新的 production-shaped 网络对抗测试;不能仅靠文案把 npm config 当作 OS enforcement。 + +## 7. Crash 与对抗矩阵 + +| 场景 | 唯一合法结果 | +|---|---| +| manifest/lockfile 不满足固定 policy | spawn 前拒绝 | +| scratch child 被 symlink/junction 预占 | spawn 前拒绝;outside 不写入 | +| npm 尝试创建 child process | Node permission 拒绝;不产生 descendant side effect | +| caller abort | tree 完全退出且 I/O drain 后返回 `aborted` | +| timeout | tree 完全退出且 I/O drain 后返回 `timeout` | +| observed byte/entry limit 超限 | tree 完全退出后返回 `filesystem_limit_exceeded`;超限内容不发布,但不承诺峰值磁盘占用 | +| escaping/unsupported entry | tree 完全退出后返回 `filesystem_invalid` | +| root exit 非零 | bounded stderr/stdout tail 随 `process_failed` 返回 | +| output 在 deadline 前未 drain | 返回 `output_drain_incomplete`;该状态禁止发布。PR 3 通过 attested no-child runtime 使 descendant 形状不可达 | +| producer 成功后 storage deep-copy 前 host 崩溃 | PR 1 启动清理 staging,无 artifact/receipt | + +child-process crash test 只能证明进程生命周期,不是断电测试。PR 2 不写 durable fact,所以没有 schema migration 或数据库 recovery 语义。 + +## 8. Extraction ledger + +| 旧集成提交 | PR 2 处理 | +|---|---| +| `89c9e0a3c feat(runtime-host): ship verified bundled npm environments` | 只提取固定 npm argv/env、input validation 与 process-owner 轮廓;runtime manifest/release 归 PR 3 | +| `8150e90a7 fix(runtime-host): constrain bundled npm provisioning` | 重写 timeout/abort、quota、scratch 与 `.bin` policy;不迁移仅 `child.kill()` 的旧生命周期 | +| `27f8f6b8e fix(release): verify shipped bundled npm closure` | 不属于 PR 2;全部留给 PR 3 | +| `9a42a761c fix(runtime-host): transport packaged dependency authority` | 不属于 PR 2;production composition 留给 PR 4 | + +本 PR 从最新 `upstream/main` 平铺重建 producer owner,没有 cherry-pick 上述跨边界提交。最终 production composition 只允许消费 Gitoxide admission/import/projection capability,不得重新引入 Git CLI owner。 diff --git a/docs/architecture/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md index 6d54c29dab..4e75d19b18 100644 --- a/docs/architecture/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md @@ -183,12 +183,15 @@ SQLite transaction 提供三平台一致的数据库原子性;本切片不声 主要不变量:只有 Git artifact owner 能把一个 operation-bound candidate 证明为 base head 的合法 successor。 -- owner:managed Git workspace service; -- 原子边界:candidate ref/commit publication 与 durable candidate receipt; -- 失败状态:base drift、额外路径、ignored mutation、artifact missing、unknown metadata 全部 park/fail closed; +- owner:Runtime Host 内部 Gitoxide mutation candidate authority; +- 原子边界:从 exact accepted base 对 operation-bound candidate ref 执行 CAS,并写 durable derived receipt; +- 失败状态:base drift、candidate ref 冲突、artifact/receipt missing 或 identity mismatch 全部 fail closed; - 回滚:未被 SQLite 接受的 candidate 是 orphan,可按 receipt/ref 证明后回收; - 不做:不写 T2,不推进 workspace head,不接 Desktop/CLI。 +Gitoxide 版不从 mutable worktree 扫描 delta。Write/Edit 在 M2.4 中会先成为受限纯转换,再把 exact result +content/blob 交给 M2.2 固化,因此“额外路径/ignored mutation”不再是 candidate capture 的输入状态。 + 该 PR 在 M2.4 消费者存在前保持 Draft。 ### M2.3a — Durable mutation reservation diff --git a/docs/architecture/runtime-workspace-version-authority-v1.zh-CN.md b/docs/architecture/runtime-workspace-version-authority-v1.zh-CN.md index 7836432f20..43ffc7734d 100644 --- a/docs/architecture/runtime-workspace-version-authority-v1.zh-CN.md +++ b/docs/architecture/runtime-workspace-version-authority-v1.zh-CN.md @@ -330,8 +330,20 @@ SQLite read transaction/snapshot;否则并发 writer 可能让读者拼接两 只证明:Maka 能用一个显式注入且经过校验的 Git runtime 创建并独占 private internal repository/worktree lifecycle;外部 drift 被检测后 quarantine。ASF Desktop 不再提供该 runtime,后续实现将 -验证 Apache-2.0/MIT 的 gitoxide backend。需要先拍板 ignored dependencies/scratch、identity marker、fixed -Git config、symlink/LFS/submodule/case/filemode 平台政策。 +验证 Apache-2.0/MIT 的 gitoxide backend。旧 Git-CLI-shaped service 仅作为历史/测试实现,不能成为 +新生产 backend 的 identity owner。 + +当前 Gitoxide 验证栈已拆成三个窄 Draft:isolated short-lived Rust helper、exact helper artifact → +opaque invocation capability、bounded invocation → opaque repository admission capability。分别见: + +- [`gitoxide-short-lived-helper-admission-v1.zh-CN.md`](./gitoxide-short-lived-helper-admission-v1.zh-CN.md) +- [`gitoxide-helper-artifact-authority-v1.zh-CN.md`](./gitoxide-helper-artifact-authority-v1.zh-CN.md) +- [`gitoxide-helper-invocation-owner-v1.zh-CN.md`](./gitoxide-helper-invocation-owner-v1.zh-CN.md) +- [`gitoxide-repository-admission-capability-v1.zh-CN.md`](./gitoxide-repository-admission-capability-v1.zh-CN.md) + +这组 Draft 尚未建立 signed packaged-release trust root,也没有 Desktop/CLI/T1 消费者,因此不能据此 +恢复 managed mode。后续生产接线仍需先拍板 ignored dependencies/scratch、identity marker、 +symlink/LFS/submodule/case/filemode 平台政策。 ### Slice 3:Baseline Open Bundle(实现中) diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 61635cf998..eeab6762c0 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -15,11 +15,11 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| -| windows-backend-gap | 20 | +| windows-backend-gap | 22 | | portable-candidate | 8 | -| platform-contract | 35 | +| platform-contract | 36 | -Total Windows-excluded declarations: **63** +Total Windows-excluded declarations: **66** ## Inventory @@ -41,6 +41,9 @@ Total Windows-excluded declarations: **63** | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` a non-reading Client overload is isolated to its connection | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` reports one shutdown failure through close and closed while releasing ownership | `process.platform === 'win32'` | | platform-contract | `packages/runtime-host/src/__tests__/host-kernel.test.ts` publishes private POSIX endpoint and registration permissions | `process.platform === 'win32'` | +| platform-contract | `packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts` reaps a surviving descendant after the direct producer accepts SIGTERM | `process.platform === 'win32' ? 'POSIX detached process-group semantics required' : false` | +| windows-backend-gap | `packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts` accepts and accounts for a contained npm bin symlink on POSIX | `process.platform === 'win32'` | +| windows-backend-gap | `packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts` rejects an escaping producer symlink as invalid output instead of a quota failure | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/memory-two-client-uds.test.ts` two UDS clients share one recoverable Memory authority across Host death | `process.platform === 'win32' ? 'POSIX process death gate' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/project-catalog-two-client-uds.test.ts` two UDS clients converge on one Host-owned Project Catalog | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts` invalidates when a real published mutation loses its commit reply | `process.platform === 'win32'` | diff --git a/native/gitoxide-helper/Cargo.lock b/native/gitoxide-helper/Cargo.lock new file mode 100644 index 0000000000..8d00ea3da4 --- /dev/null +++ b/native/gitoxide-helper/Cargo.lock @@ -0,0 +1,1433 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "bisync" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5020822f6d6f23196ccaf55e228db36f9de1cf788052b37992e17cbc96ec41a7" +dependencies = [ + "bisync_macros", +] + +[[package]] +name = "bisync_macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gix" +version = "0.86.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce" +dependencies = [ + "gix-actor", + "gix-commitgraph", + "gix-config", + "gix-date", + "gix-diff", + "gix-discover", + "gix-error", + "gix-features", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-lock", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree-stream", + "gix-zlib", + "nonempty", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-actor" +version = "0.41.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-attributes" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31c593692ebdc1e38858d9a2b56f6a594c501e24a38971fe6685571f5a07be0" +dependencies = [ + "bstr", + "gix-features", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-chunk" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4363accdf6ef7ba861871d2d521ab7418a04aaaed919fadb022af71d379b12" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2cd7f054ae2727223fe46dd39c012f066b12f532962d336d29ee193261787da" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103d11bef95c467577ecfa8b7b86a22e65af3507b2c9bfa3809a4afbae7df301" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "gix-utils", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6af5321bfd3711a279d6b244d58532ba1cfabf9eb6374791f19929d8970082" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-path", + "libc", + "thiserror", +] + +[[package]] +name = "gix-date" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc" +dependencies = [ + "bstr", + "gix-hash", + "gix-object", + "thiserror", +] + +[[package]] +name = "gix-discover" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f517766fa1101dfe2606c1a19a8ffa699099030995a9194445446dfe261bdf" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror", +] + +[[package]] +name = "gix-error" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.49.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c0e59d9d253dcccc38c3a46b91bfb9b46bd63eed54fe1a719e12194884d52a" +dependencies = [ + "bytes", + "crc32fast", + "crossbeam-channel", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "parking_lot", + "prodash", + "walkdir", +] + +[[package]] +name = "gix-filter" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7b5dbf524d97e839f642930c76d7f011c0791e7d11d8148989ac5af7c76aa8" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-fs" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcfa9fd253f25350a3b21b3dd74034a446098e373c6123d4cee3519894f12ef" +dependencies = [ + "bstr", + "gix-features", + "gix-path", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-glob" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b417cf515fd8c91468b578071f76d6cba716f8a1eccd853906bff4908b2c1413" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf125eae66b7d6e4395511a06c0d43a3c34eac96c8641fb98b22078faee65b8" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "sha2", + "thiserror", +] + +[[package]] +name = "gix-hashtable" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78fccd6fea3bcf0b39c076bae60ae49b08daaf538b950202101a981f9d3c01d3" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-lock" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c69157820343bf1c6e4b88b9808e920900de02e18aaf5862b30ada43814848" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-object" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48c235e7f886eb819fc878af75be889333dd3c38bee02ed7af48ae2cf596c4" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-odb" +version = "0.83.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dd494ffb5037e62b8220109e894d2861ff2150a2cacbfccdba57ae1ebab2b96" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "gix-zlib", + "memmap2", + "parking_lot", + "tempfile", + "thiserror", +] + +[[package]] +name = "gix-pack" +version = "0.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "gix-zlib", + "memmap2", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-packetline" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3766025c72319c4accdd854a18e6f0dd176c8eb0f3bc8a60a7765be2b50cabf2" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror", +] + +[[package]] +name = "gix-path" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b075e730586bba7341304d6fc1b4efc1d10cf64532622521c0e07f30e661046" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror", +] + +[[package]] +name = "gix-protocol" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dede40e89c1e90f548415f50636bb051f6d9c60f68b8b710bc07825722d19588" +dependencies = [ + "bisync", + "bstr", + "gix-date", + "gix-features", + "gix-hash", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb0c90a8f6202ceaaa22996cbf837c943ccb2d8af9ff3490f0758305e6b7883" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror", +] + +[[package]] +name = "gix-refspec" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7406282cc0259b51f6aee299ca3d31279a020530363152a2e6c96e8a7f7bbc83" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-revision" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681" +dependencies = [ + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c113c0a53294dc6280ffc06cbcc4f50f820397e97d6a00b429a44b8db26e29" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-sec" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" +dependencies = [ + "bitflags 2.13.1", + "gix-path", + "libc", + "windows-sys", +] + +[[package]] +name = "gix-shallow" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecc9f4b40537043e4bbd7d3d1760e74fb8e7b07a546166b558acaa73ad97f4a" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-tempfile" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e" +dependencies = [ + "gix-fs", + "libc", + "parking_lot", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" + +[[package]] +name = "gix-transport" +version = "0.58.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f36d045b840f8aeee1a527e677eab1fbebfbbe94bf2e708fa81d0b4b742d5fc" +dependencies = [ + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-sec", + "gix-url", + "thiserror", +] + +[[package]] +name = "gix-traverse" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008c5cd879e46e86b5c2469e633611978b18775d53d05668d691bc13088bd409" +dependencies = [ + "bitflags 2.13.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-url" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31bdfc93aa880cda3272718a5879ce3aa7723fa13514320dd6608151607afe72" +dependencies = [ + "bstr", + "gix-path", + "gix-utils", + "percent-encoding", + "thiserror", +] + +[[package]] +name = "gix-utils" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97" +dependencies = [ + "bstr", + "fastrand", + "getrandom", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dae8780f63ed8a803b8bdabbd7aa5f5c5d74592c8b50eed875c1bb4f6545a6a" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b088c8724e7be120c4798dd86925cf05332c9d356a463542578600c50c7a549" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", +] + +[[package]] +name = "gix-zlib" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e8813f5579b3075ff9c90f7c59cd2b62b4ebb639361f0911648b22d7446cc7c" +dependencies = [ + "thiserror", + "zlib-rs", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "maka-gitoxide-helper" +version = "0.0.0" +dependencies = [ + "gix", + "libc", + "serde", + "serde_json", + "unicode-normalization", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/gitoxide-helper/Cargo.toml b/native/gitoxide-helper/Cargo.toml new file mode 100644 index 0000000000..2d0f8c547c --- /dev/null +++ b/native/gitoxide-helper/Cargo.toml @@ -0,0 +1,37 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[package] +name = "maka-gitoxide-helper" +version = "0.0.0" +edition = "2024" +license = "Apache-2.0" +rust-version = "1.98" +publish = false + +[[bin]] +name = "maka-gitoxide-helper" +path = "src/main.rs" + +[dependencies] +gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha256", "tree-editor"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +unicode-normalization = "0.1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/native/gitoxide-helper/rust-toolchain.toml b/native/gitoxide-helper/rust-toolchain.toml new file mode 100644 index 0000000000..bfeff488e4 --- /dev/null +++ b/native/gitoxide-helper/rust-toolchain.toml @@ -0,0 +1,21 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[toolchain] +channel = "1.98.0" +components = ["rustfmt"] +profile = "minimal" diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs new file mode 100644 index 0000000000..5c9f1dd653 --- /dev/null +++ b/native/gitoxide-helper/src/main.rs @@ -0,0 +1,1758 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use std::{ + collections::HashSet, + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use serde::{Deserialize, Serialize}; +use unicode_normalization::UnicodeNormalization; + +const PROTOCOL_VERSION: u8 = 1; +const MAX_REQUEST_BYTES: u64 = MAX_IMPORT_FILE_BYTES + 64 * 1024; +const MAX_IMPORT_FILE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_IMPORT_BYTES: u64 = 2 * 1024 * 1024 * 1024; +const MAX_IMPORT_FILES: u64 = 200_000; +const MANAGED_TREE_POLICY_V1: ManagedTreePolicy = ManagedTreePolicy { + max_depth: 64, + max_tree_visits: 250_000, + max_entries: 400_000, + max_total_path_bytes: 256 * 1024 * 1024, + max_component_bytes: 255, + max_relative_path_bytes: 4096, + max_files: MAX_IMPORT_FILES, + max_file_bytes: MAX_IMPORT_FILE_BYTES, + max_bytes: MAX_IMPORT_BYTES, +}; +const MAX_TREE_FILE_BYTES: u64 = 8 * 1024 * 1024; + +#[derive(Deserialize)] +#[serde( + deny_unknown_fields, + tag = "operation", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +enum Request { + InspectRepository { + protocol_version: u8, + repository_path: PathBuf, + }, + ImportSourceHead { + protocol_version: u8, + source_repository_path: PathBuf, + expected_source_head_commit_oid: String, + destination_repository_path: PathBuf, + baseline_ref: String, + }, + CreateSuccessor { + protocol_version: u8, + repository_path: PathBuf, + expected_base_commit_oid: String, + target_ref: String, + path: String, + content: String, + }, + PrepareCandidate { + protocol_version: u8, + repository_path: PathBuf, + expected_base_commit_oid: String, + accepted_ref: String, + candidate_ref: String, + path: String, + content: String, + }, + InspectRef { + protocol_version: u8, + repository_path: PathBuf, + target_ref: String, + }, + PromoteCandidate { + protocol_version: u8, + repository_path: PathBuf, + expected_base_commit_oid: String, + accepted_ref: String, + candidate_ref: String, + expected_candidate_commit_oid: String, + }, + MaterializeProjection { + protocol_version: u8, + repository_path: PathBuf, + accepted_commit_oid: String, + destination_path: PathBuf, + }, + ObserveProjection { + protocol_version: u8, + repository_path: PathBuf, + accepted_commit_oid: String, + projection_path: PathBuf, + }, + ReadTreeFile { + protocol_version: u8, + repository_path: PathBuf, + accepted_commit_oid: String, + path: String, + }, +} + +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum Response<'a> { + #[serde(rename_all = "camelCase")] + RepositoryInspected { + protocol_version: u8, + object_format: &'static str, + head_commit_oid: String, + head_tree_oid: String, + }, + #[serde(rename_all = "camelCase")] + RepositoryRejected { + protocol_version: u8, + reason: &'static str, + object_format: String, + supported_object_formats: [&'static str; 1], + }, + #[serde(rename_all = "camelCase")] + SourceImported { + protocol_version: u8, + object_format: &'static str, + source_head_commit_oid: String, + source_tree_oid: String, + baseline_commit_oid: String, + baseline_tree_oid: String, + baseline_ref: String, + files_imported: u64, + bytes_imported: u64, + }, + #[serde(rename_all = "camelCase")] + SuccessorPublished { + protocol_version: u8, + object_format: &'static str, + base_commit_oid: String, + successor_commit_oid: String, + successor_tree_oid: String, + result_blob_oid: String, + target_ref: String, + path: String, + }, + #[serde(rename_all = "camelCase")] + SuccessorRejected { + protocol_version: u8, + reason: &'static str, + object_format: &'static str, + expected_base_commit_oid: String, + actual_base_commit_oid: String, + target_ref: String, + }, + #[serde(rename_all = "camelCase")] + RefInspected { + protocol_version: u8, + object_format: &'static str, + commit_oid: String, + tree_oid: String, + target_ref: String, + }, + #[serde(rename_all = "camelCase")] + CandidatePromoted { + protocol_version: u8, + object_format: &'static str, + base_commit_oid: String, + candidate_commit_oid: String, + accepted_ref: String, + candidate_ref: String, + }, + #[serde(rename_all = "camelCase")] + ProjectionMaterialized { + protocol_version: u8, + object_format: &'static str, + accepted_commit_oid: String, + accepted_tree_oid: String, + destination_path: PathBuf, + files_materialized: u64, + bytes_written: u64, + }, + #[serde(rename_all = "camelCase")] + ProjectionObserved { + protocol_version: u8, + object_format: &'static str, + state: &'static str, + accepted_commit_oid: String, + accepted_tree_oid: String, + projection_path: PathBuf, + files_observed: u64, + bytes_read: u64, + }, + #[serde(rename_all = "camelCase")] + ProjectionDrifted { + protocol_version: u8, + object_format: &'static str, + state: &'static str, + reason: &'static str, + path: String, + accepted_commit_oid: String, + accepted_tree_oid: String, + projection_path: PathBuf, + }, + #[serde(rename_all = "camelCase")] + TreeFileRead { + protocol_version: u8, + object_format: &'static str, + accepted_commit_oid: String, + accepted_tree_oid: String, + blob_oid: String, + path: String, + content: String, + bytes_read: u64, + }, + #[serde(rename_all = "camelCase")] + HelperError { + protocol_version: u8, + reason: &'a str, + }, +} + +fn main() -> ExitCode { + match run() { + Ok(code) => code, + Err(reason) => { + write_response(&Response::HelperError { + protocol_version: PROTOCOL_VERSION, + reason, + }); + ExitCode::from(1) + } + } +} + +fn run() -> Result { + let request = read_request()?; + match request { + Request::InspectRepository { + protocol_version, + repository_path, + } => { + assert_protocol_version(protocol_version)?; + inspect_repository(repository_path) + } + Request::ImportSourceHead { + protocol_version, + source_repository_path, + expected_source_head_commit_oid, + destination_repository_path, + baseline_ref, + } => { + assert_protocol_version(protocol_version)?; + import_source_head( + source_repository_path, + expected_source_head_commit_oid, + destination_repository_path, + baseline_ref, + ) + } + Request::CreateSuccessor { + protocol_version, + repository_path, + expected_base_commit_oid, + target_ref, + path, + content, + } => { + assert_protocol_version(protocol_version)?; + create_successor( + repository_path, + expected_base_commit_oid, + target_ref, + path, + content, + false, + ) + } + Request::PrepareCandidate { + protocol_version, + repository_path, + expected_base_commit_oid, + accepted_ref, + candidate_ref, + path, + content, + } => { + assert_protocol_version(protocol_version)?; + prepare_candidate( + repository_path, + expected_base_commit_oid, + accepted_ref, + candidate_ref, + path, + content, + ) + } + Request::InspectRef { + protocol_version, + repository_path, + target_ref, + } => { + assert_protocol_version(protocol_version)?; + inspect_ref(repository_path, target_ref) + } + Request::PromoteCandidate { + protocol_version, + repository_path, + expected_base_commit_oid, + accepted_ref, + candidate_ref, + expected_candidate_commit_oid, + } => { + assert_protocol_version(protocol_version)?; + promote_candidate( + repository_path, + expected_base_commit_oid, + accepted_ref, + candidate_ref, + expected_candidate_commit_oid, + ) + } + Request::MaterializeProjection { + protocol_version, + repository_path, + accepted_commit_oid, + destination_path, + } => { + assert_protocol_version(protocol_version)?; + materialize_projection(repository_path, accepted_commit_oid, destination_path) + } + Request::ObserveProjection { + protocol_version, + repository_path, + accepted_commit_oid, + projection_path, + } => { + assert_protocol_version(protocol_version)?; + observe_projection(repository_path, accepted_commit_oid, projection_path) + } + Request::ReadTreeFile { + protocol_version, + repository_path, + accepted_commit_oid, + path, + } => { + assert_protocol_version(protocol_version)?; + read_tree_file(repository_path, accepted_commit_oid, path) + } + } +} + +fn assert_protocol_version(protocol_version: u8) -> Result<(), &'static str> { + if protocol_version != PROTOCOL_VERSION { + return Err("unsupported_protocol_version"); + } + Ok(()) +} + +fn inspect_repository(repository_path: PathBuf) -> Result { + let repository = match gix::open::Options::isolated() + .strict_config(true) + .open(repository_path) + { + Ok(repository) => repository.to_thread_local(), + Err(gix::open::Error::Config(gix::config::Error::ConfigTypedString(error))) + if error.key.as_slice() == b"extensions.objectFormat" => + { + let object_format = error + .value + .as_ref() + .map(|value| String::from_utf8_lossy(value.as_slice()).into_owned()) + .unwrap_or_else(|| "unknown".to_owned()); + return Ok(reject_unsupported_object_format(object_format)); + } + Err(gix::open::Error::Config(gix::config::Error::UnsupportedObjectFormat { name })) => { + let object_format = String::from_utf8_lossy(name.as_slice()).into_owned(); + return Ok(reject_unsupported_object_format(object_format)); + } + Err(_) => return Err("repository_open_failed"), + }; + + match repository.object_hash() { + gix::hash::Kind::Sha1 => { + let head = repository + .head_commit() + .map_err(|_| "head_commit_unavailable")?; + let head_commit_oid = head.id().detach().to_string(); + let head_tree_oid = head + .tree_id() + .map_err(|_| "head_tree_unavailable")? + .detach() + .to_string(); + write_response(&Response::RepositoryInspected { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + head_commit_oid, + head_tree_oid, + }); + Ok(ExitCode::SUCCESS) + } + gix::hash::Kind::Sha256 => Ok(reject_unsupported_object_format("sha256".to_owned())), + _ => Ok(reject_unsupported_object_format("unknown".to_owned())), + } +} + +fn open_repository(repository_path: PathBuf) -> Result { + Ok(gix::open::Options::isolated() + .strict_config(true) + .open(repository_path) + .map_err(|_| "repository_open_failed")? + .to_thread_local()) +} + +fn import_source_head( + source_repository_path: PathBuf, + expected_source_head_commit_oid: String, + destination_repository_path: PathBuf, + baseline_ref: String, +) -> Result { + use gix::bstr::ByteSlice; + + if !baseline_ref.starts_with("refs/maka/") { + return Err("baseline_ref_outside_maka_namespace"); + } + let source = open_repository(source_repository_path)?; + if source.object_hash() != gix::hash::Kind::Sha1 { + return Err("unsupported_object_format"); + } + let expected_source_head = + gix::hash::ObjectId::from_hex(expected_source_head_commit_oid.as_bytes()) + .map_err(|_| "invalid_source_head_commit_oid")?; + if expected_source_head.kind() != gix::hash::Kind::Sha1 { + return Err("invalid_source_head_commit_oid"); + } + let source_head = source + .head_commit() + .map_err(|_| "source_head_commit_unavailable")?; + if source_head.id().detach() != expected_source_head { + return Err("source_head_commit_mismatch"); + } + let source_tree = source_head + .tree_id() + .map_err(|_| "source_head_tree_unavailable")? + .detach(); + + let destination = match fs::symlink_metadata(&destination_repository_path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + open_repository(destination_repository_path.clone())? + } + Ok(_) => return Err("import_destination_not_fresh"), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + gix::init_bare(&destination_repository_path) + .map_err(|_| "import_destination_create_failed")? + } + Err(_) => return Err("import_destination_unreadable"), + }; + if destination.object_hash() != gix::hash::Kind::Sha1 { + return Err("import_destination_object_format_mismatch"); + } + + fs::remove_dir_all(destination_repository_path.join("hooks")) + .map_err(|_| "import_hooks_cleanup_failed")?; + fs::create_dir(destination_repository_path.join("hooks")) + .map_err(|_| "import_hooks_cleanup_failed")?; + + let mut stats = ManagedTreeStats::default(); + copy_source_tree( + &source, + &destination, + source_tree, + "", + 0, + MANAGED_TREE_POLICY_V1, + &mut stats, + )?; + + let signature = gix::actor::SignatureRef { + name: b"Maka Workspace Service".as_bstr(), + email: b"workspace@maka.invalid".as_bstr(), + time: "946684800 +0000", + }; + let baseline_commit = destination + .new_commit_as( + signature, + signature, + "maka managed workspace baseline v1", + source_tree, + std::iter::empty::(), + ) + .map_err(|_| "baseline_commit_write_failed")? + .id() + .detach(); + match destination + .try_find_reference(baseline_ref.as_str()) + .map_err(|_| "baseline_publish_failed")? + { + Some(reference) => { + let current = reference + .into_fully_peeled_id() + .map_err(|_| "baseline_publish_failed")? + .detach(); + if current != baseline_commit { + return Err("baseline_publish_conflict"); + } + } + None => { + destination + .reference( + baseline_ref.as_str(), + baseline_commit, + gix::refs::transaction::PreviousValue::MustNotExist, + "maka managed workspace baseline", + ) + .map_err(|_| "baseline_publish_failed")?; + } + } + + write_response(&Response::SourceImported { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + source_head_commit_oid: expected_source_head.to_string(), + source_tree_oid: source_tree.to_string(), + baseline_commit_oid: baseline_commit.to_string(), + baseline_tree_oid: source_tree.to_string(), + baseline_ref, + files_imported: stats.files, + bytes_imported: stats.bytes, + }); + Ok(ExitCode::SUCCESS) +} + +fn copy_source_tree( + source: &gix::Repository, + destination: &gix::Repository, + tree_oid: gix::hash::ObjectId, + prefix: &str, + depth: u64, + policy: ManagedTreePolicy, + stats: &mut ManagedTreeStats, +) -> Result<(), &'static str> { + stats.enter_tree(depth, policy)?; + let tree = source + .find_tree(tree_oid) + .map_err(|_| "source_tree_unavailable")?; + for entry in tree.iter() { + let entry = entry.map_err(|_| "source_tree_invalid")?; + let component = + std::str::from_utf8(entry.filename()).map_err(|_| "unsupported_source_path")?; + if !is_supported_source_component(component) + || component.len() as u64 > policy.max_component_bytes + { + return Err("unsupported_source_path"); + } + let relative_path = if prefix.is_empty() { + component.to_owned() + } else { + format!("{prefix}/{component}") + }; + stats.observe_entry(&relative_path, policy)?; + match entry.mode().kind() { + gix::objs::tree::EntryKind::Tree => { + copy_source_tree( + source, + destination, + entry.object_id(), + &relative_path, + depth.checked_add(1).ok_or("source_tree_depth_exceeded")?, + policy, + stats, + )?; + } + gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable => { + let header = entry.id().header().map_err(|_| "source_blob_unavailable")?; + if header.kind() != gix::objs::Kind::Blob { + return Err("source_blob_invalid"); + } + stats.observe_blob(header.size(), policy)?; + let blob = entry + .object() + .map_err(|_| "source_blob_unavailable")? + .try_into_blob() + .map_err(|_| "source_blob_invalid")?; + let copied_blob = destination + .write_blob(&blob.data) + .map_err(|_| "source_blob_copy_failed")? + .detach(); + if copied_blob != entry.object_id() { + return Err("source_blob_identity_mismatch"); + } + } + _ => return Err("unsupported_source_entry_kind"), + } + } + let copied_tree = destination + .write_object(tree.decode().map_err(|_| "source_tree_invalid")?) + .map_err(|_| "source_tree_copy_failed")? + .detach(); + if copied_tree != tree_oid { + return Err("source_tree_identity_mismatch"); + } + Ok(()) +} + +fn prepare_candidate( + repository_path: PathBuf, + expected_base_commit_oid: String, + accepted_ref: String, + candidate_ref: String, + path: String, + content: String, +) -> Result { + if !accepted_ref.starts_with("refs/maka/") { + return Err("target_ref_outside_maka_namespace"); + } + if !candidate_ref.starts_with("refs/maka/candidates/") || candidate_ref == accepted_ref { + return Err("candidate_ref_outside_candidate_namespace"); + } + let repository = open_repository(repository_path.clone())?; + if repository.object_hash() != gix::hash::Kind::Sha1 { + return Err("unsupported_object_format"); + } + let expected_base = gix::hash::ObjectId::from_hex(expected_base_commit_oid.as_bytes()) + .map_err(|_| "invalid_base_commit_oid")?; + if expected_base.kind() != gix::hash::Kind::Sha1 { + return Err("invalid_base_commit_oid"); + } + let accepted = repository + .find_reference(accepted_ref.as_str()) + .map_err(|_| "target_ref_unavailable")? + .into_fully_peeled_id() + .map_err(|_| "target_ref_unavailable")? + .detach(); + if accepted != expected_base { + write_response(&Response::SuccessorRejected { + protocol_version: PROTOCOL_VERSION, + reason: "base_commit_mismatch", + object_format: "sha1", + expected_base_commit_oid, + actual_base_commit_oid: accepted.to_string(), + target_ref: accepted_ref, + }); + return Ok(ExitCode::from(3)); + } + // Publish the deterministic successor directly. A temporary base-valued + // candidate followed by an update creates a second ref lock boundary and + // is not reliable on Windows. + drop(repository); + create_successor( + repository_path, + expected_base_commit_oid, + candidate_ref, + path, + content, + true, + ) +} + +fn inspect_ref(repository_path: PathBuf, target_ref: String) -> Result { + if !target_ref.starts_with("refs/maka/") { + return Err("target_ref_outside_maka_namespace"); + } + let repository = open_repository(repository_path)?; + if repository.object_hash() != gix::hash::Kind::Sha1 { + return Err("unsupported_object_format"); + } + let commit_id = repository + .find_reference(target_ref.as_str()) + .map_err(|_| "target_ref_unavailable")? + .into_fully_peeled_id() + .map_err(|_| "target_ref_unavailable")? + .detach(); + let tree_id = repository + .find_commit(commit_id) + .map_err(|_| "base_commit_unavailable")? + .tree_id() + .map_err(|_| "base_tree_unavailable")? + .detach(); + write_response(&Response::RefInspected { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + commit_oid: commit_id.to_string(), + tree_oid: tree_id.to_string(), + target_ref, + }); + Ok(ExitCode::SUCCESS) +} + +fn promote_candidate( + repository_path: PathBuf, + expected_base_commit_oid: String, + accepted_ref: String, + candidate_ref: String, + expected_candidate_commit_oid: String, +) -> Result { + if !accepted_ref.starts_with("refs/maka/") + || !candidate_ref.starts_with("refs/maka/candidates/") + || accepted_ref == candidate_ref + { + return Err("candidate_promotion_ref_invalid"); + } + let repository = open_repository(repository_path)?; + if repository.object_hash() != gix::hash::Kind::Sha1 { + return Err("unsupported_object_format"); + } + let expected_base = gix::hash::ObjectId::from_hex(expected_base_commit_oid.as_bytes()) + .map_err(|_| "invalid_base_commit_oid")?; + let expected_candidate = + gix::hash::ObjectId::from_hex(expected_candidate_commit_oid.as_bytes()) + .map_err(|_| "invalid_candidate_commit_oid")?; + let candidate = repository + .find_reference(candidate_ref.as_str()) + .map_err(|_| "candidate_ref_unavailable")? + .into_fully_peeled_id() + .map_err(|_| "candidate_ref_unavailable")? + .detach(); + if candidate != expected_candidate { + return Err("candidate_commit_mismatch"); + } + let accepted = repository + .find_reference(accepted_ref.as_str()) + .map_err(|_| "target_ref_unavailable")? + .into_fully_peeled_id() + .map_err(|_| "target_ref_unavailable")? + .detach(); + if accepted == expected_candidate { + write_response(&Response::CandidatePromoted { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + base_commit_oid: expected_base_commit_oid, + candidate_commit_oid: expected_candidate_commit_oid, + accepted_ref, + candidate_ref, + }); + return Ok(ExitCode::SUCCESS); + } + if accepted != expected_base { + write_response(&Response::SuccessorRejected { + protocol_version: PROTOCOL_VERSION, + reason: "base_commit_mismatch", + object_format: "sha1", + expected_base_commit_oid, + actual_base_commit_oid: accepted.to_string(), + target_ref: accepted_ref, + }); + return Ok(ExitCode::from(3)); + } + repository + .reference( + accepted_ref.as_str(), + expected_candidate, + gix::refs::transaction::PreviousValue::MustExistAndMatch(gix::refs::Target::Object( + expected_base, + )), + "maka accept managed workspace candidate", + ) + .map_err(|_| "candidate_promotion_cas_failed")?; + write_response(&Response::CandidatePromoted { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + base_commit_oid: expected_base_commit_oid, + candidate_commit_oid: expected_candidate_commit_oid, + accepted_ref, + candidate_ref, + }); + Ok(ExitCode::SUCCESS) +} + +fn create_successor( + repository_path: PathBuf, + expected_base_commit_oid: String, + target_ref: String, + path: String, + content: String, + create_target_if_missing: bool, +) -> Result { + use gix::bstr::ByteSlice; + + if !target_ref.starts_with("refs/maka/") { + return Err("target_ref_outside_maka_namespace"); + } + if !is_canonical_successor_path(&path) { + return Err("invalid_successor_path"); + } + if content.len() as u64 > MAX_IMPORT_FILE_BYTES { + return Err("successor_content_limit_exceeded"); + } + + if create_target_if_missing { + fs::create_dir_all(repository_path.join("refs").join("maka").join("candidates")) + .map_err(|_| "candidate_namespace_create_failed")?; + } + let repository = open_repository(repository_path)?; + if repository.object_hash() != gix::hash::Kind::Sha1 { + return Err("unsupported_object_format"); + } + let expected_base = gix::hash::ObjectId::from_hex(expected_base_commit_oid.as_bytes()) + .map_err(|_| "invalid_base_commit_oid")?; + if expected_base.kind() != gix::hash::Kind::Sha1 { + return Err("invalid_base_commit_oid"); + } + let base_tree = repository + .find_commit(expected_base) + .map_err(|_| "base_commit_unavailable")? + .tree_id() + .map_err(|_| "base_tree_unavailable")? + .detach(); + let result_blob = repository + .write_blob(content.as_bytes()) + .map_err(|_| "blob_write_failed")? + .detach(); + let entry_kind = match repository + .find_tree(base_tree) + .map_err(|_| "base_tree_unavailable")? + .lookup_entry_by_path(path.as_str()) + .map_err(|_| "base_path_lookup_failed")? + .map(|entry| entry.mode().kind()) + { + Some(gix::objs::tree::EntryKind::BlobExecutable) => { + gix::objs::tree::EntryKind::BlobExecutable + } + Some(gix::objs::tree::EntryKind::Blob) | None => gix::objs::tree::EntryKind::Blob, + Some(_) => return Err("unsupported_base_path_kind"), + }; + let mut editor = repository + .edit_tree(base_tree) + .map_err(|_| "tree_edit_failed")?; + editor + .upsert(path.as_str(), entry_kind, result_blob) + .map_err(|_| "tree_edit_failed")?; + let successor_tree = editor.write().map_err(|_| "tree_write_failed")?.detach(); + validate_managed_tree(&repository, successor_tree, MANAGED_TREE_POLICY_V1)?; + let signature = gix::actor::SignatureRef { + name: b"Maka Workspace Service".as_bstr(), + email: b"workspace@maka.invalid".as_bstr(), + time: "946684800 +0000", + }; + let successor_commit = repository + .new_commit_as( + signature, + signature, + "maka managed workspace successor v1", + successor_tree, + [expected_base], + ) + .map_err(|_| "commit_write_failed")? + .id() + .detach(); + + let current = repository + .try_find_reference(target_ref.as_str()) + .map_err(|_| "target_ref_unavailable")? + .map(|reference| { + reference + .into_fully_peeled_id() + .map(|id| id.detach()) + .map_err(|_| "target_ref_unavailable") + }) + .transpose()?; + if current.is_none() && create_target_if_missing { + repository + .reference( + target_ref.as_str(), + successor_commit, + gix::refs::transaction::PreviousValue::MustNotExist, + "maka managed workspace candidate", + ) + .map_err(|error| { + eprintln!("Gitoxide candidate publication failed: {error:?}"); + "candidate_publish_failed" + })?; + } else if current.is_none() { + return Err("target_ref_unavailable"); + } else if current != Some(expected_base) && current != Some(successor_commit) { + write_response(&Response::SuccessorRejected { + protocol_version: PROTOCOL_VERSION, + reason: "base_commit_mismatch", + object_format: "sha1", + expected_base_commit_oid: expected_base.to_string(), + actual_base_commit_oid: current.expect("checked above").to_string(), + target_ref, + }); + return Ok(ExitCode::from(3)); + } else if current == Some(expected_base) { + repository + .reference( + target_ref.as_str(), + successor_commit, + gix::refs::transaction::PreviousValue::MustExistAndMatch( + gix::refs::Target::Object(expected_base), + ), + "maka managed workspace successor", + ) + .map_err(|_| "successor_publish_failed")?; + } + + write_response(&Response::SuccessorPublished { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + base_commit_oid: expected_base.to_string(), + successor_commit_oid: successor_commit.to_string(), + successor_tree_oid: successor_tree.to_string(), + result_blob_oid: result_blob.to_string(), + target_ref, + path, + }); + Ok(ExitCode::SUCCESS) +} + +fn validate_managed_tree( + repository: &gix::Repository, + tree_oid: gix::hash::ObjectId, + policy: ManagedTreePolicy, +) -> Result { + let mut stats = ManagedTreeStats::default(); + validate_managed_tree_inner(repository, tree_oid, "", 0, policy, &mut stats)?; + Ok(stats) +} + +fn validate_managed_tree_inner( + repository: &gix::Repository, + tree_oid: gix::hash::ObjectId, + prefix: &str, + depth: u64, + policy: ManagedTreePolicy, + stats: &mut ManagedTreeStats, +) -> Result<(), &'static str> { + stats.enter_tree(depth, policy)?; + let tree = repository + .find_tree(tree_oid) + .map_err(|_| "source_tree_unavailable")?; + for entry in tree.iter() { + let entry = entry.map_err(|_| "source_tree_invalid")?; + let component = + std::str::from_utf8(entry.filename()).map_err(|_| "unsupported_source_path")?; + if !is_supported_source_component(component) + || component.len() as u64 > policy.max_component_bytes + { + return Err("unsupported_source_path"); + } + let relative_path = if prefix.is_empty() { + component.to_owned() + } else { + format!("{prefix}/{component}") + }; + stats.observe_entry(&relative_path, policy)?; + match entry.mode().kind() { + gix::objs::tree::EntryKind::Tree => validate_managed_tree_inner( + repository, + entry.object_id(), + &relative_path, + depth.checked_add(1).ok_or("source_tree_depth_exceeded")?, + policy, + stats, + )?, + gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable => { + let header = entry.id().header().map_err(|_| "source_blob_unavailable")?; + if header.kind() != gix::objs::Kind::Blob { + return Err("source_blob_invalid"); + } + stats.observe_blob(header.size(), policy)?; + } + _ => return Err("unsupported_source_entry_kind"), + } + } + Ok(()) +} + +fn is_canonical_successor_path(path: &str) -> bool { + path.len() as u64 <= MANAGED_TREE_POLICY_V1.max_relative_path_bytes + && !path.is_empty() + && !path.starts_with('/') + && !path.contains('\\') + && !path.contains('\0') + && path.split('/').all(|component| { + component.len() as u64 <= MANAGED_TREE_POLICY_V1.max_component_bytes + && is_supported_source_component(component) + }) +} + +fn read_tree_file( + repository_path: PathBuf, + accepted_commit_oid: String, + path: String, +) -> Result { + if !is_canonical_successor_path(&path) { + return Err("invalid_tree_file_path"); + } + let repository = open_repository(repository_path)?; + let (accepted_commit, accepted_tree) = + accepted_commit_identity(&repository, &accepted_commit_oid)?; + let entry = repository + .find_tree(accepted_tree) + .map_err(|_| "accepted_tree_unavailable")? + .lookup_entry_by_path(path.as_str()) + .map_err(|_| "tree_file_lookup_failed")? + .ok_or("tree_file_unavailable")?; + if !matches!( + entry.mode().kind(), + gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable + ) { + return Err("tree_file_invalid"); + } + let header = entry.id().header().map_err(|_| "tree_file_unavailable")?; + if header.kind() != gix::objs::Kind::Blob + || header.size() > MAX_TREE_FILE_BYTES.min(MANAGED_TREE_POLICY_V1.max_file_bytes) + { + return Err("tree_file_size_limit_exceeded"); + } + let blob_oid = entry.object_id(); + let blob = entry + .object() + .map_err(|_| "tree_file_unavailable")? + .try_into_blob() + .map_err(|_| "tree_file_invalid")?; + let bytes_read = blob.data.len() as u64; + if bytes_read != header.size() { + return Err("tree_file_identity_mismatch"); + } + let actual_blob_oid = + gix::objs::compute_hash(gix::hash::Kind::Sha1, gix::objs::Kind::Blob, &blob.data) + .map_err(|_| "tree_file_identity_mismatch")?; + if actual_blob_oid != blob_oid { + return Err("tree_file_identity_mismatch"); + } + let content = std::str::from_utf8(&blob.data) + .map_err(|_| "tree_file_not_utf8")? + .to_owned(); + write_response(&Response::TreeFileRead { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + accepted_commit_oid: accepted_commit.to_string(), + accepted_tree_oid: accepted_tree.to_string(), + blob_oid: blob_oid.to_string(), + path, + content, + bytes_read, + }); + Ok(ExitCode::SUCCESS) +} + +#[derive(Default)] +struct ProjectionStats { + files: u64, + bytes: u64, + folded_paths: HashSet, + expected_paths: HashSet, +} + +struct ProjectionDrift { + reason: &'static str, + path: String, +} + +fn materialize_projection( + repository_path: PathBuf, + accepted_commit_oid: String, + destination_path: PathBuf, +) -> Result { + let repository = open_repository(repository_path)?; + let (accepted_commit, accepted_tree) = + accepted_commit_identity(&repository, &accepted_commit_oid)?; + validate_managed_tree(&repository, accepted_tree, MANAGED_TREE_POLICY_V1)?; + + let stats = match fs::create_dir(&destination_path) { + Ok(()) => { + let mut stats = ProjectionStats::default(); + materialize_tree( + &repository, + accepted_tree, + &destination_path, + "", + &mut stats, + )?; + stats + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + inspect_projection(&repository, accepted_tree, &destination_path) + .map_err(|_| "projection_destination_not_fresh")? + } + Err(_) => return Err("projection_destination_create_failed"), + }; + + write_response(&Response::ProjectionMaterialized { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + accepted_commit_oid: accepted_commit.to_string(), + accepted_tree_oid: accepted_tree.to_string(), + destination_path, + files_materialized: stats.files, + bytes_written: stats.bytes, + }); + Ok(ExitCode::SUCCESS) +} + +fn accepted_commit_identity( + repository: &gix::Repository, + accepted_commit_oid: &str, +) -> Result<(gix::hash::ObjectId, gix::hash::ObjectId), &'static str> { + if repository.object_hash() != gix::hash::Kind::Sha1 { + return Err("unsupported_object_format"); + } + let accepted_commit = gix::hash::ObjectId::from_hex(accepted_commit_oid.as_bytes()) + .map_err(|_| "invalid_accepted_commit_oid")?; + if accepted_commit.kind() != gix::hash::Kind::Sha1 { + return Err("invalid_accepted_commit_oid"); + } + let accepted_tree = repository + .find_commit(accepted_commit) + .map_err(|_| "accepted_commit_unavailable")? + .tree_id() + .map_err(|_| "accepted_tree_unavailable")? + .detach(); + Ok((accepted_commit, accepted_tree)) +} + +fn materialize_tree( + repository: &gix::Repository, + tree_oid: gix::hash::ObjectId, + destination: &Path, + prefix: &str, + stats: &mut ProjectionStats, +) -> Result<(), &'static str> { + let tree = repository + .find_tree(tree_oid) + .map_err(|_| "projection_tree_unavailable")?; + for entry in tree.iter() { + let entry = entry.map_err(|_| "projection_tree_invalid")?; + let component = + std::str::from_utf8(entry.filename()).map_err(|_| "unsupported_projection_path")?; + if !is_supported_source_component(component) { + return Err("unsupported_projection_path"); + } + let relative_path = join_projection_path(prefix, component); + record_projection_path(stats, &relative_path).map_err(|_| "projection_path_collision")?; + let output_path = destination.join(component); + match entry.mode().kind() { + gix::objs::tree::EntryKind::Tree => { + fs::create_dir(&output_path).map_err(|_| "projection_directory_create_failed")?; + materialize_tree( + repository, + entry.object_id(), + &output_path, + &relative_path, + stats, + )?; + } + gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable => { + let header = entry + .id() + .header() + .map_err(|_| "projection_blob_unavailable")?; + if header.kind() != gix::objs::Kind::Blob + || header.size() > MANAGED_TREE_POLICY_V1.max_file_bytes + { + return Err("projection_file_limit_exceeded"); + } + stats.files = stats + .files + .checked_add(1) + .filter(|count| *count <= MANAGED_TREE_POLICY_V1.max_files) + .ok_or("projection_file_limit_exceeded")?; + stats.bytes = stats + .bytes + .checked_add(header.size()) + .filter(|bytes| *bytes <= MANAGED_TREE_POLICY_V1.max_bytes) + .ok_or("projection_byte_limit_exceeded")?; + let blob = entry + .object() + .map_err(|_| "projection_blob_unavailable")? + .try_into_blob() + .map_err(|_| "projection_blob_invalid")?; + if blob.data.len() as u64 != header.size() { + return Err("projection_blob_invalid"); + } + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&output_path) + .map_err(|_| "projection_file_create_failed")?; + output + .write_all(&blob.data) + .map_err(|_| "projection_file_write_failed")?; + output + .sync_all() + .map_err(|_| "projection_file_sync_failed")?; + drop(output); + set_projection_mode( + &output_path, + entry.mode().kind() == gix::objs::tree::EntryKind::BlobExecutable, + )?; + } + _ => return Err("unsupported_projection_entry_kind"), + } + } + sync_directory(destination)?; + Ok(()) +} + +fn observe_projection( + repository_path: PathBuf, + accepted_commit_oid: String, + projection_path: PathBuf, +) -> Result { + let repository = open_repository(repository_path)?; + let (accepted_commit, accepted_tree) = + accepted_commit_identity(&repository, &accepted_commit_oid)?; + validate_managed_tree(&repository, accepted_tree, MANAGED_TREE_POLICY_V1)?; + match inspect_projection(&repository, accepted_tree, &projection_path) { + Ok(stats) => { + write_response(&Response::ProjectionObserved { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + state: "clean", + accepted_commit_oid: accepted_commit.to_string(), + accepted_tree_oid: accepted_tree.to_string(), + projection_path, + files_observed: stats.files, + bytes_read: stats.bytes, + }); + Ok(ExitCode::SUCCESS) + } + Err(drift) => projection_drifted(accepted_commit, accepted_tree, projection_path, drift), + } +} + +fn inspect_projection( + repository: &gix::Repository, + accepted_tree: gix::hash::ObjectId, + projection_path: &Path, +) -> Result { + let root_metadata = fs::symlink_metadata(projection_path).map_err(|_| ProjectionDrift { + reason: "projection_unreadable", + path: String::new(), + })?; + if !root_metadata.is_dir() || root_metadata.file_type().is_symlink() { + return Err(ProjectionDrift { + reason: "projection_root_type_mismatch", + path: String::new(), + }); + } + let mut stats = ProjectionStats::default(); + observe_expected_tree(repository, accepted_tree, projection_path, "", &mut stats)?; + reject_extra_projection_paths(projection_path, "", &stats.expected_paths)?; + Ok(stats) +} + +fn observe_expected_tree( + repository: &gix::Repository, + tree_oid: gix::hash::ObjectId, + projection: &Path, + prefix: &str, + stats: &mut ProjectionStats, +) -> Result<(), ProjectionDrift> { + let tree = repository + .find_tree(tree_oid) + .map_err(|_| ProjectionDrift { + reason: "expected_tree_unavailable", + path: prefix.to_owned(), + })?; + for entry in tree.iter() { + let entry = entry.map_err(|_| ProjectionDrift { + reason: "expected_tree_invalid", + path: prefix.to_owned(), + })?; + let component = std::str::from_utf8(entry.filename()).map_err(|_| ProjectionDrift { + reason: "unsupported_projection_path", + path: prefix.to_owned(), + })?; + if !is_supported_source_component(component) { + return Err(ProjectionDrift { + reason: "unsupported_projection_path", + path: prefix.to_owned(), + }); + } + let relative_path = join_projection_path(prefix, component); + record_projection_path(stats, &relative_path).map_err(|_| ProjectionDrift { + reason: "projection_path_collision", + path: relative_path.clone(), + })?; + let output_path = projection.join(component); + let metadata = fs::symlink_metadata(&output_path).map_err(|_| ProjectionDrift { + reason: "expected_path_missing_or_unreadable", + path: relative_path.clone(), + })?; + match entry.mode().kind() { + gix::objs::tree::EntryKind::Tree => { + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(ProjectionDrift { + reason: "expected_directory_type_mismatch", + path: relative_path, + }); + } + observe_expected_tree( + repository, + entry.object_id(), + &output_path, + &relative_path, + stats, + )?; + } + gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable => { + observe_expected_blob(&entry, &metadata, &output_path, &relative_path, stats)?; + } + _ => { + return Err(ProjectionDrift { + reason: "unsupported_projection_entry_kind", + path: relative_path, + }); + } + } + } + Ok(()) +} + +fn observe_expected_blob( + entry: &gix::object::tree::EntryRef<'_, '_>, + metadata: &fs::Metadata, + output_path: &Path, + relative_path: &str, + stats: &mut ProjectionStats, +) -> Result<(), ProjectionDrift> { + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(ProjectionDrift { + reason: "expected_file_type_mismatch", + path: relative_path.to_owned(), + }); + } + let header = entry.id().header().map_err(|_| ProjectionDrift { + reason: "expected_blob_unavailable", + path: relative_path.to_owned(), + })?; + if header.kind() != gix::objs::Kind::Blob + || header.size() > MANAGED_TREE_POLICY_V1.max_file_bytes + || metadata.len() != header.size() + { + return Err(ProjectionDrift { + reason: "expected_file_size_mismatch", + path: relative_path.to_owned(), + }); + } + stats.files = stats + .files + .checked_add(1) + .filter(|count| *count <= MANAGED_TREE_POLICY_V1.max_files) + .ok_or_else(|| ProjectionDrift { + reason: "projection_file_limit_exceeded", + path: relative_path.to_owned(), + })?; + stats.bytes = stats + .bytes + .checked_add(header.size()) + .filter(|bytes| *bytes <= MANAGED_TREE_POLICY_V1.max_bytes) + .ok_or_else(|| ProjectionDrift { + reason: "projection_byte_limit_exceeded", + path: relative_path.to_owned(), + })?; + let input = open_projection_file_nofollow(output_path).map_err(|_| ProjectionDrift { + reason: "expected_file_unreadable", + path: relative_path.to_owned(), + })?; + let opened_metadata = input.metadata().map_err(|_| ProjectionDrift { + reason: "expected_file_unreadable", + path: relative_path.to_owned(), + })?; + if !opened_metadata.is_file() || opened_metadata.len() != header.size() { + return Err(ProjectionDrift { + reason: "expected_file_size_mismatch", + path: relative_path.to_owned(), + }); + } + let mut bytes = Vec::with_capacity(header.size() as usize); + input + .take(header.size() + 1) + .read_to_end(&mut bytes) + .map_err(|_| ProjectionDrift { + reason: "expected_file_unreadable", + path: relative_path.to_owned(), + })?; + if bytes.len() as u64 != header.size() { + return Err(ProjectionDrift { + reason: "expected_file_size_mismatch", + path: relative_path.to_owned(), + }); + } + let actual_oid = gix::objs::compute_hash(gix::hash::Kind::Sha1, gix::objs::Kind::Blob, &bytes) + .map_err(|_| ProjectionDrift { + reason: "expected_file_hash_failed", + path: relative_path.to_owned(), + })?; + if actual_oid != entry.object_id() { + return Err(ProjectionDrift { + reason: "expected_file_content_mismatch", + path: relative_path.to_owned(), + }); + } + if !projection_mode_matches( + &opened_metadata, + entry.mode().kind() == gix::objs::tree::EntryKind::BlobExecutable, + ) { + return Err(ProjectionDrift { + reason: "expected_file_mode_mismatch", + path: relative_path.to_owned(), + }); + } + Ok(()) +} + +fn reject_extra_projection_paths( + projection: &Path, + prefix: &str, + expected_paths: &HashSet, +) -> Result<(), ProjectionDrift> { + let entries = fs::read_dir(projection).map_err(|_| ProjectionDrift { + reason: "projection_directory_unreadable", + path: prefix.to_owned(), + })?; + for entry in entries { + let entry = entry.map_err(|_| ProjectionDrift { + reason: "projection_directory_unreadable", + path: prefix.to_owned(), + })?; + let component = entry + .file_name() + .into_string() + .map_err(|_| ProjectionDrift { + reason: "unexpected_non_utf8_path", + path: prefix.to_owned(), + })?; + let relative_path = join_projection_path(prefix, &component); + if !expected_paths.contains(&relative_path) { + return Err(ProjectionDrift { + reason: "unexpected_projection_path", + path: relative_path, + }); + } + let file_type = entry.file_type().map_err(|_| ProjectionDrift { + reason: "projection_path_unreadable", + path: relative_path.clone(), + })?; + if file_type.is_symlink() { + return Err(ProjectionDrift { + reason: "projection_path_type_mismatch", + path: relative_path, + }); + } + if file_type.is_dir() { + reject_extra_projection_paths(&entry.path(), &relative_path, expected_paths)?; + } + } + Ok(()) +} + +fn record_projection_path(stats: &mut ProjectionStats, path: &str) -> Result<(), ()> { + let folded_path: String = path.nfc().flat_map(char::to_lowercase).collect(); + if !stats.folded_paths.insert(folded_path) { + return Err(()); + } + stats.expected_paths.insert(path.to_owned()); + Ok(()) +} + +fn join_projection_path(prefix: &str, component: &str) -> String { + if prefix.is_empty() { + component.to_owned() + } else { + format!("{prefix}/{component}") + } +} + +fn projection_drifted( + accepted_commit: gix::hash::ObjectId, + accepted_tree: gix::hash::ObjectId, + projection_path: PathBuf, + drift: ProjectionDrift, +) -> Result { + write_response(&Response::ProjectionDrifted { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + state: "drifted", + reason: drift.reason, + path: drift.path, + accepted_commit_oid: accepted_commit.to_string(), + accepted_tree_oid: accepted_tree.to_string(), + projection_path, + }); + Ok(ExitCode::from(3)) +} + +#[cfg(unix)] +fn open_projection_file_nofollow(path: &Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) +} + +#[cfg(windows)] +fn open_projection_file_nofollow(path: &Path) -> io::Result { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) +} + +#[cfg(not(any(unix, windows)))] +fn open_projection_file_nofollow(path: &Path) -> io::Result { + File::open(path) +} + +#[cfg(unix)] +fn projection_mode_matches(metadata: &fs::Metadata, executable: bool) -> bool { + use std::os::unix::fs::PermissionsExt; + (metadata.permissions().mode() & 0o111 != 0) == executable +} + +#[cfg(not(unix))] +fn projection_mode_matches(_metadata: &fs::Metadata, _executable: bool) -> bool { + true +} + +#[cfg(unix)] +fn set_projection_mode(path: &Path, executable: bool) -> Result<(), &'static str> { + use std::os::unix::fs::PermissionsExt; + let mode = if executable { 0o755 } else { 0o644 }; + fs::set_permissions(path, fs::Permissions::from_mode(mode)) + .map_err(|_| "projection_mode_update_failed") +} + +#[cfg(not(unix))] +fn set_projection_mode(_path: &Path, _executable: bool) -> Result<(), &'static str> { + Ok(()) +} + +fn sync_directory(path: &Path) -> Result<(), &'static str> { + #[cfg(unix)] + { + File::open(path) + .and_then(|directory| directory.sync_all()) + .map_err(|_| "projection_directory_sync_failed")?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +fn is_supported_source_component(component: &str) -> bool { + !component.is_empty() + && component != "." + && component != ".." + && !component.contains('/') + && !component.contains('\\') + && !component.contains('\0') + && !component.eq_ignore_ascii_case(".git") + && !component.eq_ignore_ascii_case(".gitattributes") +} + +#[derive(Clone, Copy)] +struct ManagedTreePolicy { + max_depth: u64, + max_tree_visits: u64, + max_entries: u64, + max_total_path_bytes: u64, + max_component_bytes: u64, + max_relative_path_bytes: u64, + max_files: u64, + max_file_bytes: u64, + max_bytes: u64, +} + +#[derive(Default)] +struct ManagedTreeStats { + tree_visits: u64, + entries: u64, + total_path_bytes: u64, + files: u64, + bytes: u64, + folded_paths: HashSet, +} + +impl ManagedTreeStats { + fn enter_tree(&mut self, depth: u64, policy: ManagedTreePolicy) -> Result<(), &'static str> { + if depth > policy.max_depth { + return Err("source_tree_depth_exceeded"); + } + self.tree_visits = self + .tree_visits + .checked_add(1) + .filter(|visits| *visits <= policy.max_tree_visits) + .ok_or("source_tree_visit_limit_exceeded")?; + Ok(()) + } + + fn observe_entry( + &mut self, + relative_path: &str, + policy: ManagedTreePolicy, + ) -> Result<(), &'static str> { + let path_bytes = relative_path.len() as u64; + if path_bytes > policy.max_relative_path_bytes { + return Err("source_path_length_exceeded"); + } + self.entries = self + .entries + .checked_add(1) + .filter(|entries| *entries <= policy.max_entries) + .ok_or("source_tree_entry_limit_exceeded")?; + self.total_path_bytes = self + .total_path_bytes + .checked_add(path_bytes) + .filter(|bytes| *bytes <= policy.max_total_path_bytes) + .ok_or("source_path_byte_limit_exceeded")?; + let folded_path: String = relative_path.nfc().flat_map(char::to_lowercase).collect(); + if !self.folded_paths.insert(folded_path) { + return Err("source_path_collision"); + } + Ok(()) + } + + fn observe_blob(&mut self, size: u64, policy: ManagedTreePolicy) -> Result<(), &'static str> { + if size > policy.max_file_bytes { + return Err("source_file_limit_exceeded"); + } + self.files = self + .files + .checked_add(1) + .filter(|files| *files <= policy.max_files) + .ok_or("source_file_limit_exceeded")?; + self.bytes = self + .bytes + .checked_add(size) + .filter(|bytes| *bytes <= policy.max_bytes) + .ok_or("source_byte_limit_exceeded")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tiny_policy() -> ManagedTreePolicy { + ManagedTreePolicy { + max_depth: 1, + max_tree_visits: 2, + max_entries: 2, + max_total_path_bytes: 5, + max_component_bytes: 3, + max_relative_path_bytes: 4, + max_files: 1, + max_file_bytes: 3, + max_bytes: 3, + } + } + + #[test] + fn managed_tree_budget_bounds_depth_visits_and_entries() { + let policy = tiny_policy(); + let mut stats = ManagedTreeStats::default(); + assert_eq!(stats.enter_tree(0, policy), Ok(())); + assert_eq!(stats.enter_tree(1, policy), Ok(())); + assert_eq!( + stats.enter_tree(1, policy), + Err("source_tree_visit_limit_exceeded") + ); + + let mut stats = ManagedTreeStats::default(); + assert_eq!( + stats.enter_tree(2, policy), + Err("source_tree_depth_exceeded") + ); + assert_eq!(stats.observe_entry("a", policy), Ok(())); + assert_eq!(stats.observe_entry("bb", policy), Ok(())); + assert_eq!( + stats.observe_entry("c", policy), + Err("source_tree_entry_limit_exceeded") + ); + } + + #[test] + fn managed_tree_budget_bounds_paths_and_blob_bytes() { + let policy = tiny_policy(); + let mut stats = ManagedTreeStats::default(); + assert_eq!( + stats.observe_entry("abcde", policy), + Err("source_path_length_exceeded") + ); + assert_eq!(stats.observe_entry("abc", policy), Ok(())); + assert_eq!( + stats.observe_entry("def", policy), + Err("source_path_byte_limit_exceeded") + ); + + let mut stats = ManagedTreeStats::default(); + assert_eq!( + stats.observe_blob(4, policy), + Err("source_file_limit_exceeded") + ); + assert_eq!(stats.observe_blob(3, policy), Ok(())); + assert_eq!( + stats.observe_blob(1, policy), + Err("source_file_limit_exceeded") + ); + } + + #[test] + fn direct_tree_paths_share_the_managed_tree_policy() { + assert!(!is_canonical_successor_path(".gitattributes")); + assert!(!is_canonical_successor_path(&format!( + "{}.txt", + "a".repeat(MANAGED_TREE_POLICY_V1.max_component_bytes as usize) + ))); + assert!(is_canonical_successor_path("docs/guide.txt")); + } +} + +fn reject_unsupported_object_format(object_format: String) -> ExitCode { + write_response(&Response::RepositoryRejected { + protocol_version: PROTOCOL_VERSION, + reason: "unsupported_object_format", + object_format, + supported_object_formats: ["sha1"], + }); + ExitCode::from(2) +} + +fn read_request() -> Result { + let mut bytes = Vec::new(); + io::stdin() + .take(MAX_REQUEST_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| "request_read_failed")?; + if bytes.len() as u64 > MAX_REQUEST_BYTES { + return Err("request_too_large"); + } + serde_json::from_slice(&bytes).map_err(|_| "invalid_request") +} + +fn write_response(response: &Response<'_>) { + let encoded = serde_json::to_string(response).expect("closed response shape must serialize"); + println!("{encoded}"); +} diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs new file mode 100644 index 0000000000..837eabbb5e --- /dev/null +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -0,0 +1,864 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + process::{Command, Output, Stdio}, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +const HELPER: &str = env!("CARGO_BIN_EXE_maka-gitoxide-helper"); +static FIXTURE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[test] +fn inspects_a_sha1_repository_without_invoking_system_git() { + let fixture = RepositoryFixture::sha1_with_commit(); + let expected_commit = fixture.git_output(["rev-parse", "HEAD"]); + let expected_tree = fixture.git_output(["rev-parse", "HEAD^{tree}"]); + + let output = invoke_helper(&fixture.root); + + assert!( + output.status.success(), + "helper failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + response, + serde_json::json!({ + "protocolVersion": 1, + "kind": "repository_inspected", + "objectFormat": "sha1", + "headCommitOid": expected_commit, + "headTreeOid": expected_tree, + }) + ); +} + +#[test] +fn rejects_sha256_before_returning_repository_identity() { + let fixture = RepositoryFixture::sha256_unborn(); + + let output = invoke_helper(&fixture.root); + + assert_eq!(output.status.code(), Some(2)); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + response, + serde_json::json!({ + "protocolVersion": 1, + "kind": "repository_rejected", + "reason": "unsupported_object_format", + "objectFormat": "sha256", + "supportedObjectFormats": ["sha1"], + }) + ); +} + +#[test] +fn rejects_an_unknown_object_format_during_repository_open() { + let fixture = RepositoryFixture::unknown_object_format(); + + let output = invoke_helper(&fixture.root); + + assert_eq!(output.status.code(), Some(2)); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + response, + serde_json::json!({ + "protocolVersion": 1, + "kind": "repository_rejected", + "reason": "unsupported_object_format", + "objectFormat": "sha512", + "supportedObjectFormats": ["sha1"], + }) + ); +} + +#[test] +fn observes_raw_head_identity_instead_of_replacement_ref_semantics() { + let (fixture, expected_commit, expected_tree) = RepositoryFixture::sha1_with_replacement_ref(); + + let output = invoke_helper(&fixture.root); + + assert!(output.status.success()); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(response["headCommitOid"], expected_commit); + assert_eq!(response["headTreeOid"], expected_tree); +} + +#[test] +fn imports_an_exact_source_head_into_a_fresh_managed_repository() { + let fixture = RepositoryFixture::sha1_with_commit(); + fs::create_dir_all(fixture.root.join("docs")).unwrap(); + fs::write(fixture.root.join("docs/guide.txt"), b"nested guide\n").unwrap(); + fixture.git(["add", "docs/guide.txt"]); + fixture.git([ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "-m", + "source import fixture", + ]); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let source_tree = fixture.git_output(["rev-parse", "HEAD^{tree}"]); + let destination = fixture.root.join("managed.git"); + + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/baseline", + })); + + assert!( + output.status.success(), + "helper failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(response["kind"], "source_imported"); + assert_eq!(response["sourceHeadCommitOid"], source_head); + assert_eq!(response["sourceTreeOid"], source_tree); + assert_eq!(response["baselineTreeOid"], source_tree); + assert_eq!(response["filesImported"], 2); + assert_eq!(response["bytesImported"], 29); + let baseline_commit = response["baselineCommitOid"].as_str().unwrap(); + assert_ne!(baseline_commit, source_head); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/baseline"]), + baseline_commit + ); + assert_eq!( + git_bare_output( + &destination, + ["rev-parse", &format!("{baseline_commit}^{{tree}}")] + ), + source_tree + ); + assert!(!git_bare_succeeds( + &destination, + ["cat-file", "-e", source_head.as_str()] + )); + assert!(!destination.join("objects/info/alternates").exists()); + + let retry = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/baseline", + })); + assert!(retry.status.success()); + assert_eq!( + serde_json::from_slice::(&retry.stdout).unwrap(), + response + ); +} + +#[test] +fn repairs_an_initialized_import_destination_without_a_published_baseline() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("managed-partial.git"); + let initialized = Command::new("git") + .args(["init", "--bare"]) + .arg(&destination) + .output() + .unwrap(); + assert!(initialized.status.success()); + + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + })); + + assert!(output.status.success()); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/accepted"]), + response["baselineCommitOid"].as_str().unwrap() + ); +} + +#[test] +fn publishes_and_exactly_retries_a_successor_from_the_current_ref() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("managed.git"); + let imported = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + })); + assert!(imported.status.success()); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "create_successor", + "repositoryPath": destination, + "expectedBaseCommitOid": baseline, + "targetRef": "refs/maka/accepted", + "path": "docs/hello.txt", + "content": "successor content\n", + }); + + let first = invoke_request(request.clone()); + assert!(first.status.success()); + let first: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap(); + assert_eq!(first["kind"], "successor_published"); + assert_eq!(first["baseCommitOid"], baseline); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/accepted"]), + first["successorCommitOid"].as_str().unwrap() + ); + assert_eq!( + git_bare_bytes( + &destination, + [ + "show", + &format!( + "{}:docs/hello.txt", + first["successorCommitOid"].as_str().unwrap() + ) + ] + ), + b"successor content\n" + ); + + let retry = invoke_request(request); + assert!(retry.status.success()); + let retry: serde_json::Value = serde_json::from_slice(&retry.stdout).unwrap(); + assert_eq!(retry, first); +} + +#[test] +fn prepares_and_exactly_retries_a_candidate_without_advancing_the_accepted_ref() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("managed.git"); + let imported = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + })); + assert!(imported.status.success()); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "prepare_candidate", + "repositoryPath": destination, + "expectedBaseCommitOid": baseline, + "acceptedRef": "refs/maka/accepted", + "candidateRef": "refs/maka/candidates/operation-1", + "path": "docs/hello.txt", + "content": "candidate content\n", + }); + + let first = invoke_request(request.clone()); + assert!(first.status.success()); + let first: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap(); + assert_eq!(first["kind"], "successor_published"); + assert_eq!(first["baseCommitOid"], baseline); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/accepted"]), + baseline + ); + assert_eq!( + git_bare_output( + &destination, + ["rev-parse", "refs/maka/candidates/operation-1"] + ), + first["successorCommitOid"].as_str().unwrap() + ); + + let retry = invoke_request(request); + assert!(retry.status.success()); + let retry: serde_json::Value = serde_json::from_slice(&retry.stdout).unwrap(); + assert_eq!(retry, first); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/accepted"]), + baseline + ); +} + +#[test] +fn reads_one_exact_utf8_file_from_the_accepted_tree() { + let fixture = RepositoryFixture::sha1_with_commit(); + fs::create_dir_all(fixture.root.join("config")).unwrap(); + fs::write( + fixture.root.join("config/package-lock.json"), + b"{\"lockfileVersion\":3}\n", + ) + .unwrap(); + fixture.git(["add", "config/package-lock.json"]); + fixture.git([ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "-m", + "tree file fixture", + ]); + let accepted_commit = fixture.git_output(["rev-parse", "HEAD"]); + let accepted_tree = fixture.git_output(["rev-parse", "HEAD^{tree}"]); + let expected_blob = fixture.git_output(["rev-parse", "HEAD:config/package-lock.json"]); + + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "read_tree_file", + "repositoryPath": fixture.root, + "acceptedCommitOid": accepted_commit, + "path": "config/package-lock.json", + })); + + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + serde_json::json!({ + "protocolVersion": 1, + "kind": "tree_file_read", + "objectFormat": "sha1", + "acceptedCommitOid": accepted_commit, + "acceptedTreeOid": accepted_tree, + "blobOid": expected_blob, + "path": "config/package-lock.json", + "content": "{\"lockfileVersion\":3}\n", + "bytesRead": 22, + }) + ); +} + +#[test] +fn observes_the_exact_commit_and_tree_behind_a_managed_ref() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("managed.git"); + let imported = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + })); + assert!(imported.status.success()); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + + let observed = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "inspect_ref", + "repositoryPath": destination, + "targetRef": "refs/maka/accepted", + })); + assert!(observed.status.success()); + let observed: serde_json::Value = serde_json::from_slice(&observed.stdout).unwrap(); + assert_eq!(observed["kind"], "ref_inspected"); + assert_eq!(observed["commitOid"], imported["baselineCommitOid"]); + assert_eq!(observed["treeOid"], imported["baselineTreeOid"]); + assert_eq!(observed["targetRef"], "refs/maka/accepted"); +} + +#[test] +fn promotes_one_exact_candidate_with_compare_and_swap_and_convergent_retry() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("promote-managed.git"); + let imported = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + })); + assert!(imported.status.success()); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let prepared = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "prepare_candidate", + "repositoryPath": destination, + "expectedBaseCommitOid": baseline, + "acceptedRef": "refs/maka/accepted", + "candidateRef": "refs/maka/candidates/promote-operation", + "path": "docs/promoted.txt", + "content": "accepted candidate\n", + })); + assert!(prepared.status.success()); + let prepared: serde_json::Value = serde_json::from_slice(&prepared.stdout).unwrap(); + let candidate = prepared["successorCommitOid"].as_str().unwrap(); + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "promote_candidate", + "repositoryPath": destination, + "expectedBaseCommitOid": baseline, + "acceptedRef": "refs/maka/accepted", + "candidateRef": "refs/maka/candidates/promote-operation", + "expectedCandidateCommitOid": candidate, + }); + + let first = invoke_request(request.clone()); + assert!(first.status.success()); + let first: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap(); + assert_eq!(first["kind"], "candidate_promoted"); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/accepted"]), + candidate + ); + + let retry = invoke_request(request); + assert!(retry.status.success()); + assert_eq!( + serde_json::from_slice::(&retry.stdout).unwrap(), + first + ); +} + +#[test] +fn refuses_to_read_a_tree_file_from_the_wrong_commit_identity() { + let fixture = RepositoryFixture::sha1_with_commit(); + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "read_tree_file", + "repositoryPath": fixture.root, + "acceptedCommitOid": "0000000000000000000000000000000000000000", + "path": "hello.txt", + })); + + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap()["reason"], + "accepted_commit_unavailable" + ); +} + +#[test] +fn rejects_a_successor_when_the_target_ref_no_longer_matches_the_base() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("managed.git"); + let imported = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + })); + assert!(imported.status.success()); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let advanced = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "create_successor", + "repositoryPath": destination, + "expectedBaseCommitOid": baseline, + "targetRef": "refs/maka/accepted", + "path": "advanced.txt", + "content": "advanced\n", + })); + assert!(advanced.status.success()); + let advanced: serde_json::Value = serde_json::from_slice(&advanced.stdout).unwrap(); + + let rejected = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "create_successor", + "repositoryPath": destination, + "expectedBaseCommitOid": baseline, + "targetRef": "refs/maka/accepted", + "path": "should-not-exist.txt", + "content": "must not publish\n", + })); + assert_eq!(rejected.status.code(), Some(3)); + let rejected: serde_json::Value = serde_json::from_slice(&rejected.stdout).unwrap(); + assert_eq!(rejected["kind"], "successor_rejected"); + assert_eq!(rejected["reason"], "base_commit_mismatch"); + assert_eq!( + rejected["actualBaseCommitOid"], + advanced["successorCommitOid"] + ); +} + +#[test] +fn materializes_and_observes_an_exact_commit_without_git_metadata() { + let fixture = RepositoryFixture::sha1_with_commit(); + fs::create_dir_all(fixture.root.join("docs")).unwrap(); + fs::write(fixture.root.join("docs/guide.txt"), b"nested guide\n").unwrap(); + fixture.git(["add", "docs/guide.txt"]); + fixture.git([ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "-m", + "projection fixture", + ]); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("managed.git"); + let imported = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + })); + assert!(imported.status.success()); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let accepted_commit = imported["baselineCommitOid"].as_str().unwrap(); + let projection = fixture.root.join("projection"); + + let materialized = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "materialize_projection", + "repositoryPath": destination, + "acceptedCommitOid": accepted_commit, + "destinationPath": projection, + })); + assert!( + materialized.status.success(), + "materialize failed: stdout={} stderr={}", + String::from_utf8_lossy(&materialized.stdout), + String::from_utf8_lossy(&materialized.stderr) + ); + let materialized: serde_json::Value = serde_json::from_slice(&materialized.stdout).unwrap(); + assert_eq!(materialized["kind"], "projection_materialized"); + assert_eq!( + fs::read(projection.join("hello.txt")).unwrap(), + b"hello from sha1\n" + ); + assert_eq!( + fs::read(projection.join("docs/guide.txt")).unwrap(), + b"nested guide\n" + ); + assert!(!projection.join(".git").exists()); + + let observed = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "observe_projection", + "repositoryPath": destination, + "acceptedCommitOid": accepted_commit, + "projectionPath": projection, + })); + assert!(observed.status.success()); + let observed: serde_json::Value = serde_json::from_slice(&observed.stdout).unwrap(); + assert_eq!(observed["kind"], "projection_observed"); + assert_eq!(observed["state"], "clean"); + + let retry = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "materialize_projection", + "repositoryPath": destination, + "acceptedCommitOid": accepted_commit, + "destinationPath": projection, + })); + assert!(retry.status.success()); + assert_eq!( + serde_json::from_slice::(&retry.stdout).unwrap(), + materialized + ); +} + +#[test] +fn reports_projection_content_and_extra_path_drift() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("managed.git"); + let imported = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + })); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let accepted_commit = imported["baselineCommitOid"].as_str().unwrap(); + let projection = fixture.root.join("projection"); + assert!( + invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "materialize_projection", + "repositoryPath": destination, + "acceptedCommitOid": accepted_commit, + "destinationPath": projection, + })) + .status + .success() + ); + + fs::write(projection.join("hello.txt"), b"evil! from sha1\n").unwrap(); + let drifted = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "observe_projection", + "repositoryPath": destination, + "acceptedCommitOid": accepted_commit, + "projectionPath": projection, + })); + assert_eq!(drifted.status.code(), Some(3)); + let drifted: serde_json::Value = serde_json::from_slice(&drifted.stdout).unwrap(); + assert_eq!(drifted["reason"], "expected_file_content_mismatch"); + + fs::write(projection.join("hello.txt"), b"hello from sha1\n").unwrap(); + fs::write(projection.join("external.txt"), b"external\n").unwrap(); + let extra = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "observe_projection", + "repositoryPath": destination, + "acceptedCommitOid": accepted_commit, + "projectionPath": projection, + })); + assert_eq!(extra.status.code(), Some(3)); + let extra: serde_json::Value = serde_json::from_slice(&extra.stdout).unwrap(); + assert_eq!(extra["reason"], "unexpected_projection_path"); + assert_eq!(extra["path"], "external.txt"); +} + +#[test] +fn rejects_a_successor_tree_outside_the_managed_tree_policy_before_ref_cas() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("managed.git"); + let imported = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + })); + assert!(imported.status.success()); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let overdeep_path = (0..65) + .map(|index| format!("d{index}")) + .chain(std::iter::once("file.txt".to_owned())) + .collect::>() + .join("/"); + + let rejected = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "create_successor", + "repositoryPath": destination, + "expectedBaseCommitOid": baseline, + "targetRef": "refs/maka/accepted", + "path": overdeep_path, + "content": "must not publish\n", + })); + + assert_eq!(rejected.status.code(), Some(1)); + let rejected: serde_json::Value = serde_json::from_slice(&rejected.stdout).unwrap(); + assert_eq!(rejected["reason"], "source_tree_depth_exceeded"); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/accepted"]), + baseline + ); +} + +fn invoke_helper(repository_path: &Path) -> Output { + invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "inspect_repository", + "repositoryPath": repository_path, + })) +} + +fn invoke_request(request: serde_json::Value) -> Output { + let mut child = Command::new(HELPER) + .env("PATH", "") + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "extensions.objectFormat") + .env("GIT_CONFIG_VALUE_0", "sha256") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(serde_json::to_string(&request).unwrap().as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +fn git_bare_output(repository: &Path, args: [&str; N]) -> String { + let output = Command::new("git") + .arg("--git-dir") + .arg(repository) + .args(args) + .output() + .unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +fn git_bare_succeeds(repository: &Path, args: [&str; N]) -> bool { + Command::new("git") + .arg("--git-dir") + .arg(repository) + .args(args) + .status() + .unwrap() + .success() +} + +fn git_bare_bytes(repository: &Path, args: [&str; N]) -> Vec { + let output = Command::new("git") + .arg("--git-dir") + .arg(repository) + .args(args) + .output() + .unwrap(); + assert!(output.status.success()); + output.stdout +} + +struct RepositoryFixture { + root: PathBuf, +} + +impl RepositoryFixture { + fn sha1_with_commit() -> Self { + let fixture = Self::init("sha1"); + fs::write(fixture.root.join("hello.txt"), b"hello from sha1\n").unwrap(); + fixture.git(["add", "hello.txt"]); + fixture.git([ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "-m", + "fixture", + ]); + fixture + } + + fn sha256_unborn() -> Self { + Self::init("sha256") + } + + fn unknown_object_format() -> Self { + let fixture = Self::init("sha1"); + fixture.git(["config", "core.repositoryFormatVersion", "1"]); + fixture.git(["config", "extensions.objectFormat", "sha512"]); + fixture + } + + fn sha1_with_replacement_ref() -> (Self, String, String) { + let fixture = Self::sha1_with_commit(); + let raw_commit = fixture.git_output(["rev-parse", "HEAD"]); + let raw_tree = fixture.git_output(["rev-parse", "HEAD^{tree}"]); + + fs::write(fixture.root.join("hello.txt"), b"replacement content\n").unwrap(); + fixture.git(["add", "hello.txt"]); + fixture.git([ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "-m", + "replacement", + ]); + let replacement_commit = fixture.git_output(["rev-parse", "HEAD"]); + fixture.git(["replace", &raw_commit, &replacement_commit]); + fixture.git(["checkout", "--detach", &raw_commit]); + + (fixture, raw_commit, raw_tree) + } + + fn init(object_format: &str) -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let sequence = FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "maka-gitoxide-helper-admission-{}-{nonce}-{sequence}", + std::process::id(), + )); + fs::create_dir_all(&root).unwrap(); + let fixture = Self { root }; + fixture.git([ + "init", + "--quiet", + &format!("--object-format={object_format}"), + ]); + fixture + } + + fn git(&self, args: [&str; N]) { + let output = Command::new("git") + .arg("-C") + .arg(&self.root) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git fixture command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn git_output(&self, args: [&str; N]) -> String { + let output = Command::new("git") + .arg("-C") + .arg(&self.root) + .args(args) + .output() + .unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + } +} + +impl Drop for RepositoryFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} diff --git a/package-lock.json b/package-lock.json index 963aae16b8..1957e8b4f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,9 +28,14 @@ "@biomejs/biome": "2.5.9", "@electron/asar": "3.4.1", "@types/node": "^26.2.0", + "brace-expansion": "5.0.9", + "ip-address": "10.4.0", "knip": "^6.32.2", + "npm": "12.0.2", "patch-package": "8.0.1", + "tar": "7.5.22", "typescript": "^7.0.2", + "undici": "6.28.0", "yaml": "2.9.0" }, "engines": { @@ -1040,9 +1045,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1060,9 +1062,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1080,9 +1079,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1100,9 +1096,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -3075,9 +3068,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3092,9 +3082,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3109,9 +3096,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3126,9 +3110,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3143,9 +3124,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3160,9 +3138,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3177,9 +3152,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3194,9 +3166,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3499,9 +3468,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3519,9 +3485,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3539,9 +3502,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3559,9 +3519,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3579,9 +3536,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3599,9 +3553,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5531,16 +5482,16 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -9314,9 +9265,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9334,9 +9282,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9354,9 +9299,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9374,9 +9316,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9394,9 +9333,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9414,9 +9350,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9434,9 +9367,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9454,9 +9384,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9736,9 +9663,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9760,9 +9684,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9784,9 +9705,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9808,9 +9726,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10523,215 +10438,2177 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/nth-check": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", - "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^2.0.0" + "node_modules/npm": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/npm/-/npm-12.0.2.tgz", + "integrity": "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/git", + "@npmcli/map-workspaces", + "@npmcli/metavuln-calculator", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "bin-links", + "cacache", + "chalk", + "ci-info", + "diff", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which" + ], + "dev": true, + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^10.0.2", + "@npmcli/config": "^11.0.1", + "@npmcli/fs": "^6.0.0", + "@npmcli/git": "^8.0.0", + "@npmcli/map-workspaces": "^6.0.0", + "@npmcli/metavuln-calculator": "^10.0.0", + "@npmcli/package-json": "^8.0.0", + "@npmcli/promise-spawn": "^10.0.0", + "@npmcli/redact": "^5.0.0", + "@npmcli/run-script": "^11.0.0", + "@sigstore/tuf": "^5.0.0", + "abbrev": "^5.0.0", + "archy": "~1.0.0", + "bin-links": "^7.0.0", + "cacache": "^21.0.1", + "chalk": "^5.6.2", + "ci-info": "^4.4.0", + "diff": "^8.0.2", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^10.1.1", + "ini": "^7.0.0", + "init-package-json": "^9.0.0", + "is-cidr": "^7.0.0", + "json-parse-even-better-errors": "^6.0.0", + "libnpmaccess": "^11.0.0", + "libnpmdiff": "^9.0.2", + "libnpmexec": "^11.0.2", + "libnpmfund": "^8.0.2", + "libnpmorg": "^9.0.0", + "libnpmpack": "^10.0.2", + "libnpmpublish": "^12.0.0", + "libnpmsearch": "^10.0.0", + "libnpmteam": "^9.0.0", + "libnpmversion": "^9.0.0", + "make-fetch-happen": "^16.0.1", + "minimatch": "^10.2.5", + "minipass": "^7.1.3", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^13.0.0", + "nopt": "^10.0.1", + "npm-audit-report": "^8.0.0", + "npm-install-checks": "^9.0.0", + "npm-package-arg": "^14.0.0", + "npm-pick-manifest": "^12.0.0", + "npm-profile": "^13.0.1", + "npm-registry-fetch": "^20.0.1", + "npm-user-validate": "^5.0.0", + "p-map": "^7.0.4", + "pacote": "^22.0.0", + "parse-conflict-json": "^6.0.0", + "proc-log": "^7.0.0", + "qrcode-terminal": "^0.12.0", + "read": "^6.0.0", + "semver": "^7.8.5", + "spdx-expression-parse": "^4.0.0", + "ssri": "^14.0.0", + "supports-color": "^10.2.2", + "tar": "^7.5.19", + "text-table": "~0.2.0", + "tiny-relative-date": "^2.0.2", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^8.0.0", + "which": "^7.0.0" }, - "engines": { - "node": ">=20.19.0" + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" }, - "funding": { - "type": "github", - "url": "https://github.com/fb55/nth-check?sponsor=1" + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/npm/node_modules/@gar/promise-retry": { + "version": "1.0.3", "dev": true, + "inBundle": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" + "node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", "dev": true, - "license": "MIT", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/@npmcli/agent": { + "version": "5.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^9.0.0", + "http-proxy-agent": "^9.0.0", + "https-proxy-agent": "^9.0.0", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^10.0.0" + }, "engines": { - "node": ">= 0.4" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/npm/node_modules/@npmcli/agent/node_modules/agent-base": { + "version": "9.0.0", "dev": true, + "inBundle": true, "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, "engines": { - "node": ">= 0.8" + "node": ">= 20" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "node_modules/npm/node_modules/@npmcli/agent/node_modules/http-proxy-agent": { + "version": "9.1.0", "dev": true, - "license": "ISC", + "inBundle": true, + "license": "MIT", "dependencies": { - "wrappy": "1" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" } }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "node_modules/npm/node_modules/@npmcli/agent/node_modules/https-proxy-agent": { + "version": "9.1.0", "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 20" } }, - "node_modules/openai": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-7.5.0.tgz", - "integrity": "sha512-ZbDBz8FSB8Mv8fFYIUvzTFMdV5vl93/octp1MdtK2lfYepSpfv/ewmeugpKz/cwGtFSx+YuUM4NwpZ2P55YiPA==", - "license": "Apache-2.0", - "engines": { - "node": ">=22.0.0" + "node_modules/npm/node_modules/@npmcli/arborist": { + "version": "10.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^6.0.0", + "@npmcli/installed-package-contents": "^5.0.0", + "@npmcli/map-workspaces": "^6.0.0", + "@npmcli/metavuln-calculator": "^10.0.0", + "@npmcli/name-from-folder": "^5.0.0", + "@npmcli/node-gyp": "^6.0.0", + "@npmcli/package-json": "^8.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^5.0.0", + "@npmcli/run-script": "^11.0.0", + "bin-links": "^7.0.0", + "cacache": "^21.0.1", + "common-ancestor-path": "^2.0.0", + "diff": "^8.0.2", + "hosted-git-info": "^10.1.1", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^10.0.1", + "npm-install-checks": "^9.0.0", + "npm-package-arg": "^14.0.0", + "npm-pick-manifest": "^12.0.0", + "npm-registry-fetch": "^20.0.1", + "pacote": "^22.0.0", + "parse-conflict-json": "^6.0.0", + "proc-log": "^7.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^14.0.0", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^8.0.0", + "walk-up-path": "^4.0.0" }, - "peerDependencies": { - "@aws-sdk/credential-provider-node": ">=3.972.0 <4", - "@smithy/hash-node": ">=4.3.0 <5", - "@smithy/signature-v4": ">=5.4.0 <6", - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" + "bin": { + "arborist": "bin/index.js" }, - "peerDependenciesMeta": { - "@aws-sdk/credential-provider-node": { - "optional": true - }, - "@smithy/hash-node": { - "optional": true - }, - "@smithy/signature-v4": { - "optional": true - }, - "ws": { - "optional": true - }, - "zod": { - "optional": true - } + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, - "node_modules/oxc-parser": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz", - "integrity": "sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==", + "node_modules/npm/node_modules/@npmcli/config": { + "version": "11.0.1", "dev": true, - "license": "MIT", + "inBundle": true, + "license": "ISC", "dependencies": { - "@oxc-project/types": "^0.127.0" + "@npmcli/map-workspaces": "^6.0.0", + "@npmcli/package-json": "^8.0.0", + "ci-info": "^4.0.0", + "ini": "^7.0.0", + "nopt": "^10.0.1", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.127.0", - "@oxc-parser/binding-android-arm64": "0.127.0", - "@oxc-parser/binding-darwin-arm64": "0.127.0", - "@oxc-parser/binding-darwin-x64": "0.127.0", - "@oxc-parser/binding-freebsd-x64": "0.127.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.127.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.127.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.127.0", - "@oxc-parser/binding-linux-arm64-musl": "0.127.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.127.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.127.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.127.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.127.0", - "@oxc-parser/binding-linux-x64-gnu": "0.127.0", - "@oxc-parser/binding-linux-x64-musl": "0.127.0", - "@oxc-parser/binding-openharmony-arm64": "0.127.0", - "@oxc-parser/binding-wasm32-wasi": "0.127.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.127.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.127.0", - "@oxc-parser/binding-win32-x64-msvc": "0.127.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, - "node_modules/oxc-resolver": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz", - "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==", + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "6.0.0", "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" }, - "optionalDependencies": { - "@oxc-resolver/binding-android-arm-eabi": "11.24.2", - "@oxc-resolver/binding-android-arm64": "11.24.2", - "@oxc-resolver/binding-darwin-arm64": "11.24.2", - "@oxc-resolver/binding-darwin-x64": "11.24.2", - "@oxc-resolver/binding-freebsd-x64": "11.24.2", - "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", - "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", - "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", - "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", - "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", - "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", - "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", - "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", - "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", - "@oxc-resolver/binding-linux-x64-musl": "11.24.2", - "@oxc-resolver/binding-openharmony-arm64": "11.24.2", - "@oxc-resolver/binding-wasm32-wasi": "11.24.2", - "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", - "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, - "node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "node_modules/npm/node_modules/@npmcli/git": { + "version": "8.0.0", "dev": true, - "license": "MIT", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^10.0.0", + "ini": "^7.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^12.0.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "which": "^7.0.0" + }, "engines": { - "node": ">=8" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "license": "MIT", + "node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^6.0.0", + "npm-normalize-package-bin": "^6.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, "engines": { - "node": ">=4" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents/node_modules/npm-bundled": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^6.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents/node_modules/npm-normalize-package-bin": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^5.0.0", + "@npmcli/package-json": "^8.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "10.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^21.0.1", + "json-parse-even-better-errors": "^6.0.0", + "pacote": "^22.0.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/package-json": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^8.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^10.1.1", + "json-parse-even-better-errors": "^6.0.0", + "proc-log": "^7.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "10.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^7.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/query": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/redact": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "11.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^6.0.0", + "@npmcli/package-json": "^8.0.0", + "@npmcli/promise-spawn": "^10.0.0", + "node-gyp": "^13.0.0", + "proc-log": "^7.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/bundle": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/core": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.5.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/sign": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^5.0.0", + "@sigstore/core": "^4.0.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^16.0.0", + "proc-log": "^7.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/tuf": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^6.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/verify": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^5.0.0", + "@sigstore/core": "^4.0.1", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@tufjs/models": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.2.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/abbrev": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/aproba": { + "version": "2.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/npm/node_modules/bin-links": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^9.0.0", + "npm-normalize-package-bin": "^6.0.0", + "proc-log": "^7.0.0", + "read-cmd-shim": "^7.0.0", + "write-file-atomic": "^8.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/bin-links/node_modules/cmd-shim": { + "version": "9.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/bin-links/node_modules/npm-normalize-package-bin": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/bin-links/node_modules/read-cmd-shim": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/binary-extensions": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/brace-expansion": { + "version": "5.0.7", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/npm/node_modules/cacache": { + "version": "21.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^6.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^14.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/npm/node_modules/chownr": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/ci-info": { + "version": "4.4.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/cidr-regex": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22" + } + }, + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/diff": { + "version": "8.0.4", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.3", + "dev": true, + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/glob": { + "version": "13.0.6", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/hosted-git-info": { + "version": "10.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/npm/node_modules/iconv-lite": { + "version": "0.7.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/npm/node_modules/ini": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/init-package-json": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^8.0.0", + "npm-package-arg": "^14.0.0", + "promzard": "^4.0.0", + "read": "^6.0.0", + "semver": "^7.7.2", + "validate-npm-package-name": "^8.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/ip-address": { + "version": "10.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/npm/node_modules/is-cidr": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^6.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/npm/node_modules/isexe": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/libnpmaccess": { + "version": "11.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^14.0.0", + "npm-registry-fetch": "^20.0.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff": { + "version": "9.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^10.0.2", + "@npmcli/installed-package-contents": "^5.0.0", + "binary-extensions": "^3.0.0", + "diff": "^8.0.2", + "minimatch": "^10.0.3", + "npm-package-arg": "^14.0.0", + "pacote": "^22.0.0", + "tar": "^7.5.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmexec": { + "version": "11.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/arborist": "^10.0.2", + "@npmcli/package-json": "^8.0.0", + "@npmcli/run-script": "^11.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^14.0.0", + "pacote": "^22.0.0", + "proc-log": "^7.0.0", + "read": "^6.0.0", + "semver": "^7.3.7", + "signal-exit": "^4.1.0", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmfund": { + "version": "8.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^10.0.2" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmorg": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^20.0.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmpack": { + "version": "10.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^10.0.2", + "@npmcli/run-script": "^11.0.0", + "npm-package-arg": "^14.0.0", + "pacote": "^22.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmpublish": { + "version": "12.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^8.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^14.0.0", + "npm-registry-fetch": "^20.0.1", + "proc-log": "^7.0.0", + "semver": "^7.3.7", + "sigstore": "^5.0.0", + "ssri": "^14.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmsearch": { + "version": "10.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^20.0.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmteam": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^20.0.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmversion": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^8.0.0", + "@npmcli/run-script": "^11.0.0", + "json-parse-even-better-errors": "^6.0.0", + "proc-log": "^7.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/lru-cache": { + "version": "11.5.2", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/npm/node_modules/make-fetch-happen": { + "version": "16.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^5.0.0", + "@npmcli/redact": "^5.0.0", + "cacache": "^21.0.0", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^6.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^7.0.0", + "ssri": "^14.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-fetch": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.6", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^7.1.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/minipass-sized": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minizlib": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/mute-stream": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/negotiator": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm/node_modules/node-gyp": { + "version": "13.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^10.0.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^7.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/nopt": { + "version": "10.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-audit-report": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-install-checks": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-package-arg": { + "version": "14.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^10.1.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^8.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-packlist": { + "version": "11.3.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "glob": "^13.0.6", + "ignore-walk": "^9.0.0", + "proc-log": "^7.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-packlist/node_modules/ignore-walk": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest": { + "version": "12.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^9.0.0", + "npm-normalize-package-bin": "^6.0.0", + "npm-package-arg": "^14.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest/node_modules/npm-normalize-package-bin": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-profile": { + "version": "13.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^20.0.0", + "proc-log": "^7.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch": { + "version": "20.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^5.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^16.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^6.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^14.0.0", + "proc-log": "^7.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-user-validate": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/p-map": { + "version": "7.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/pacote": { + "version": "22.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^8.0.0", + "@npmcli/installed-package-contents": "^5.0.0", + "@npmcli/package-json": "^8.0.0", + "@npmcli/promise-spawn": "^10.0.0", + "@npmcli/run-script": "^11.0.0", + "cacache": "^21.0.1", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^14.0.0", + "npm-packlist": "^11.2.0", + "npm-pick-manifest": "^12.0.0", + "npm-registry-fetch": "^20.0.1", + "proc-log": "^7.0.0", + "sigstore": "^5.0.0", + "ssri": "^14.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/parse-conflict-json": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^6.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/path-scurry": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/proc-log": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/proggy": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promzard": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^6.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/npm/node_modules/read": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^4.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/npm/node_modules/semver": { + "version": "7.8.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/sigstore": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^5.0.0", + "@sigstore/core": "^4.0.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^5.0.0", + "@sigstore/tuf": "^5.0.0", + "@sigstore/verify": "^4.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks": { + "version": "2.8.9", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent": { + "version": "10.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.23", + "dev": true, + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/npm/node_modules/ssri": { + "version": "14.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/supports-color": { + "version": "10.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/npm/node_modules/tar": { + "version": "7.5.19", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tiny-relative-date": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tinyglobby": { + "version": "0.2.17", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/tuf-js": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@gar/promise-retry": "^1.0.3", + "@tufjs/models": "5.0.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/undici": { + "version": "6.27.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/validate-npm-package-name": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/walk-up-path": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/npm/node_modules/which": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/write-file-atomic": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/yallist": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/nth-check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", + "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openai": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.5.0.tgz", + "integrity": "sha512-ZbDBz8FSB8Mv8fFYIUvzTFMdV5vl93/octp1MdtK2lfYepSpfv/ewmeugpKz/cwGtFSx+YuUM4NwpZ2P55YiPA==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/oxc-parser": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz", + "integrity": "sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.127.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.127.0", + "@oxc-parser/binding-android-arm64": "0.127.0", + "@oxc-parser/binding-darwin-arm64": "0.127.0", + "@oxc-parser/binding-darwin-x64": "0.127.0", + "@oxc-parser/binding-freebsd-x64": "0.127.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.127.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.127.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.127.0", + "@oxc-parser/binding-linux-arm64-musl": "0.127.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.127.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.127.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.127.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.127.0", + "@oxc-parser/binding-linux-x64-gnu": "0.127.0", + "@oxc-parser/binding-linux-x64-musl": "0.127.0", + "@oxc-parser/binding-openharmony-arm64": "0.127.0", + "@oxc-parser/binding-wasm32-wasi": "0.127.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.127.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.127.0", + "@oxc-parser/binding-win32-x64-msvc": "0.127.0" + } + }, + "node_modules/oxc-resolver": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz", + "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.24.2", + "@oxc-resolver/binding-android-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-x64": "11.24.2", + "@oxc-resolver/binding-freebsd-x64": "11.24.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-musl": "11.24.2", + "@oxc-resolver/binding-openharmony-arm64": "11.24.2", + "@oxc-resolver/binding-wasm32-wasi": "11.24.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" } }, "node_modules/p-limit": { @@ -12679,9 +14556,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -12696,9 +14570,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -12713,9 +14584,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -12730,9 +14598,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -12747,9 +14612,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -12764,9 +14626,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -12781,9 +14640,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -12798,9 +14654,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index 7c5c6c330e..27671c0d11 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "test": "npm run build:test && node scripts/run-workspace-tests-parallel.mjs --concurrency=3", "test:dist": "node scripts/run-workspace-tests-parallel.mjs --concurrency=3", "test:dist:serial": "node scripts/run-workspace-tests-parallel.mjs --serial", + "test:gitoxide-helper": "cargo +1.98.0 test --locked --manifest-path native/gitoxide-helper/Cargo.toml", "dev": "npm --workspace @maka/desktop run dev:hmr --", "dev:full": "npm run build && npm --workspace @maka/desktop run start", "cli:dev": "node packages/cli/dist/dev-cli.js", @@ -62,6 +63,9 @@ "test:product-release": "node --test scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs", "generate:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs", "check:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs --check", + "build:gitoxide-helper": "cargo +1.98.0 build --manifest-path native/gitoxide-helper/Cargo.toml --release --locked", + "prepare:gitoxide-helper": "node scripts/prepare-gitoxide-helper.mjs", + "generate:gitoxide-cargo-notices": "node scripts/generate-gitoxide-cargo-notices.mjs", "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", @@ -82,7 +86,10 @@ "windows:inventory": "node --test scripts/windows-test-inventory.test.mjs && node scripts/windows-test-inventory.mjs --check", "windows:inventory:write": "node scripts/windows-test-inventory.mjs --write", "smoke:windows": "npm run build && npm run smoke:windows:dist", - "smoke:windows:dist": "node scripts/windows-smoke.mjs" + "smoke:windows:dist": "node scripts/windows-smoke.mjs", + "prepare:bundled-npm": "node scripts/prepare-bundled-npm.mjs", + "verify:bundled-npm": "node scripts/verify-bundled-npm-runtime.mjs", + "audit:bundled-npm": "npm audit --prefix apps/desktop/.generated/bundled-npm/audit --omit=dev --audit-level=high" }, "devDependencies": { "@ai-sdk/provider-utils": "5.0.28", @@ -91,9 +98,14 @@ "@astryxdesign/core": "0.4.5", "@biomejs/biome": "2.5.9", "@types/node": "^26.2.0", + "brace-expansion": "5.0.9", + "ip-address": "10.4.0", "knip": "^6.32.2", + "npm": "12.0.2", "patch-package": "8.0.1", + "tar": "7.5.22", "typescript": "^7.0.2", + "undici": "6.28.0", "yaml": "2.9.0" }, "allowScripts": { diff --git a/packages/core/src/__tests__/canonical-runtime-event.test.ts b/packages/core/src/__tests__/canonical-runtime-event.test.ts index 444db1e6ab..3c8be3a909 100644 --- a/packages/core/src/__tests__/canonical-runtime-event.test.ts +++ b/packages/core/src/__tests__/canonical-runtime-event.test.ts @@ -112,4 +112,27 @@ describe('canonical RuntimeEvent encoding', () => { /RuntimeEvent is not losslessly serializable/, ); }); + + test('encodes a deeply frozen durable tool outcome without mutating it', () => { + const event = Object.freeze({ + ...baseEvent({ + role: 'tool', + author: 'tool', + content: Object.freeze({ + kind: 'function_response' as const, + id: 'call-1', + name: 'Write', + result: Object.freeze({ kind: 'text' as const, text: 'done' }), + }), + refs: Object.freeze({ operationId: 'op-1', toolCallId: 'call-1' }), + actions: Object.freeze({ stateDelta: Object.freeze({ durationMs: 0 }) }), + }), + }); + + const encoded = encodeCanonicalRuntimeEvent(event); + + assert.deepEqual(encoded.event, event); + assert.equal(Object.isFrozen(event), true); + assert.equal(Object.isFrozen(event.content), true); + }); }); diff --git a/packages/core/src/__tests__/workspace-version-authority.test.ts b/packages/core/src/__tests__/workspace-version-authority.test.ts index 2d101825c2..6ed40870a6 100644 --- a/packages/core/src/__tests__/workspace-version-authority.test.ts +++ b/packages/core/src/__tests__/workspace-version-authority.test.ts @@ -26,11 +26,25 @@ import { scanWorkspaceBaselineAuthority, validateWorkspaceFactEventLane, workspaceAuthorityIdentity, + workspaceMutationPolicyHashV1, type WorkspaceBaselineAuthorityInput, type WorkspaceSuccessorAuthorityInput, } from '../workspace-version-authority.js'; describe('workspace version authority contract', () => { + it('binds the materialization and mutation execution profiles into one policy identity', () => { + const materialization = `sha256:${'1'.repeat(64)}` as const; + const execution = `sha256:${'2'.repeat(64)}` as const; + const policy = workspaceMutationPolicyHashV1(materialization, execution); + + assert.match(policy, /^sha256:[0-9a-f]{64}$/u); + assert.notEqual( + policy, + workspaceMutationPolicyHashV1(materialization, `sha256:${'3'.repeat(64)}`), + ); + assert.notEqual(policy, workspaceMutationPolicyHashV1(`sha256:${'4'.repeat(64)}`, execution)); + }); + it('decodes only exact v1 baseline facts on the store-owned semantic lane', () => { const { epochOpenedEvent, baselineAcceptedEvent } = buildWorkspaceBaselineAuthorityEvents( baselineInput(), diff --git a/packages/core/src/canonical-runtime-event.ts b/packages/core/src/canonical-runtime-event.ts index ea9b8ddde3..778e00cd76 100644 --- a/packages/core/src/canonical-runtime-event.ts +++ b/packages/core/src/canonical-runtime-event.ts @@ -112,7 +112,19 @@ function omitUndefinedEnvelopeFields(value: object): object { throw new Error('RuntimeEvent is not losslessly serializable'); } if (descriptor.value === undefined) continue; - Object.defineProperty(result, key, descriptor); + // This is a normalization snapshot, not an alias of the caller's object. + // A durable owner is allowed to freeze its event before handing it to the + // canonical writer. Copying that non-configurable descriptor verbatim + // would make the snapshot impossible to normalize below (`content`, + // `refs`, and `actions` are replaced with their undefined-free copies). + // Strict-JSON validation still checks prototypes, enumerable keys, + // accessors, and values; mutability is not part of the persisted meaning. + Object.defineProperty(result, key, { + value: descriptor.value, + enumerable: descriptor.enumerable, + writable: true, + configurable: true, + }); } return result; } diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index fa082005a0..546bad9bd6 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -274,6 +274,20 @@ export interface RuntimeEventManagedWorkspaceMutationV1 { executionProfileDigest: `sha256:${string}`; } +/** + * Immutable proof that one managed mutation reached a committed terminal + * state without advancing the workspace head. The SQLite workspace + * settlement writer is the only authority allowed to persist this fact. + */ +export interface RuntimeEventManagedWorkspaceMutationTerminalV1 { + protocol: 'managed_mutation_terminal_v1'; + disposition: 'operation_failed_no_effect_committed' | 'no_workspace_change_committed'; + operationId: string; + dispatchEventId: string; + outcomeEventId: string; + mutation: RuntimeEventManagedWorkspaceMutationV1; +} + export interface RuntimeEventProtocolMarker { toolBoundary: ToolBoundaryProtocol; } @@ -349,6 +363,8 @@ export interface RuntimeEventActions { continuationStart?: RuntimeEventContinuationStartV2; /** Reserved workspace authority fact; only its atomic SQLite writer may persist it. */ workspaceFact?: RuntimeEventWorkspaceFactEnvelope; + /** Reserved no-effect terminal fact; only the workspace settlement writer may persist it. */ + managedMutationTerminal?: RuntimeEventManagedWorkspaceMutationTerminalV1; } // ============================================================================ @@ -536,6 +552,7 @@ const RUNTIME_ACTIONS_SHAPE = defineObjectShape()( 'runtimeProtocol', 'continuationStart', 'workspaceFact', + 'managedMutationTerminal', ], ); const ANSWER_ACCEPTED_IDENTITY_SHAPE = defineObjectShape()( @@ -579,6 +596,11 @@ const RUNTIME_MANAGED_WORKSPACE_MUTATION_SHAPE = ], [], ); +const RUNTIME_MANAGED_WORKSPACE_MUTATION_TERMINAL_SHAPE = + defineObjectShape()( + ['protocol', 'disposition', 'operationId', 'dispatchEventId', 'outcomeEventId', 'mutation'], + [], + ); const RUNTIME_PROTOCOL_MARKER_SHAPE = defineObjectShape()( ['toolBoundary'], [], @@ -786,7 +808,10 @@ function isRuntimeEventActions(value: unknown): value is RuntimeEventActions { (value.runtimeProtocol === undefined || isRuntimeProtocolMarker(value.runtimeProtocol)) && (value.continuationStart === undefined || isRuntimeContinuationStart(value.continuationStart)) && - (value.workspaceFact === undefined || isRuntimeEventWorkspaceFactEnvelope(value.workspaceFact)) + (value.workspaceFact === undefined || + isRuntimeEventWorkspaceFactEnvelope(value.workspaceFact)) && + (value.managedMutationTerminal === undefined || + isRuntimeManagedWorkspaceMutationTerminal(value.managedMutationTerminal)) ); } @@ -890,6 +915,25 @@ function isRuntimeManagedWorkspaceMutation( ); } +export function isRuntimeManagedWorkspaceMutationTerminal( + value: unknown, +): value is RuntimeEventManagedWorkspaceMutationTerminalV1 { + return ( + isRecord(value) && + hasExactShape(value, RUNTIME_MANAGED_WORKSPACE_MUTATION_TERMINAL_SHAPE) && + value.protocol === 'managed_mutation_terminal_v1' && + (value.disposition === 'operation_failed_no_effect_committed' || + value.disposition === 'no_workspace_change_committed') && + typeof value.operationId === 'string' && + value.operationId.length > 0 && + typeof value.dispatchEventId === 'string' && + value.dispatchEventId.length > 0 && + typeof value.outcomeEventId === 'string' && + value.outcomeEventId.length > 0 && + isRuntimeManagedWorkspaceMutation(value.mutation) + ); +} + /** Platform-independent canonical Git path syntax used by durable mutation facts. */ export function isCanonicalManagedMutationPathV1(path: unknown): path is string { if ( diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 48b545754f..8379b5f8cd 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -194,7 +194,7 @@ export function isTurnStatus(value: unknown): value is TurnStatus { // Header (JSONL line 1) // ============================================================================ -export const SESSION_TOOL_PROFILES = ['headless-coding-v1'] as const; +export const SESSION_TOOL_PROFILES = ['headless-coding-v1', 'managed-coding-v1'] as const; export type SessionToolProfile = (typeof SESSION_TOOL_PROFILES)[number]; export function isSessionToolProfile(value: unknown): value is SessionToolProfile { diff --git a/packages/core/src/workspace-version-authority.ts b/packages/core/src/workspace-version-authority.ts index 386f0dff09..8eb3e7060c 100644 --- a/packages/core/src/workspace-version-authority.ts +++ b/packages/core/src/workspace-version-authority.ts @@ -17,6 +17,7 @@ * under the License. */ +import { createHash } from 'node:crypto'; import { isCanonicalManagedMutationPathV1, type RuntimeEvent } from './runtime-event.js'; export const WORKSPACE_EPOCH_OPENED_FACT_KIND = 'maka.workspace.epoch_opened' as const; @@ -31,6 +32,25 @@ export const WORKSPACE_MATERIALIZATION_SEMANTICS_V1 = export type WorkspaceGitObjectFormat = 'sha1' | 'sha256'; +/** Durable commitment to both Git materialization and Runtime transform semantics. */ +export function workspaceMutationPolicyHashV1( + materializationProfileDigest: `sha256:${string}`, + mutationExecutionProfileDigest: `sha256:${string}`, +): `sha256:${string}` { + if ( + !isSha256Digest(materializationProfileDigest) || + !isSha256Digest(mutationExecutionProfileDigest) + ) { + throw new Error('Invalid workspace mutation policy profile digest'); + } + const hash = createHash('sha256'); + hash.update('maka.workspace-mutation-policy.v1\0', 'utf8'); + hash.update(materializationProfileDigest, 'utf8'); + hash.update('\0', 'utf8'); + hash.update(mutationExecutionProfileDigest, 'utf8'); + return `sha256:${hash.digest('hex')}`; +} + export interface WorkspaceEpochDescriptorV1 { repositoryId: string; workspaceId: string; diff --git a/packages/runtime-host/src/__tests__/bundled-npm-runtime.test.ts b/packages/runtime-host/src/__tests__/bundled-npm-runtime.test.ts new file mode 100644 index 0000000000..6088f5188f --- /dev/null +++ b/packages/runtime-host/src/__tests__/bundled-npm-runtime.test.ts @@ -0,0 +1,190 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { resolveBundledNpmRuntime } from '../server/bundled-npm-runtime.js'; +import { runManagedNpmDependencyProvision } from '../server/managed-dependency-producer-process.js'; + +test('attests a strict packaged npm tree against every declared file', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + + const capability = await resolveBundledNpmRuntime({ + resourcesRoot: fixture.root, + }); + + assert.equal(capability.npmVersion, '12.0.2'); + assert.equal(capability.nodeVersion, process.versions.node); + assert.equal(capability.nodeAbi, process.versions.modules); + assert.equal(capability.platform, process.platform); + assert.equal(capability.arch, process.arch); + assert.equal(capability.npmRuntimeRoot, await realpath(join(fixture.root, 'npm'))); + assert.equal( + capability.npmCliPath, + await realpath(join(fixture.root, 'npm', 'bin', 'npm-cli.js')), + ); + assert.match(capability.runtimeIdentitySha256, /^sha256:[a-f0-9]{64}$/u); +}); + +test('rejects a bundled npm file changed before attestation', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + await writeFile(join(fixture.root, 'npm', 'bin', 'npm-cli.js'), 'tampered\n'); + + await assert.rejects( + resolveBundledNpmRuntime({ resourcesRoot: fixture.root }), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'bundled_npm_integrity_mismatch', + ); +}); + +test('rejects undeclared files in the bundled npm tree', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + await writeFile(join(fixture.root, 'npm', 'undeclared.js'), 'unexpected\n'); + + await assert.rejects( + resolveBundledNpmRuntime({ resourcesRoot: fixture.root }), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'bundled_npm_integrity_mismatch', + ); +}); + +test('revalidates the full npm tree before every managed invocation', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + const capability = await resolveBundledNpmRuntime({ resourcesRoot: fixture.root }); + await writeFile(join(fixture.root, 'npm', 'LICENSE'), 'changed after attestation\n'); + + await assert.rejects( + runManagedNpmDependencyProvision({ + runtime: capability, + producerInput: undefined as never, + }), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'bundled_npm_integrity_mismatch', + ); +}); + +test('rejects a manifest for another platform before issuing capability', async (t) => { + const fixture = await createFixture({ + platform: process.platform === 'win32' ? 'linux' : 'win32', + }); + t.after(fixture.remove); + + await assert.rejects( + resolveBundledNpmRuntime({ resourcesRoot: fixture.root }), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'bundled_npm_platform_mismatch', + ); +}); + +test('classifies malformed manifest JSON as invalid evidence', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + await writeFile(join(fixture.root, 'bundled-npm.json'), '{not-json'); + + await assert.rejects( + resolveBundledNpmRuntime({ resourcesRoot: fixture.root }), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'bundled_npm_manifest_invalid', + ); +}); + +async function createFixture(options: { readonly platform?: NodeJS.Platform } = {}) { + const root = await mkdtemp(join(tmpdir(), 'maka-bundled-npm-runtime-')); + const npmRoot = join(root, 'npm'); + await mkdir(join(npmRoot, 'bin'), { recursive: true }); + await Promise.all([ + writeFile( + join(npmRoot, 'package.json'), + '{"name":"npm","version":"12.0.2","license":"Artistic-2.0"}\n', + ), + writeFile(join(npmRoot, 'bin', 'npm-cli.js'), "console.log('fixture npm');\n"), + writeFile(join(npmRoot, 'LICENSE'), 'Artistic License fixture\n'), + ]); + await writeFile( + join(root, 'bundled-npm.json'), + `${JSON.stringify({ + schemaVersion: 1, + protocol: 'maka_bundled_npm_runtime_v1', + provider: 'desktop/npm-cli', + npmVersion: '12.0.2', + platform: options.platform ?? process.platform, + arch: process.arch, + runtimeRootRelativePath: 'npm', + cliRelativePath: 'npm/bin/npm-cli.js', + securityPatches: approvedSecurityPatches, + files: [ + { + path: 'LICENSE', + bytes: 25, + sha256: 'sha256:871a16ed3b8cf5ceaee50e01124761c4be8310cc9908820b8fde5953f4034f83', + }, + { + path: 'bin/npm-cli.js', + bytes: 28, + sha256: 'sha256:d104584f0a4ea5ead632bdbb4bb3aa6999600e33c92808bda4ce642aa53d5d6b', + }, + { + path: 'package.json', + bytes: 59, + sha256: 'sha256:1c0973dc9bec7dab061b42e164446693459ff4e192e233a33b6b2a86809e6e22', + }, + ], + distributionReady: true, + })}\n`, + ); + return { + root, + remove: () => rm(root, { recursive: true, force: true }), + }; +} + +const approvedSecurityPatches = [ + { + packageName: 'tar', + fromVersion: '7.5.19', + toVersion: '7.5.22', + advisories: ['GHSA-r292-9mhp-454m'], + }, + { + packageName: 'brace-expansion', + fromVersion: '5.0.7', + toVersion: '5.0.9', + advisories: ['GHSA-mh99-v99m-4gvg', 'GHSA-rgw5-rvv9-x895'], + }, + { + packageName: 'ip-address', + fromVersion: '10.2.0', + toVersion: '10.4.0', + advisories: ['GHSA-mwp4-54f8-5fhr', 'GHSA-4xrf-jv44-h6hh', 'GHSA-22jq-vg5j-6vgg'], + }, + { + packageName: 'undici', + fromVersion: '6.27.0', + toVersion: '6.28.0', + advisories: ['GHSA-8xcm-r25x-g524', 'GHSA-m8rv-5g2x-5cg5', 'GHSA-v3r7-h72x-cjcm'], + }, +] as const; diff --git a/packages/runtime-host/src/__tests__/fixtures/gitoxide-candidate-crash-child.ts b/packages/runtime-host/src/__tests__/fixtures/gitoxide-candidate-crash-child.ts new file mode 100644 index 0000000000..4f6715b634 --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/gitoxide-candidate-crash-child.ts @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { readFile, realpath, stat, writeFile } from 'node:fs/promises'; +import type { WorkspaceHeadRecordV1 } from '@maka/core/workspace-version-authority'; +import { + discoverMarkedStorageRoot, + tryAcquireInteractiveRootOwner, +} from '@maka/storage/root-authority'; +import { + admitGitoxideHelperArtifactInternal, + issueGitoxideHelperReleaseArtifactClaimInternal, +} from '../../server/gitoxide-helper-artifact-authority-internal.js'; +import { + type GitoxideMutationCandidateCaptureInput, + createGitoxideMutationCandidateAuthorityInternal, +} from '../../server/gitoxide-helper-mutation-candidate-authority-internal.js'; + +interface CrashInput { + readonly helperPath: string; + readonly storageRoot: string; + readonly baseHead: WorkspaceHeadRecordV1; + readonly capture: GitoxideMutationCandidateCaptureInput; + readonly readyPath: string; +} + +const inputPath = process.argv[2]; +if (!inputPath) throw new Error('Gitoxide candidate crash input path is required'); +const input = JSON.parse(await readFile(inputPath, 'utf8')) as CrashInput; +const helperPath = await realpath(input.helperPath); +const [helperBytes, helperInfo] = await Promise.all([readFile(helperPath), stat(helperPath)]); +const releaseOwnerToken = {}; +const invocationOwnerToken = {}; +const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath: helperPath, + expectedSha256: `sha256:${createHash('sha256').update(helperBytes).digest('hex')}`, + expectedBytes: helperInfo.size, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, +}); +const helperCapability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, +}); +const rootCapability = await discoverMarkedStorageRoot({ path: input.storageRoot }); +const rootOwner = await tryAcquireInteractiveRootOwner(rootCapability); +if (!rootOwner) throw new Error('Gitoxide candidate fixture could not acquire the storage root'); +const authority = await createGitoxideMutationCandidateAuthorityInternal({ + invocationOwnerToken, + helperCapability, + storageRootLease: rootOwner.lease, + baseHead: input.baseHead, + async failpoint(point) { + if (point !== 'after_candidate_ref') return; + await writeFile(input.readyPath, 'ready\n', 'utf8'); + await new Promise(() => undefined); + }, +}); +await authority.capture(input.capture); +throw new Error('Gitoxide candidate crash fixture unexpectedly completed'); diff --git a/packages/runtime-host/src/__tests__/fixtures/gitoxide-managed-mutation-session-crash-child.ts b/packages/runtime-host/src/__tests__/fixtures/gitoxide-managed-mutation-session-crash-child.ts new file mode 100644 index 0000000000..3418fd7155 --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/gitoxide-managed-mutation-session-crash-child.ts @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { readFile, realpath, stat, writeFile } from 'node:fs/promises'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; +import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; +import { requireExecutionStoresWorkspaceMutationAuthorityInternal } from '@maka/storage/execution-stores-workspace-authority-internal'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; +import { + admitGitoxideHelperArtifactInternal, + issueGitoxideHelperReleaseArtifactClaimInternal, +} from '../../server/gitoxide-helper-artifact-authority-internal.js'; +import { openGitoxideManagedMutationSession } from '../../server/gitoxide-managed-mutation-session.js'; + +interface CrashInput { + readonly helperPath: string; + readonly storageRoot: string; + readonly sourceRoot: string; + readonly sessionId: string; + readonly readyPath: string; +} + +const inputPath = process.argv[2]; +if (!inputPath) throw new Error('Missing crash fixture input'); +const input = JSON.parse(await readFile(inputPath, 'utf8')) as CrashInput; +const helperPath = await realpath(input.helperPath); +const [helperBytes, helperInfo] = await Promise.all([readFile(helperPath), stat(helperPath)]); +const releaseOwnerToken = {}; +const invocationOwnerToken = {}; +const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath: helperPath, + expectedSha256: `sha256:${createHash('sha256').update(helperBytes).digest('hex')}`, + expectedBytes: helperInfo.size, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, +}); +const helperCapability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, +}); +const storageCapability = await resolveStorageRoot({ + path: input.storageRoot, + kind: 'interactive', +}); +const storageOwner = await tryAcquireInteractiveRootOwner(storageCapability); +if (!storageOwner) throw new Error('Crash fixture could not own the storage root'); +const stores = await openInteractiveExecutionStoresForWrite(storageOwner.lease); +const operationId = 'operation-gitoxide-process-crash'; +const toolCallId = `${operationId}-call`; +const args = { path: 'notes.txt', content: 'after crash boundary\n' }; +const session = await openGitoxideManagedMutationSession({ + storageRootLease: storageOwner.lease, + sourceRoot: input.sourceRoot, + sessionId: input.sessionId, + invocationOwnerToken, + helperCapability, + settlementAuthority: requireExecutionStoresWorkspaceMutationAuthorityInternal(stores), + async failpoint(point) { + if (point !== 'after_successor_commit') return; + await writeFile(input.readyPath, 'ready\n', 'utf8'); + await new Promise(() => { + setInterval(() => {}, 60_000); + }); + }, +}); +const admission = await session.admitManagedMutation({ + operationId, + toolName: 'Write', + persistedArgs: args, + abortSignal: new AbortController().signal, +}); +const identity = { + sessionId: input.sessionId, + invocationId: `invocation-${operationId}`, + runId: `run-${operationId}`, + turnId: `turn-${operationId}`, +}; +const callEvent: RuntimeEvent = { + id: `${operationId}-call-event`, + ...identity, + ts: 20, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: toolCallId, name: 'Write', args }, + refs: { operationId, toolCallId }, +}; +const dispatchEvent: RuntimeEvent = { + id: `${operationId}_dispatch`, + ...identity, + ts: 20, + partial: false, + role: 'system', + author: 'system', + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash: canonicalToolArgsHash('Write', args), + recoveryMode: 'reconcile', + managedMutation: admission.durableDispatch, + }, + }, + refs: { operationId, toolCallId }, +}; +await stores.runtimeEventStore.commitToolPrepared({ + operationId, + journalEventId: `${operationId}_prepared`, + runtimeEvent: callEvent, + dispatchRuntimeEvent: dispatchEvent, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash: canonicalToolArgsHash('Write', args), + recoveryMode: 'reconcile', + committedAt: 20, +}); +const providerResult = { kind: 'file_write', path: 'notes.txt', bytes: args.content.length }; +const resultContent = { kind: 'json' as const, value: providerResult }; +const outcome: RuntimeEvent = { + id: `${operationId}-outcome-event`, + ...identity, + ts: 21, + partial: false, + role: 'tool', + author: 'tool', + content: { kind: 'function_response', id: toolCallId, name: 'Write', result: resultContent }, + refs: { operationId, toolCallId }, + actions: { stateDelta: { durationMs: 1 } }, +}; +await admission.execute(async () => ({ + content: resultContent, + isError: false, + durationMs: 1, + durableOutcome: outcome, + managedMutationResult: { + canonicalPath: 'notes.txt', + content: args.content, + changed: true, + }, +})); diff --git a/packages/runtime-host/src/__tests__/gitoxide-helper-artifact-authority-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-helper-artifact-authority-internal.test.ts new file mode 100644 index 0000000000..e7038911c0 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-helper-artifact-authority-internal.test.ts @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + admitGitoxideHelperArtifactInternal, + GitoxideHelperArtifactAuthorityError, + issueGitoxideHelperReleaseArtifactClaimInternal, + type GitoxideHelperReleaseArtifactClaim, + verifyGitoxideHelperArtifactForInvocationInternal, +} from '../server/gitoxide-helper-artifact-authority-internal.js'; + +test('rejects a caller-forged Gitoxide helper release claim', async () => { + const forgedClaim = Object.freeze({ + kind: 'gitoxide_helper_release_artifact_claim_v1', + }) as GitoxideHelperReleaseArtifactClaim; + + await assert.rejects( + admitGitoxideHelperArtifactInternal({ + releaseOwnerToken: {}, + invocationOwnerToken: {}, + claim: forgedClaim, + }), + (error) => + error instanceof GitoxideHelperArtifactAuthorityError && + error.code === 'gitoxide_helper_release_claim_invalid', + ); +}); + +test('rejects a release claim reached through a symbolic link or junction', async (t) => { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-helper-artifact-'))); + t.after(() => rm(directory, { recursive: true, force: true })); + const targetDirectory = join(directory, 'target'); + const claimedDirectory = join(directory, 'claimed'); + const targetPath = join(targetDirectory, 'helper'); + const claimedPath = join(claimedDirectory, 'helper'); + const bytes = Buffer.from('trusted helper bytes'); + await mkdir(targetDirectory); + await writeFile(targetPath, bytes); + try { + await symlink( + targetDirectory, + claimedDirectory, + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EPERM') { + t.skip('This Windows host cannot create symbolic links'); + return; + } + throw error; + } + + const releaseOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath: claimedPath, + expectedSha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + expectedBytes: bytes.length, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + + await assert.rejects( + admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken: {}, + claim, + }), + (error) => + error instanceof GitoxideHelperArtifactAuthorityError && + error.code === 'gitoxide_helper_artifact_invalid', + ); +}); + +test('keeps an admitted helper artifact opaque and bound to its invocation owner', async (t) => { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-helper-artifact-'))); + t.after(() => rm(directory, { recursive: true, force: true })); + const executablePath = join(directory, 'helper'); + const bytes = Buffer.from('trusted helper bytes'); + await writeFile(executablePath, bytes); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath, + expectedSha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + expectedBytes: bytes.length, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + + const capability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }); + + assert.deepEqual(capability, { kind: 'gitoxide_helper_invocation_capability_v1' }); + await assert.rejects( + verifyGitoxideHelperArtifactForInvocationInternal({}, capability), + (error) => + error instanceof GitoxideHelperArtifactAuthorityError && + error.code === 'gitoxide_helper_invocation_capability_invalid', + ); + assert.equal( + (await verifyGitoxideHelperArtifactForInvocationInternal(invocationOwnerToken, capability)) + .executablePath, + executablePath, + ); +}); + +test('rejects a helper artifact changed after admission', async (t) => { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-helper-artifact-'))); + t.after(() => rm(directory, { recursive: true, force: true })); + const executablePath = join(directory, 'helper'); + const bytes = Buffer.from('trusted helper bytes'); + await writeFile(executablePath, bytes); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath, + expectedSha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + expectedBytes: bytes.length, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + const capability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }); + + await writeFile(executablePath, Buffer.alloc(bytes.length, 0x78)); + + await assert.rejects( + verifyGitoxideHelperArtifactForInvocationInternal(invocationOwnerToken, capability), + (error) => + error instanceof GitoxideHelperArtifactAuthorityError && + error.code === 'gitoxide_helper_artifact_identity_mismatch', + ); +}); diff --git a/packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts new file mode 100644 index 0000000000..7aec765035 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts @@ -0,0 +1,155 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test, { type TestContext } from 'node:test'; +import { + admitGitoxideHelperArtifactInternal, + type GitoxideHelperInvocationCapability, + issueGitoxideHelperReleaseArtifactClaimInternal, +} from '../server/gitoxide-helper-artifact-authority-internal.js'; +import { + GitoxideHelperInvocationError, + inspectRepositoryWithGitoxideHelperInternal, +} from '../server/gitoxide-helper-invocation-internal.js'; + +interface AdmittedHelper { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; +} + +let admittedHelperPromise: Promise | undefined; + +test('observes exact SHA-1 HEAD identity through the admitted helper capability', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(repositoryPath, 'hello.txt'), 'hello from invocation owner\n'); + git(repositoryPath, ['add', 'hello.txt']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const expectedCommit = git(repositoryPath, ['rev-parse', 'HEAD']); + const expectedTree = git(repositoryPath, ['rev-parse', 'HEAD^{tree}']); + + assert.deepEqual( + await inspectRepositoryWithGitoxideHelperInternal({ + ...helper, + repositoryPath, + }), + { + kind: 'repository_inspected', + protocolVersion: 1, + objectFormat: 'sha1', + headCommitOid: expectedCommit, + headTreeOid: expectedTree, + }, + ); +}); + +test('returns SHA-256 as a policy rejection from the admitted helper', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha256'); + + assert.deepEqual( + await inspectRepositoryWithGitoxideHelperInternal({ ...helper, repositoryPath }), + { + kind: 'repository_rejected', + protocolVersion: 1, + reason: 'unsupported_object_format', + objectFormat: 'sha256', + supportedObjectFormats: ['sha1'], + }, + ); +}); + +test('reports an unborn SHA-1 repository as a stable helper operation failure', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha1'); + + await assert.rejects( + inspectRepositoryWithGitoxideHelperInternal({ ...helper, repositoryPath }), + (error) => + error instanceof GitoxideHelperInvocationError && + error.code === 'gitoxide_helper_operation_failed' && + error.helperReason === 'head_commit_unavailable', + ); +}); + +async function admittedHelper(): Promise { + if (admittedHelperPromise) return admittedHelperPromise; + admittedHelperPromise = (async () => { + const configuredHelperPath = process.env.MAKA_GITOXIDE_HELPER_PATH; + if (!configuredHelperPath) return undefined; + const helperPath = await realpath(configuredHelperPath); + const helperBytes = await readFile(helperPath); + const helperInfo = await stat(helperPath); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath: helperPath, + expectedSha256: `sha256:${createHash('sha256').update(helperBytes).digest('hex')}`, + expectedBytes: helperInfo.size, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + const capability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }); + return { invocationOwnerToken, capability }; + })(); + return admittedHelperPromise; +} + +async function createRepository(t: TestContext, objectFormat: 'sha1' | 'sha256') { + const repositoryPath = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-invocation-'))); + t.after(() => rm(repositoryPath, { recursive: true, force: true })); + git(repositoryPath, ['init', '--quiet', `--object-format=${objectFormat}`]); + return repositoryPath; +} + +function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim(); +} diff --git a/packages/runtime-host/src/__tests__/gitoxide-helper-mutation-candidate-authority-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-helper-mutation-candidate-authority-internal.test.ts new file mode 100644 index 0000000000..b72f53d39f --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-helper-mutation-candidate-authority-internal.test.ts @@ -0,0 +1,454 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFileSync, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, realpath, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test, { type TestContext } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import type { WorkspaceHeadRecordV1 } from '@maka/core/workspace-version-authority'; +import { + resolveStorageRoot, + StorageRootAuthorityError, + tryAcquireInteractiveRootOwner, +} from '@maka/storage/root-authority'; +import { + admitGitoxideHelperArtifactInternal, + type GitoxideHelperInvocationCapability, + issueGitoxideHelperReleaseArtifactClaimInternal, +} from '../server/gitoxide-helper-artifact-authority-internal.js'; +import { + createGitoxideMutationCandidateAuthorityInternal, + gitoxideManagedRepositoryPathInternal, + gitoxideMutationCandidateReceiptRootInternal, + GitoxideMutationCandidateAuthorityError, +} from '../server/gitoxide-helper-mutation-candidate-authority-internal.js'; +import { + admitGitoxideRepositoryInternal, + importAdmittedGitoxideRepositoryInternal, +} from '../server/gitoxide-repository-admission-authority-internal.js'; + +interface AdmittedHelper { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; +} + +let admittedHelperPromise: Promise | undefined; + +test('rejects candidate authority creation after its storage-root lease closes', async (t) => { + const fixture = await candidateFixture(t); + if (!fixture) return; + await fixture.rootOwner.close(); + + await assert.rejects( + createGitoxideMutationCandidateAuthorityInternal({ + ...fixture.helper, + storageRootLease: fixture.rootOwner.lease, + baseHead: fixture.baseHead, + }), + (error) => error instanceof StorageRootAuthorityError && error.code === 'invalid_lease', + ); +}); + +test('persists and revalidates an exact Gitoxide candidate without advancing accepted truth', async (t) => { + const fixture = await candidateFixture(t); + if (!fixture) return; + const authority = await createGitoxideMutationCandidateAuthorityInternal({ + ...fixture.helper, + storageRootLease: fixture.rootOwner.lease, + baseHead: fixture.baseHead, + }); + const input = { + operationId: 'operation-candidate-1', + path: 'docs/result.txt', + content: 'candidate result\n', + executionProfileDigest: `sha256:${'a'.repeat(64)}` as const, + }; + + const first = await authority.capture(input); + assert.equal( + gitBare(fixture.repositoryPath, ['rev-parse', 'refs/maka/accepted']), + fixture.baseHead.commitOid, + ); + assert.equal( + gitBare(fixture.repositoryPath, ['rev-parse', first.receipt.candidateRef]), + first.receipt.candidateCommitOid, + ); + + const reopened = await createGitoxideMutationCandidateAuthorityInternal({ + ...fixture.helper, + storageRootLease: fixture.rootOwner.lease, + baseHead: fixture.baseHead, + }); + const retry = await reopened.capture(input); + assert.deepEqual(retry.receipt, first.receipt); + assert.notEqual(retry.candidateCapability, first.candidateCapability); +}); + +test('rejects a candidate proof whose receipt was recomposed around a valid capability', async (t) => { + const fixture = await candidateFixture(t); + if (!fixture) return; + const authority = await createGitoxideMutationCandidateAuthorityInternal({ + ...fixture.helper, + storageRootLease: fixture.rootOwner.lease, + baseHead: fixture.baseHead, + }); + const proof = await authority.capture({ + operationId: 'operation-recomposed-proof-1', + path: 'docs/result.txt', + content: 'candidate result\n', + executionProfileDigest: `sha256:${'a'.repeat(64)}`, + }); + + assert.throws( + () => + authority.validate({ + candidateCapability: proof.candidateCapability, + receipt: { ...proof.receipt, repositoryId: 'forged-repository' }, + }), + (error) => + error instanceof GitoxideMutationCandidateAuthorityError && + error.code === 'gitoxide_mutation_candidate_identity_conflict', + ); +}); + +test('promotes an exact candidate only after the caller presents its owner-bound proof', async (t) => { + const fixture = await candidateFixture(t); + if (!fixture) return; + const authority = await createGitoxideMutationCandidateAuthorityInternal({ + ...fixture.helper, + storageRootLease: fixture.rootOwner.lease, + baseHead: fixture.baseHead, + }); + const proof = await authority.capture({ + operationId: 'operation-promote-1', + path: 'docs/promoted.txt', + content: 'promoted result\n', + executionProfileDigest: `sha256:${'b'.repeat(64)}`, + }); + + await authority.promote(proof); + assert.equal( + gitBare(fixture.repositoryPath, ['rev-parse', 'refs/maka/accepted']), + proof.receipt.candidateCommitOid, + ); + await authority.promote(proof); +}); + +test('replays promotion from the strict durable receipt without recreating the mutation', async (t) => { + const fixture = await candidateFixture(t); + if (!fixture) return; + const operationId = 'operation-durable-promote-1'; + const authority = await createGitoxideMutationCandidateAuthorityInternal({ + ...fixture.helper, + storageRootLease: fixture.rootOwner.lease, + baseHead: fixture.baseHead, + }); + const proof = await authority.capture({ + operationId, + path: 'docs/recovered.txt', + content: 'recovered result\n', + executionProfileDigest: `sha256:${'c'.repeat(64)}`, + }); + const reopened = await createGitoxideMutationCandidateAuthorityInternal({ + ...fixture.helper, + storageRootLease: fixture.rootOwner.lease, + baseHead: fixture.baseHead, + }); + + const receipt = await reopened.promoteDurable(operationId); + + assert.deepEqual(receipt, proof.receipt); + assert.equal( + gitBare(fixture.repositoryPath, ['rev-parse', 'refs/maka/accepted']), + proof.receipt.candidateCommitOid, + ); + + const exactRetry = await createGitoxideMutationCandidateAuthorityInternal({ + ...fixture.helper, + storageRootLease: fixture.rootOwner.lease, + baseHead: fixture.baseHead, + }); + assert.deepEqual(await exactRetry.promoteDurable(operationId), receipt); +}); + +test('converges when execution stops after candidate ref publication and rejects receipt tampering', async (t) => { + const fixture = await candidateFixture(t); + if (!fixture) return; + let stopped = false; + const interrupted = await createGitoxideMutationCandidateAuthorityInternal({ + ...fixture.helper, + storageRootLease: fixture.rootOwner.lease, + baseHead: fixture.baseHead, + failpoint(point) { + if (point === 'after_candidate_ref' && !stopped) { + stopped = true; + throw new Error('simulated stop after candidate ref'); + } + }, + }); + const input = { + operationId: 'operation-crash-1', + path: 'nested/result.txt', + content: 'crash-safe candidate\n', + executionProfileDigest: `sha256:${'b'.repeat(64)}` as const, + }; + await assert.rejects(interrupted.capture(input), /simulated stop/u); + + const reopened = await createGitoxideMutationCandidateAuthorityInternal({ + ...fixture.helper, + storageRootLease: fixture.rootOwner.lease, + baseHead: fixture.baseHead, + }); + const recovered = await reopened.capture(input); + assert.equal( + gitBare(fixture.repositoryPath, ['rev-parse', recovered.receipt.candidateRef]), + recovered.receipt.candidateCommitOid, + ); + + const receiptRoot = gitoxideMutationCandidateReceiptRootInternal( + fixture.storageRoot, + fixture.baseHead, + ); + const [receiptName] = (await readdir(receiptRoot)).filter((name) => name.endsWith('.json')); + assert.ok(receiptName); + const receiptPath = join(receiptRoot, receiptName); + const tampered = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; + tampered.candidateTreeOid = 'f'.repeat(40); + await writeFile(receiptPath, `${JSON.stringify(tampered)}\n`, 'utf8'); + await assert.rejects( + reopened.capture(input), + (error) => + error instanceof GitoxideMutationCandidateAuthorityError && + error.code === 'gitoxide_mutation_candidate_identity_conflict', + ); +}); + +test('reopens and completes a candidate after the owner process is killed post-ref', async (t) => { + const fixture = await candidateFixture(t); + if (!fixture) return; + const input = { + operationId: 'operation-process-crash-1', + path: 'process/result.txt', + content: 'process crash candidate\n', + executionProfileDigest: `sha256:${'c'.repeat(64)}` as const, + }; + const readyPath = join(dirname(fixture.storageRoot), 'candidate-ready'); + const inputPath = join(dirname(fixture.storageRoot), 'candidate-crash-input.json'); + await writeFile( + inputPath, + JSON.stringify({ + helperPath: process.env.MAKA_GITOXIDE_HELPER_PATH, + storageRoot: fixture.storageRoot, + baseHead: fixture.baseHead, + capture: input, + readyPath, + }), + ); + await fixture.rootOwner.close(); + const child = spawn( + process.execPath, + [ + fileURLToPath(new URL('./fixtures/gitoxide-candidate-crash-child.js', import.meta.url)), + inputPath, + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + try { + await waitForPath(readyPath, 20_000, child, stdout, stderr); + child.kill('SIGKILL'); + await waitForExit(child, 10_000); + + const reopenedRootOwner = await tryAcquireInteractiveRootOwner(fixture.rootCapability); + assert.ok(reopenedRootOwner); + t.after(() => reopenedRootOwner.close()); + const reopened = await createGitoxideMutationCandidateAuthorityInternal({ + ...fixture.helper, + storageRootLease: reopenedRootOwner.lease, + baseHead: fixture.baseHead, + }); + const recovered = await reopened.capture(input); + assert.equal( + gitBare(fixture.repositoryPath, ['rev-parse', recovered.receipt.candidateRef]), + recovered.receipt.candidateCommitOid, + ); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + } +}); + +async function candidateFixture(t: TestContext) { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return undefined; + } + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-candidate-owner-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const sourceRoot = join(root, 'source'); + const rootCapability = await resolveStorageRoot({ + path: join(root, 'storage'), + kind: 'interactive', + }); + const rootOwner = await tryAcquireInteractiveRootOwner(rootCapability); + assert.ok(rootOwner); + t.after(() => (rootOwner.closed ? undefined : rootOwner.close())); + const storageRoot = rootOwner.capability.canonicalPath; + git(root, ['init', '--quiet', '--object-format=sha1', sourceRoot]); + await writeFile(join(sourceRoot, 'hello.txt'), 'candidate base\n'); + git(sourceRoot, ['add', 'hello.txt']); + git(sourceRoot, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const admissionOwnerToken = {}; + const managedRepositoryOwnerToken = {}; + const admitted = await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryPath: sourceRoot, + }); + assert.equal(admitted.kind, 'accepted'); + if (admitted.kind !== 'accepted') return undefined; + const sourceHead: WorkspaceHeadRecordV1 = { + repositoryId: `repository_${'1'.repeat(32)}`, + workspaceId: `workspace_${'2'.repeat(32)}`, + workspaceEpochId: `epoch_${'3'.repeat(32)}`, + workspaceVersionId: `version_${'4'.repeat(32)}`, + acceptedEventId: 'accepted-event-1', + commitOid: git(sourceRoot, ['rev-parse', 'HEAD']), + treeOid: git(sourceRoot, ['rev-parse', 'HEAD^{tree}']), + revision: 1, + }; + const repositoryPath = gitoxideManagedRepositoryPathInternal(storageRoot, sourceHead); + await mkdir(dirname(repositoryPath), { recursive: true }); + const imported = await importAdmittedGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, + destinationRepositoryPath: repositoryPath, + baselineRef: 'refs/maka/accepted', + }); + assert.equal(imported.sourceHeadCommitOid, sourceHead.commitOid); + assert.equal(imported.sourceTreeOid, sourceHead.treeOid); + const baseHead: WorkspaceHeadRecordV1 = { + ...sourceHead, + commitOid: imported.baselineCommitOid, + treeOid: imported.baselineTreeOid, + }; + return { helper, storageRoot, rootCapability, rootOwner, repositoryPath, baseHead }; +} + +async function admittedHelper(): Promise { + if (admittedHelperPromise) return admittedHelperPromise; + admittedHelperPromise = (async () => { + const configuredHelperPath = process.env.MAKA_GITOXIDE_HELPER_PATH; + if (!configuredHelperPath) return undefined; + const helperPath = await realpath(configuredHelperPath); + const helperBytes = await readFile(helperPath); + const helperInfo = await stat(helperPath); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath: helperPath, + expectedSha256: `sha256:${createHash('sha256').update(helperBytes).digest('hex')}`, + expectedBytes: helperInfo.size, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + const helperCapability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }); + return { invocationOwnerToken, helperCapability }; + })(); + return admittedHelperPromise; +} + +function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); +} + +function gitBare(repositoryPath: string, args: readonly string[]): string { + return execFileSync('git', ['--git-dir', repositoryPath, ...args], { + encoding: 'utf8', + }).trim(); +} + +async function waitForPath( + path: string, + timeoutMs: number, + child: ReturnType, + stdout: Buffer[], + stderr: Buffer[], +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + if ( + await stat(path) + .then(() => true) + .catch(() => false) + ) + return; + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error( + `Crash fixture exited before ready: ${Buffer.concat(stdout).toString('utf8')} ${Buffer.concat(stderr).toString('utf8')}`, + ); + } + if (Date.now() >= deadline) throw new Error('Timed out waiting for candidate crash fixture'); + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +async function waitForExit(child: ReturnType, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + let timer: ReturnType | undefined; + try { + await Promise.race([ + new Promise((resolve, reject) => { + child.once('exit', () => resolve()); + child.once('error', reject); + }), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('Timed out waiting for candidate crash fixture exit')), + timeoutMs, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts new file mode 100644 index 0000000000..4e86fb9525 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts @@ -0,0 +1,300 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { selectCollaborationTools } from '@maka/runtime/plan-mode'; +import type { MakaToolContext } from '@maka/runtime/tool-runtime'; +import type { + AcquireManagedDependencyEnvironmentInput, + ManagedDependencyEnvironmentAuthority, + ManagedDependencyEnvironmentIdentityV1, +} from '@maka/storage/managed-dependency-environment'; +import type { ManagedWorkspaceFilesystemWorker } from '@maka/storage/managed-workspace-owner'; +import { + admitGitoxideHelperArtifactInternal, + issueGitoxideHelperReleaseArtifactClaimInternal, + type GitoxideHelperInvocationCapability, +} from '../server/gitoxide-helper-artifact-authority-internal.js'; +import { createGitoxideManagedInspectionComposition } from '../server/gitoxide-managed-inspection.js'; + +const fakeNpmRuntime = Object.freeze({ + npmVersion: '12.0.2' as const, + nodeVersion: '24.15.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + resourcesRoot: join(tmpdir(), 'not-used-resources'), + nodeExecutablePath: process.execPath, + npmRuntimeRoot: join(tmpdir(), 'not-used-npm-runtime'), + npmCliPath: join(tmpdir(), 'not-used-npm-cli.js'), + runtimeIdentitySha256: `sha256:${'1'.repeat(64)}` as const, +}); + +test('keeps provisioning-backed managed inspection out of read-only Plan Mode', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-gitoxide-inspection-plan-')); + t.after(() => rm(root, { recursive: true, force: true })); + const composition = await createGitoxideManagedInspectionComposition({ + storageRoot: root, + invocationOwnerToken: {}, + helperCapability: Object.freeze({ + kind: 'gitoxide_helper_invocation_capability_v1' as const, + }), + npmRuntime: fakeNpmRuntime, + dependencyAuthority: inertDependencyAuthority(), + filesystemWorker: rejectingFilesystemWorker(), + }); + t.after(() => composition.close()); + + assert.equal(composition.tool.categoryHint, 'custom_tool'); + assert.equal(composition.tool.recoveryMode, 'never_auto_retry'); + assert.deepEqual( + selectCollaborationTools({ + mode: 'plan', + tools: [composition.tool], + hasActiveExecution: false, + }), + [], + ); + await assert.rejects( + Promise.resolve( + composition.tool.impl( + { kind: 'read', path: 'foo/../node_modules/escape.js' }, + toolContext(root), + ), + ), + /dot-dot/u, + ); +}); + +test('binds an inspection tool to the current accepted repository provider', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-gitoxide-inspection-accepted-')); + t.after(() => rm(root, { recursive: true, force: true })); + const composition = await createGitoxideManagedInspectionComposition({ + storageRoot: root, + invocationOwnerToken: {}, + helperCapability: Object.freeze({ + kind: 'gitoxide_helper_invocation_capability_v1' as const, + }), + npmRuntime: fakeNpmRuntime, + dependencyAuthority: inertDependencyAuthority(), + filesystemWorker: rejectingFilesystemWorker(), + }); + t.after(() => composition.close()); + let providerCalls = 0; + const tool = composition.toolForRepositoryProvider(async () => { + providerCalls += 1; + return Object.freeze({ + acceptedCommitOid: '1'.repeat(40), + acceptedTreeOid: '2'.repeat(40), + async readFile(path: string) { + return Object.freeze({ path, content: 'accepted revision two\n' }); + }, + async materializeProjection() { + throw new Error('not used'); + }, + }); + }); + + const result = await tool.impl({ kind: 'read', path: 'notes.txt' }, toolContext(root)); + assert.equal(providerCalls, 1); + assert.equal(result.acceptedCommitOid, '1'.repeat(40)); + assert.equal(result.acceptedTreeOid, '2'.repeat(40)); + assert.deepEqual(result.result, { kind: 'read', content: 'accepted revision two\n' }); +}); + +test('reads source and dependency files through the real Gitoxide product data plane', async (t) => { + const admittedHelper = await admitRealHelper(); + if (!admittedHelper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the product composition test'); + return; + } + const root = await mkdtemp(join(tmpdir(), 'maka-gitoxide-inspection-product-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sourceRoot = join(root, 'source'); + const dependencyRoot = join(root, 'leased', 'node_modules'); + await Promise.all([ + mkdir(join(sourceRoot, 'src'), { recursive: true }), + mkdir(join(dependencyRoot, 'fixture-package'), { recursive: true }), + ]); + const manifestText = '{"name":"fixture","version":"1.0.0"}\n'; + const lockfileText = '{"name":"fixture","version":"1.0.0","lockfileVersion":3,"packages":{}}\n'; + await Promise.all([ + writeFile(join(sourceRoot, 'package.json'), manifestText), + writeFile(join(sourceRoot, 'package-lock.json'), lockfileText), + writeFile(join(sourceRoot, 'src', 'index.ts'), 'export const answer = 42;\n'), + writeFile( + join(dependencyRoot, 'fixture-package', 'package.json'), + '{"name":"fixture-package"}\n', + ), + ]); + git(sourceRoot, ['init', '--quiet']); + git(sourceRoot, ['add', '.']); + git(sourceRoot, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + + const identities: ManagedDependencyEnvironmentIdentityV1[] = []; + let closed = false; + const dependencyAuthority: ManagedDependencyEnvironmentAuthority = Object.freeze({ + async acquire( + identity: ManagedDependencyEnvironmentIdentityV1, + source: AcquireManagedDependencyEnvironmentInput, + ) { + identities.push(identity); + assert.equal(Buffer.from(source.manifestBytes).toString('utf8'), manifestText); + assert.equal(Buffer.from(source.lockfileBytes).toString('utf8'), lockfileText); + return Object.freeze({ + environmentId: identity.environmentId, + dependencyRoot, + async release() {}, + }); + }, + async close() { + closed = true; + }, + }); + const seenCwds: string[] = []; + const filesystemWorker: ManagedWorkspaceFilesystemWorker = { + async execute(input) { + seenCwds.push(input.cwd); + assert.equal(input.operation.kind, 'read'); + if (input.operation.kind !== 'read') throw new Error('unexpected operation'); + return { + kind: 'read', + content: await readFile(join(input.cwd, input.operation.path), 'utf8'), + }; + }, + }; + const composition = await createGitoxideManagedInspectionComposition({ + storageRoot: root, + invocationOwnerToken: admittedHelper.invocationOwnerToken, + helperCapability: admittedHelper.helperCapability, + npmRuntime: fakeNpmRuntime, + dependencyAuthority, + filesystemWorker, + }); + + const source = await composition.tool.impl( + { kind: 'read', path: 'src/index.ts' }, + toolContext(sourceRoot), + ); + assert.deepEqual(source.result, { kind: 'read', content: 'export const answer = 42;\n' }); + assert.equal(source.dependencyEnvironmentId, undefined); + assert.equal(identities.length, 0); + assert.equal(seenCwds.length, 0); + + const dependency = await composition.tool.impl( + { kind: 'read', path: 'node_modules/fixture-package/package.json' }, + toolContext(sourceRoot), + ); + assert.deepEqual(dependency.result, { + kind: 'read', + content: '{"name":"fixture-package"}\n', + }); + assert.equal(identities.length, 1); + assert.equal(seenCwds[0], dependencyRoot); + await composition.close(); + assert.equal(closed, true); +}); + +function inertDependencyAuthority(): ManagedDependencyEnvironmentAuthority { + return Object.freeze({ + async acquire() { + throw new Error('not used'); + }, + async close() {}, + }); +} + +function rejectingFilesystemWorker(): ManagedWorkspaceFilesystemWorker { + return { + async execute() { + throw new Error('not used'); + }, + }; +} + +function toolContext(cwd: string): MakaToolContext { + return { + sessionId: 'session-managed-inspection', + turnId: 'turn-managed-inspection', + toolCallId: 'tool-managed-inspection', + cwd, + abortSignal: new AbortController().signal, + } as MakaToolContext; +} + +interface AdmittedHelper { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; +} + +async function admitRealHelper(): Promise { + const executablePath = process.env.MAKA_GITOXIDE_HELPER_PATH; + if (!executablePath) return undefined; + const canonicalPath = await realpath(executablePath); + const bytes = await readFile(canonicalPath); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath: canonicalPath, + expectedSha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + expectedBytes: (await stat(canonicalPath)).size, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + return { + invocationOwnerToken, + helperCapability: await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }), + }; +} + +function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + GIT_CONFIG_NOSYSTEM: '1', + HOME: join(cwd, '.home'), + GIT_CONFIG_GLOBAL: join(cwd, '.missing-global-config'), + GIT_CONFIG_COUNT: '0', + GIT_TERMINAL_PROMPT: '0', + }, + }).trim(); +} diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-admission.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-admission.test.ts new file mode 100644 index 0000000000..8b09a3f84d --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-admission.test.ts @@ -0,0 +1,725 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; +import type { + WorkspaceHeadRecordV1, + WorkspaceVersionRecordV1, +} from '@maka/core/workspace-version-authority'; +import { + createGitoxideManagedMutationAdmissionInternal, + reconcilePreparedGitoxideManagedMutationInternal, + reconcileGitoxideManagedMutationProjectionInternal, + type GitoxideManagedMutationSettlementAuthorityInternal, +} from '../server/gitoxide-managed-mutation-admission.js'; +import { GitoxideHelperInvocationError } from '../server/gitoxide-helper-invocation-internal.js'; + +test('reconciles an active T1 from immutable Git and ledger facts without rerunning a tool', async () => { + const head = baselineHead(); + const version = baselineVersion(head); + const operationId = 'op-active-t1'; + const args = { path: 'notes.txt', content: 'recovered\n' }; + const hash = canonicalToolArgsHash('Write', args); + const managedMutation = { + protocol: 'managed_mutation_v1' as const, + repositoryId: head.repositoryId, + workspaceId: head.workspaceId, + workspaceEpochId: head.workspaceEpochId, + workspaceInstanceId: 'instance_44444444444444444444444444444444', + objectFormat: 'sha1' as const, + baseWorkspaceVersionId: head.workspaceVersionId, + baseAcceptedEventId: head.acceptedEventId, + baseHeadRevision: head.revision, + baseCommitOid: head.commitOid, + baseTreeOid: head.treeOid, + expectedPaths: ['notes.txt'], + executionProfileDigest: + 'sha256:4d9d03626705fdc7f895256b7a94b6c6fdd04c7bf76c70e67bab6a6f177e4b99' as const, + }; + const callEvent: RuntimeEvent = { + id: `${operationId}_call`, + sessionId: 'session-1', + invocationId: 'run-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 10, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'call-1', name: 'Write', args }, + refs: { operationId, toolCallId: 'call-1' }, + }; + const dispatchEvent: RuntimeEvent = { + id: `${operationId}_dispatch`, + sessionId: 'session-1', + invocationId: 'run-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 10, + partial: false, + role: 'system', + author: 'system', + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId, + providerToolCallId: 'call-1', + toolName: 'Write', + canonicalArgsHash: hash, + recoveryMode: 'reconcile', + managedMutation, + }, + }, + refs: { operationId, toolCallId: 'call-1' }, + }; + let successorCommits = 0; + let captures = 0; + const result = await reconcilePreparedGitoxideManagedMutationInternal({ + sessionId: 'session-1', + reservation: { + workspaceInstanceId: managedMutation.workspaceInstanceId, + repositoryId: head.repositoryId, + workspaceId: head.workspaceId, + workspaceEpochId: head.workspaceEpochId, + operationId, + dispatchEventId: dispatchEvent.id, + baseWorkspaceVersionId: head.workspaceVersionId, + baseAcceptedEventId: head.acceptedEventId, + baseHeadRevision: head.revision, + baseCommitOid: head.commitOid, + baseTreeOid: head.treeOid, + expectedPaths: ['notes.txt'], + executionProfileDigest: managedMutation.executionProfileDigest, + reservedAt: 10, + }, + operation: { + operationId, + invocationId: 'run-1', + runId: 'run-1', + turnId: 'turn-1', + providerToolCallId: 'call-1', + toolName: 'Write', + canonicalArgsHash: hash, + recoveryMode: 'reconcile', + currentState: 'prepared', + callEventId: callEvent.id, + dispatchEventId: dispatchEvent.id, + }, + runtimeEvents: [callEvent, dispatchEvent], + settlementAuthority: { + readHead: async () => head, + readVersion: async () => version, + commitSuccessor: async (input) => { + successorCommits += 1; + assert.equal(input.toolOutcome.runtimeEvent.id, `${operationId}_response`); + assert.equal(input.toolOutcome.runtimeEvent.content?.kind, 'function_response'); + return { created: true, outcomeRuntimeEventSeq: 3, head: { ...head, revision: 2 } }; + }, + commitTerminal: async () => { + throw new Error('changed recovery must commit a successor'); + }, + }, + candidateAuthorityForHead: async () => ({ + readBaseFile: async () => ({ content: 'baseline\n', blobOid: '5'.repeat(40) }), + capture: async (input) => { + captures += 1; + assert.equal(input.content, 'recovered\n'); + return { + receipt: { + repositoryId: head.repositoryId, + workspaceId: head.workspaceId, + workspaceEpochId: head.workspaceEpochId, + workspaceVersionId: head.workspaceVersionId, + baseAcceptedEventId: head.acceptedEventId, + baseHeadRevision: head.revision, + baseCommitOid: head.commitOid, + baseTreeOid: head.treeOid, + candidateCommitOid: '3'.repeat(40), + candidateTreeOid: '4'.repeat(40), + resultBlobOid: '6'.repeat(40), + path: 'notes.txt', + contentSha256: sha256('recovered\n'), + executionProfileDigest: managedMutation.executionProfileDigest, + }, + }; + }, + promote: async (proof) => proof.receipt, + promoteDurable: async () => { + throw new Error('not used'); + }, + }), + }); + + assert.equal(result, 'successor_committed'); + assert.equal(captures, 1); + assert.equal(successorCommits, 1); +}); + +test('recovery releases T1 when candidate policy proves no publication', async () => { + const head = baselineHead(); + const version = baselineVersion(head); + const operationId = 'op-recovery-policy-rejected'; + const args = { path: 'notes.txt', content: 'recovered\n' }; + const canonicalArgsHash = canonicalToolArgsHash('Write', args); + const executionProfileDigest = + 'sha256:4d9d03626705fdc7f895256b7a94b6c6fdd04c7bf76c70e67bab6a6f177e4b99' as const; + const identity = { + sessionId: 'session-1', + invocationId: 'run-1', + runId: 'run-1', + turnId: 'turn-1', + }; + const callEvent: RuntimeEvent = { + id: `${operationId}_call`, + ...identity, + ts: 10, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'call-1', name: 'Write', args }, + refs: { operationId, toolCallId: 'call-1' }, + }; + const managedMutation = { + protocol: 'managed_mutation_v1' as const, + repositoryId: head.repositoryId, + workspaceId: head.workspaceId, + workspaceEpochId: head.workspaceEpochId, + workspaceInstanceId: 'instance_44444444444444444444444444444444', + objectFormat: 'sha1' as const, + baseWorkspaceVersionId: head.workspaceVersionId, + baseAcceptedEventId: head.acceptedEventId, + baseHeadRevision: head.revision, + baseCommitOid: head.commitOid, + baseTreeOid: head.treeOid, + expectedPaths: ['notes.txt'], + executionProfileDigest, + }; + const dispatchEvent: RuntimeEvent = { + id: `${operationId}_dispatch`, + ...identity, + ts: 10, + partial: false, + role: 'system', + author: 'system', + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId, + providerToolCallId: 'call-1', + toolName: 'Write', + canonicalArgsHash, + recoveryMode: 'reconcile', + managedMutation, + }, + }, + refs: { operationId, toolCallId: 'call-1' }, + }; + let terminalOutcome: RuntimeEvent | undefined; + + const result = await reconcilePreparedGitoxideManagedMutationInternal({ + sessionId: identity.sessionId, + reservation: { + workspaceInstanceId: managedMutation.workspaceInstanceId, + repositoryId: head.repositoryId, + workspaceId: head.workspaceId, + workspaceEpochId: head.workspaceEpochId, + operationId, + dispatchEventId: dispatchEvent.id, + baseWorkspaceVersionId: head.workspaceVersionId, + baseAcceptedEventId: head.acceptedEventId, + baseHeadRevision: head.revision, + baseCommitOid: head.commitOid, + baseTreeOid: head.treeOid, + expectedPaths: ['notes.txt'], + executionProfileDigest, + reservedAt: 10, + }, + operation: { + operationId, + invocationId: identity.invocationId, + runId: identity.runId, + turnId: identity.turnId, + providerToolCallId: 'call-1', + toolName: 'Write', + canonicalArgsHash, + recoveryMode: 'reconcile', + currentState: 'prepared', + callEventId: callEvent.id, + dispatchEventId: dispatchEvent.id, + }, + runtimeEvents: [callEvent, dispatchEvent], + settlementAuthority: { + readHead: async () => head, + readVersion: async () => version, + commitSuccessor: async () => { + throw new Error('rejected candidate must not commit a successor'); + }, + commitTerminal: async (input) => { + terminalOutcome = input.toolOutcome.runtimeEvent; + return { created: true, outcomeRuntimeEventSeq: 3 }; + }, + }, + candidateAuthorityForHead: async () => ({ + readBaseFile: async () => ({ content: 'baseline\n', blobOid: '5'.repeat(40) }), + capture: async () => { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + 'candidate exceeds the managed content limit', + 'successor_content_limit_exceeded', + ); + }, + promote: async () => { + throw new Error('rejected candidate must not promote'); + }, + promoteDurable: async () => { + throw new Error('not used'); + }, + }), + }); + + assert.equal(result, 'terminal_committed'); + assert.equal(terminalOutcome?.content?.kind, 'function_response'); + assert.equal( + terminalOutcome?.content?.kind === 'function_response' + ? terminalOutcome.content.isError + : undefined, + true, + ); +}); + +test('commits the exact Runtime outcome before promoting the Gitoxide candidate', async () => { + const order: string[] = []; + const head = baselineHead(); + const version = baselineVersion(head); + const authority: GitoxideManagedMutationSettlementAuthorityInternal = { + readHead: async () => head, + readVersion: async () => version, + commitSuccessor: async (input) => { + order.push('sqlite'); + assert.equal(input.toolOutcome.runtimeEvent.id, 'op-1_response'); + assert.equal(input.successor.successor.commitOid, '3'.repeat(40)); + return { + created: true, + outcomeRuntimeEventSeq: 4, + head: { ...head, commitOid: '3'.repeat(40), treeOid: '4'.repeat(40), revision: 2 }, + }; + }, + commitTerminal: async () => { + throw new Error('changed success must not use a no-effect terminal'); + }, + }; + const admissionOwner = createGitoxideManagedMutationAdmissionInternal({ + workspaceInstanceId: 'instance_44444444444444444444444444444444', + workspaceId: head.workspaceId, + workspaceEpochId: head.workspaceEpochId, + settlementAuthority: authority, + candidateAuthorityForHead: async () => ({ + readBaseFile: async () => ({ content: 'before\n', blobOid: '5'.repeat(40) }), + capture: async () => ({ + receipt: { + repositoryId: head.repositoryId, + workspaceId: head.workspaceId, + workspaceEpochId: head.workspaceEpochId, + workspaceVersionId: head.workspaceVersionId, + baseAcceptedEventId: head.acceptedEventId, + baseHeadRevision: head.revision, + baseCommitOid: head.commitOid, + baseTreeOid: head.treeOid, + candidateCommitOid: '3'.repeat(40), + candidateTreeOid: '4'.repeat(40), + resultBlobOid: '6'.repeat(40), + path: 'notes.txt', + contentSha256: sha256('after\n'), + executionProfileDigest: + 'sha256:4d9d03626705fdc7f895256b7a94b6c6fdd04c7bf76c70e67bab6a6f177e4b99', + }, + }), + promote: async (proof) => { + order.push('promote'); + return proof.receipt; + }, + promoteDurable: async () => { + throw new Error('not used'); + }, + }), + }); + + const admission = await admissionOwner({ + operationId: 'op-1', + toolName: 'Write', + persistedArgs: { path: 'notes.txt', content: 'after\n' }, + abortSignal: new AbortController().signal, + }); + const durableOutcome = outcomeEvent(); + const content = { + kind: 'json' as const, + value: { kind: 'file_diff', paths: ['notes.txt'], diff: 'diff' }, + }; + const settlement = await admission.execute(async () => ({ + content, + isError: false, + durationMs: 5, + durableOutcome, + managedMutationResult: { + canonicalPath: 'notes.txt', + content: 'after\n', + changed: true, + }, + })); + + assert.equal(settlement.kind, 'workspace_successor_committed'); + assert.deepEqual(order, ['sqlite', 'promote']); + assert.equal(admission.gitoxideTransform?.baseContent, 'before\n'); +}); + +test('commits no-op success and deterministic failure without advancing the workspace', async () => { + const head = baselineHead(); + const version = baselineVersion(head); + const dispositions: string[] = []; + const authority: GitoxideManagedMutationSettlementAuthorityInternal = { + readHead: async () => head, + readVersion: async () => version, + commitSuccessor: async () => { + throw new Error('no-effect operations must not advance the workspace'); + }, + commitTerminal: async (input) => { + dispositions.push(input.disposition); + return { created: true, outcomeRuntimeEventSeq: dispositions.length }; + }, + }; + const owner = createGitoxideManagedMutationAdmissionInternal({ + workspaceInstanceId: 'instance_44444444444444444444444444444444', + workspaceId: head.workspaceId, + workspaceEpochId: head.workspaceEpochId, + settlementAuthority: authority, + candidateAuthorityForHead: async () => ({ + readBaseFile: async () => ({ content: 'same\n', blobOid: '5'.repeat(40) }), + capture: async () => { + throw new Error('no-effect operations must not create a candidate'); + }, + promote: async () => { + throw new Error('no-effect operations must not promote a candidate'); + }, + promoteDurable: async () => { + throw new Error('not used'); + }, + }), + }); + + const noChange = await owner({ + operationId: 'op-no-change', + toolName: 'Write', + persistedArgs: { path: 'notes.txt', content: 'same\n' }, + abortSignal: new AbortController().signal, + }); + const noChangeOutcome = outcomeEvent('op-no-change', false); + const noChangeSettlement = await noChange.execute(async () => ({ + content: { + kind: 'json' as const, + value: { kind: 'file_diff', paths: ['notes.txt'], diff: 'diff' }, + }, + isError: false, + durationMs: 5, + durableOutcome: noChangeOutcome, + managedMutationResult: { + canonicalPath: 'notes.txt', + content: 'same\n', + changed: false, + }, + })); + + const failed = await owner({ + operationId: 'op-failed', + toolName: 'Edit', + persistedArgs: { path: 'notes.txt', old_string: 'missing', new_string: 'new' }, + abortSignal: new AbortController().signal, + }); + const failedOutcome = outcomeEvent('op-failed', true); + const failedSettlement = await failed.execute(async () => ({ + content: { + kind: 'json' as const, + value: { kind: 'file_diff', paths: ['notes.txt'], diff: 'diff' }, + }, + isError: true, + durationMs: 5, + durableOutcome: failedOutcome, + })); + + assert.equal(noChangeSettlement.kind, 'no_workspace_change_committed'); + assert.equal(failedSettlement.kind, 'operation_failed_no_effect_committed'); + assert.deepEqual(dispositions, [ + 'no_workspace_change_committed', + 'operation_failed_no_effect_committed', + ]); +}); + +test('commits a Runtime-owned no-effect failure when candidate policy rejects before publication', async () => { + const head = baselineHead(); + const version = baselineVersion(head); + const terminalOutcomes: RuntimeEvent[] = []; + const owner = createGitoxideManagedMutationAdmissionInternal({ + workspaceInstanceId: 'instance_44444444444444444444444444444444', + workspaceId: head.workspaceId, + workspaceEpochId: head.workspaceEpochId, + settlementAuthority: { + readHead: async () => head, + readVersion: async () => version, + commitSuccessor: async () => { + throw new Error('rejected candidate must not advance the workspace'); + }, + commitTerminal: async (input) => { + terminalOutcomes.push(input.toolOutcome.runtimeEvent); + return { created: true, outcomeRuntimeEventSeq: 4 }; + }, + }, + candidateAuthorityForHead: async () => ({ + readBaseFile: async () => ({ content: 'before\n', blobOid: '5'.repeat(40) }), + capture: async () => { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + 'candidate exceeds the managed content limit', + 'successor_content_limit_exceeded', + ); + }, + promote: async () => { + throw new Error('rejected candidate must not be promoted'); + }, + promoteDurable: async () => { + throw new Error('not used'); + }, + }), + }); + const admission = await owner({ + operationId: 'op-candidate-policy-rejected', + toolName: 'Write', + persistedArgs: { path: 'notes.txt', content: 'after\n' }, + abortSignal: new AbortController().signal, + }); + const successContent = { + kind: 'json' as const, + value: { kind: 'file_write', path: 'notes.txt', bytes: 6 }, + }; + const failureContent = { + kind: 'json' as const, + value: { error: 'Managed workspace candidate was rejected before publication' }, + }; + const successOutcome = outcomeEvent('op-candidate-policy-rejected', false); + const failureOutcome: RuntimeEvent = { + ...outcomeEvent('op-candidate-policy-rejected', true), + content: { + kind: 'function_response', + id: 'call-1', + name: 'Write', + result: failureContent, + isError: true, + }, + }; + const operation = Object.assign( + async () => ({ + content: successContent, + isError: false, + durationMs: 5, + durableOutcome: successOutcome, + managedMutationResult: { + canonicalPath: 'notes.txt', + content: 'after\n', + changed: true, + }, + }), + { + rejectNoEffect: async () => ({ + content: failureContent, + isError: true, + durationMs: 5, + durableOutcome: failureOutcome, + }), + }, + ); + + const settlement = await admission.execute(operation); + + assert.equal(settlement.kind, 'operation_failed_no_effect_committed'); + assert.equal(terminalOutcomes.length, 1); + assert.equal(terminalOutcomes[0]?.content?.kind, 'function_response'); + assert.equal( + terminalOutcomes[0]?.content?.kind === 'function_response' + ? terminalOutcomes[0].content.isError + : undefined, + true, + ); +}); + +test('replays only candidate promotion after SQLite already accepted the successor', async () => { + const parent = baselineHead(); + const parentVersion = baselineVersion(parent); + const successor: WorkspaceVersionRecordV1 = { + protocol: 'workspace_version_accepted_v1', + repositoryId: parent.repositoryId, + workspaceId: parent.workspaceId, + workspaceEpochId: parent.workspaceEpochId, + workspaceVersionId: 'version_99999999999999999999999999999999', + objectFormat: 'sha1', + parents: [parent.workspaceVersionId], + origin: { + kind: 'tool_mutation', + operationId: 'op-recover', + dispatchEventId: 'op-recover_dispatch', + outcomeEventId: 'op-recover_response', + }, + baseAcceptedEventId: parent.acceptedEventId, + baseHeadRevision: parent.revision, + commitOid: '3'.repeat(40), + treeOid: '4'.repeat(40), + policyHash: parentVersion.policyHash, + treeDeltaDigest: `sha256:${'a'.repeat(64)}`, + changedPaths: ['notes.txt'], + changedFileCount: 1, + deletedFileCount: 0, + executionProfileDigest: + 'sha256:4d9d03626705fdc7f895256b7a94b6c6fdd04c7bf76c70e67bab6a6f177e4b99', + acceptedEventId: 'successor-event-1', + committedAt: 10, + }; + const head: WorkspaceHeadRecordV1 = { + ...parent, + workspaceVersionId: successor.workspaceVersionId, + acceptedEventId: successor.acceptedEventId, + commitOid: successor.commitOid, + treeOid: successor.treeOid, + revision: 2, + }; + let promoted = 0; + const result = await reconcileGitoxideManagedMutationProjectionInternal({ + workspaceId: head.workspaceId, + workspaceEpochId: head.workspaceEpochId, + settlementAuthority: { + readHead: async () => head, + readVersion: async (id) => (id === successor.workspaceVersionId ? successor : parentVersion), + commitSuccessor: async () => { + throw new Error('reconciliation must not rewrite SQLite'); + }, + commitTerminal: async () => { + throw new Error('reconciliation must not write a terminal'); + }, + }, + candidateAuthorityForHead: async (base) => { + assert.deepEqual(base, parent); + return { + readBaseFile: async () => null, + capture: async () => { + throw new Error('reconciliation must not rerun the transform'); + }, + promote: async () => { + throw new Error('reconciliation must use the durable receipt'); + }, + promoteDurable: async (operationId) => { + promoted += 1; + assert.equal(operationId, 'op-recover'); + return { + repositoryId: parent.repositoryId, + workspaceId: parent.workspaceId, + workspaceEpochId: parent.workspaceEpochId, + workspaceVersionId: parent.workspaceVersionId, + baseAcceptedEventId: parent.acceptedEventId, + baseHeadRevision: parent.revision, + baseCommitOid: parent.commitOid, + baseTreeOid: parent.treeOid, + candidateCommitOid: successor.commitOid, + candidateTreeOid: successor.treeOid, + resultBlobOid: '6'.repeat(40), + path: 'notes.txt', + contentSha256: `sha256:${'b'.repeat(64)}`, + executionProfileDigest: successor.executionProfileDigest, + }; + }, + }; + }, + }); + + assert.equal(result, 'promoted'); + assert.equal(promoted, 1); +}); + +function baselineHead(): WorkspaceHeadRecordV1 { + return { + repositoryId: 'repository_11111111111111111111111111111111', + workspaceId: 'workspace_22222222222222222222222222222222', + workspaceEpochId: 'epoch_33333333333333333333333333333333', + workspaceVersionId: 'version_55555555555555555555555555555555', + acceptedEventId: 'baseline-event-1', + commitOid: '1'.repeat(40), + treeOid: '2'.repeat(40), + revision: 1, + }; +} + +function baselineVersion(head: WorkspaceHeadRecordV1): WorkspaceVersionRecordV1 { + return { + protocol: 'workspace_baseline_accepted_v1', + repositoryId: head.repositoryId, + workspaceId: head.workspaceId, + workspaceEpochId: head.workspaceEpochId, + workspaceVersionId: head.workspaceVersionId, + objectFormat: 'sha1', + parents: [], + origin: { kind: 'baseline', epochOpenedEventId: 'epoch-event-1' }, + commitOid: head.commitOid, + treeOid: head.treeOid, + policyHash: `sha256:${'7'.repeat(64)}`, + treeDeltaDigest: `sha256:${'8'.repeat(64)}`, + changedFileCount: 1, + deletedFileCount: 0, + acceptedEventId: head.acceptedEventId, + committedAt: 1, + }; +} + +function outcomeEvent(operationId = 'op-1', isError = false): RuntimeEvent { + return { + id: `${operationId}_response`, + sessionId: 'session-1', + invocationId: 'run-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 10, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'call-1', + name: 'Write', + result: { + kind: 'json', + value: { kind: 'file_diff', paths: ['notes.txt'], diff: 'diff' }, + }, + ...(isError ? { isError: true } : {}), + }, + refs: { operationId, toolCallId: 'call-1' }, + actions: { stateDelta: { durationMs: 5 } }, + }; +} + +function sha256(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts new file mode 100644 index 0000000000..147f1dac59 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts @@ -0,0 +1,450 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test, type TestContext } from 'node:test'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; +import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; +import { requireExecutionStoresWorkspaceMutationAuthorityInternal } from '@maka/storage/execution-stores-workspace-authority-internal'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; +import { + admitGitoxideHelperArtifactInternal, + issueGitoxideHelperReleaseArtifactClaimInternal, +} from '../server/gitoxide-helper-artifact-authority-internal.js'; +import { + openGitoxideManagedMutationSession, + recoverGitoxideManagedMutationBeforeRunClosureInternal, +} from '../server/gitoxide-managed-mutation-session.js'; + +test('opens one durable Gitoxide baseline and exactly reuses it for the session', async (t) => { + const helperPath = process.env.MAKA_GITOXIDE_HELPER_PATH; + if (!helperPath) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper session test'); + return; + } + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-session-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const sourceRoot = join(root, 'source'); + await mkdir(sourceRoot); + git(root, ['init', '--quiet', '--object-format=sha1', sourceRoot]); + await writeFile(join(sourceRoot, 'notes.txt'), 'baseline\n'); + git(sourceRoot, ['add', 'notes.txt']); + git(sourceRoot, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'baseline', + ]); + const storageCapability = await resolveStorageRoot({ + path: join(root, 'storage'), + kind: 'interactive', + }); + const storageOwner = await tryAcquireInteractiveRootOwner(storageCapability); + assert.ok(storageOwner); + if (!storageOwner) return; + const stores = await openInteractiveExecutionStoresForWrite(storageOwner.lease); + try { + const helperBytes = await readFile(await realpath(helperPath)); + const helperInfo = await stat(await realpath(helperPath)); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath: await realpath(helperPath), + expectedSha256: `sha256:${createHash('sha256').update(helperBytes).digest('hex')}`, + expectedBytes: helperInfo.size, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + const helperCapability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }); + const input = { + storageRootLease: storageOwner.lease, + sourceRoot, + sessionId: 'session-gitoxide-managed-1', + invocationOwnerToken, + helperCapability, + settlementAuthority: requireExecutionStoresWorkspaceMutationAuthorityInternal(stores), + }; + + const first = await openGitoxideManagedMutationSession(input); + const reopened = await openGitoxideManagedMutationSession(input); + + assert.deepEqual(reopened.head, first.head); + assert.equal(first.head.revision, 1); + assert.notEqual(first.head.commitOid, git(sourceRoot, ['rev-parse', 'HEAD'])); + assert.equal(first.head.treeOid, git(sourceRoot, ['rev-parse', 'HEAD^{tree}'])); + const baselineRepository = await reopened.inspectionRepositoryProvider({ + sourceCwd: sourceRoot, + repositoryPath: join(root, 'unused.git'), + abortSignal: new AbortController().signal, + }); + assert.equal( + (await baselineRepository.readFile('notes.txt', new AbortController().signal)).content, + 'baseline\n', + ); + + await prepareManagedWrite({ + stores, + session: reopened, + operationId: 'operation-gitoxide-recover-t1', + content: 'recovered\n', + }); + const unavailableGate = await recoverGitoxideManagedMutationBeforeRunClosureInternal({ + storageRootLease: storageOwner.lease, + sourceRoot, + sessionId: input.sessionId, + settlementAuthority: requireExecutionStoresWorkspaceMutationAuthorityInternal(stores), + }); + assert.equal(unavailableGate.kind, 'parked'); + const recovered = await openGitoxideManagedMutationSession(input); + assert.equal(recovered.head.revision, 2); + const recoveredRepository = await recovered.inspectionRepositoryProvider({ + sourceCwd: sourceRoot, + repositoryPath: join(root, 'unused.git'), + abortSignal: new AbortController().signal, + }); + assert.equal( + (await recoveredRepository.readFile('notes.txt', new AbortController().signal)).content, + 'recovered\n', + ); + + const changed = await executeManagedWrite({ + stores, + session: recovered, + operationId: 'operation-gitoxide-write-1', + content: 'after\n', + changed: true, + }); + assert.equal(changed.kind, 'workspace_successor_committed'); + const afterChange = await openGitoxideManagedMutationSession(input); + assert.equal(afterChange.head.revision, 3); + assert.notEqual(afterChange.head.commitOid, first.head.commitOid); + const acceptedRepository = await afterChange.inspectionRepositoryProvider({ + sourceCwd: sourceRoot, + repositoryPath: join(root, 'unused.git'), + abortSignal: new AbortController().signal, + }); + assert.equal( + (await acceptedRepository.readFile('notes.txt', new AbortController().signal)).content, + 'after\n', + ); + assert.equal(await readFile(join(sourceRoot, 'notes.txt'), 'utf8'), 'baseline\n'); + + const noChange = await executeManagedWrite({ + stores, + session: afterChange, + operationId: 'operation-gitoxide-write-noop', + content: 'after\n', + changed: false, + }); + assert.equal(noChange.kind, 'no_workspace_change_committed'); + const afterNoChange = await openGitoxideManagedMutationSession(input); + assert.deepEqual(afterNoChange.head, afterChange.head); + } finally { + await stores.sessionStore.close?.(); + await storageOwner.close(); + } +}); + +test('reopens after process death between successor acceptance and Git ref promotion', async (t) => { + const helperPath = process.env.MAKA_GITOXIDE_HELPER_PATH; + if (!helperPath) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper crash test'); + return; + } + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-session-crash-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const sourceRoot = join(root, 'source'); + const storageRoot = join(root, 'storage'); + const readyPath = join(root, 'successor-committed'); + const childInputPath = join(root, 'child-input.json'); + const sessionId = 'session-gitoxide-managed-crash'; + await mkdir(sourceRoot); + git(root, ['init', '--quiet', '--object-format=sha1', sourceRoot]); + await writeFile(join(sourceRoot, 'notes.txt'), 'baseline\n'); + git(sourceRoot, ['add', 'notes.txt']); + git(sourceRoot, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'baseline', + ]); + + const initialStorage = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const initialOwner = await tryAcquireInteractiveRootOwner(initialStorage); + assert.ok(initialOwner); + if (!initialOwner) return; + const initialStores = await openInteractiveExecutionStoresForWrite(initialOwner.lease); + const initialHelper = await admitRealHelper(helperPath); + try { + const initial = await openGitoxideManagedMutationSession({ + storageRootLease: initialOwner.lease, + sourceRoot, + sessionId, + ...initialHelper, + settlementAuthority: requireExecutionStoresWorkspaceMutationAuthorityInternal(initialStores), + }); + assert.equal(initial.head.revision, 1); + } finally { + await initialStores.sessionStore.close?.(); + await initialOwner.close(); + } + + await writeFile( + childInputPath, + JSON.stringify({ helperPath, storageRoot, sourceRoot, sessionId, readyPath }), + 'utf8', + ); + const child = spawn( + process.execPath, + [ + fileURLToPath( + new URL('./fixtures/gitoxide-managed-mutation-session-crash-child.js', import.meta.url), + ), + childInputPath, + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + try { + await waitForPath(readyPath, child, stdout, stderr); + child.kill('SIGKILL'); + await waitForExit(child); + + const reopenedStorage = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const reopenedOwner = await tryAcquireInteractiveRootOwner(reopenedStorage); + assert.ok(reopenedOwner); + if (!reopenedOwner) return; + const reopenedStores = await openInteractiveExecutionStoresForWrite(reopenedOwner.lease); + const reopenedHelper = await admitRealHelper(helperPath); + try { + const reopened = await openGitoxideManagedMutationSession({ + storageRootLease: reopenedOwner.lease, + sourceRoot, + sessionId, + ...reopenedHelper, + settlementAuthority: + requireExecutionStoresWorkspaceMutationAuthorityInternal(reopenedStores), + }); + assert.equal(reopened.head.revision, 2); + const exactRetry = await openGitoxideManagedMutationSession({ + storageRootLease: reopenedOwner.lease, + sourceRoot, + sessionId, + ...reopenedHelper, + settlementAuthority: + requireExecutionStoresWorkspaceMutationAuthorityInternal(reopenedStores), + }); + assert.deepEqual(exactRetry.head, reopened.head); + } finally { + await reopenedStores.sessionStore.close?.(); + await reopenedOwner.close(); + } + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + } +}); + +async function executeManagedWrite(input: { + readonly stores: Awaited>; + readonly session: Awaited>; + readonly operationId: string; + readonly content: string; + readonly changed: boolean; +}) { + const prepared = await prepareManagedWrite(input); + const { admission, toolCallId, args, identity } = prepared; + const providerResult = { kind: 'file_write', path: 'notes.txt', bytes: input.content.length }; + const resultContent = { kind: 'json' as const, value: providerResult }; + const outcome: RuntimeEvent = { + id: `${input.operationId}-outcome-event`, + ...identity, + ts: 11, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: toolCallId, + name: 'Write', + result: resultContent, + }, + refs: { operationId: input.operationId, toolCallId }, + actions: { stateDelta: { durationMs: 1 } }, + }; + return admission.execute(async () => ({ + content: resultContent, + isError: false, + durationMs: 1, + durableOutcome: outcome, + managedMutationResult: { + canonicalPath: String(args.path), + content: input.content, + changed: input.changed, + }, + })); +} + +async function prepareManagedWrite(input: { + readonly stores: Awaited>; + readonly session: Awaited>; + readonly operationId: string; + readonly content: string; +}) { + const toolCallId = `${input.operationId}-call`; + const args = { path: 'notes.txt', content: input.content }; + const admission = await input.session.admitManagedMutation({ + operationId: input.operationId, + toolName: 'Write', + persistedArgs: args, + abortSignal: new AbortController().signal, + }); + const identity = { + sessionId: 'session-gitoxide-managed-1', + invocationId: `invocation-${input.operationId}`, + runId: `run-${input.operationId}`, + turnId: `turn-${input.operationId}`, + }; + const callEvent: RuntimeEvent = { + id: `${input.operationId}-call-event`, + ...identity, + ts: 10, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: toolCallId, name: 'Write', args }, + refs: { operationId: input.operationId, toolCallId }, + }; + const dispatchEvent: RuntimeEvent = { + id: `${input.operationId}_dispatch`, + ...identity, + ts: 10, + partial: false, + role: 'system', + author: 'system', + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: input.operationId, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash: canonicalToolArgsHash('Write', args), + recoveryMode: 'reconcile', + managedMutation: admission.durableDispatch, + }, + }, + refs: { operationId: input.operationId, toolCallId }, + }; + await input.stores.runtimeEventStore.commitToolPrepared({ + operationId: input.operationId, + journalEventId: `${input.operationId}_prepared`, + runtimeEvent: callEvent, + dispatchRuntimeEvent: dispatchEvent, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash: canonicalToolArgsHash('Write', args), + recoveryMode: 'reconcile', + committedAt: 10, + }); + return { admission, toolCallId, args, identity }; +} + +function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); +} + +async function admitRealHelper(helperPath: string) { + const executablePath = await realpath(helperPath); + const [helperBytes, helperInfo] = await Promise.all([ + readFile(executablePath), + stat(executablePath), + ]); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath, + expectedSha256: `sha256:${createHash('sha256').update(helperBytes).digest('hex')}`, + expectedBytes: helperInfo.size, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + const helperCapability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }); + return { invocationOwnerToken, helperCapability }; +} + +async function waitForPath( + path: string, + child: ChildProcess, + stdout: readonly Buffer[], + stderr: readonly Buffer[], +): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + try { + await stat(path); + return; + } catch { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error( + `Crash fixture exited before successor commit: ${Buffer.concat(stdout).toString('utf8')} ${Buffer.concat(stderr).toString('utf8')}`, + ); + } + await delay(50); + } + } + throw new Error('Timed out waiting for the durable successor crash boundary'); +} + +async function waitForExit(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await Promise.race([ + new Promise((resolve) => child.once('exit', () => resolve())), + delay(10_000).then(() => { + throw new Error('Timed out waiting for crash fixture exit'); + }), + ]); +} diff --git a/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts new file mode 100644 index 0000000000..e30ae59663 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts @@ -0,0 +1,570 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test, { type TestContext } from 'node:test'; +import { + admitGitoxideHelperArtifactInternal, + type GitoxideHelperInvocationCapability, + issueGitoxideHelperReleaseArtifactClaimInternal, +} from '../server/gitoxide-helper-artifact-authority-internal.js'; +import { + admitGitoxideRepositoryInternal, + createGitoxideSuccessorInternal, + GitoxideRepositoryAdmissionAuthorityError, + importAdmittedGitoxideRepositoryInternal, + prepareGitoxideMutationCandidateInternal, + reopenGitoxideManagedRepositoryInternal, + materializeGitoxideProjectionInternal, + observeGitoxideProjectionInternal, + readGitoxideTreeFileInternal, + requireGitoxideRepositoryAdmissionInternal, + requireGitoxideMutationCandidateInternal, +} from '../server/gitoxide-repository-admission-authority-internal.js'; + +interface AdmittedHelper { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; +} + +let admittedHelperPromise: Promise | undefined; + +test('issues an opaque owner-bound admission capability from the exact helper observation', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(repositoryPath, 'hello.txt'), 'hello from admission authority\n'); + git(repositoryPath, ['add', 'hello.txt']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const expectedCommit = git(repositoryPath, ['rev-parse', 'HEAD']); + const expectedTree = git(repositoryPath, ['rev-parse', 'HEAD^{tree}']); + const admissionOwnerToken = {}; + + const result = await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryPath, + }); + + assert.equal(result.kind, 'accepted'); + if (result.kind !== 'accepted') return; + assert.deepEqual(result.capability, { kind: 'gitoxide_repository_admission_capability_v1' }); + assert.throws( + () => requireGitoxideRepositoryAdmissionInternal({}, result.capability), + (error) => + error instanceof GitoxideRepositoryAdmissionAuthorityError && + error.code === 'gitoxide_repository_admission_capability_invalid', + ); + assert.deepEqual( + requireGitoxideRepositoryAdmissionInternal(admissionOwnerToken, result.capability), + { + protocolVersion: 1, + repositoryPath, + objectFormat: 'sha1', + headCommitOid: expectedCommit, + headTreeOid: expectedTree, + }, + ); +}); + +test('returns a policy rejection without issuing an admission capability', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha256'); + + assert.deepEqual( + await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken: {}, + repositoryPath, + }), + { + kind: 'repository_rejected', + protocolVersion: 1, + reason: 'unsupported_object_format', + objectFormat: 'sha256', + supportedObjectFormats: ['sha1'], + }, + ); +}); + +test('imports only the exact repository identity bound to the admission capability', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(repositoryPath, 'hello.txt'), 'hello from source import authority\n'); + git(repositoryPath, ['add', 'hello.txt']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const expectedCommit = git(repositoryPath, ['rev-parse', 'HEAD']); + const expectedTree = git(repositoryPath, ['rev-parse', 'HEAD^{tree}']); + const admissionOwnerToken = {}; + const managedRepositoryOwnerToken = {}; + const admitted = await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryPath, + }); + assert.equal(admitted.kind, 'accepted'); + if (admitted.kind !== 'accepted') return; + const destinationRepositoryPath = join(repositoryPath, 'managed.git'); + + const imported = await importAdmittedGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, + destinationRepositoryPath, + baselineRef: 'refs/maka/baseline', + }); + + assert.equal(imported.sourceHeadCommitOid, expectedCommit); + assert.equal(imported.sourceTreeOid, expectedTree); + assert.equal(imported.baselineTreeOid, expectedTree); + assert.equal( + gitBare(destinationRepositoryPath, ['rev-parse', 'refs/maka/baseline']), + imported.baselineCommitOid, + ); + await assert.rejects( + importAdmittedGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken: {}, + repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, + destinationRepositoryPath: join(repositoryPath, 'forged.git'), + baselineRef: 'refs/maka/forged', + }), + (error) => + error instanceof GitoxideRepositoryAdmissionAuthorityError && + error.code === 'gitoxide_repository_admission_capability_invalid', + ); +}); + +test('binds successor publication to the imported repository capability and exact base', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(repositoryPath, 'hello.txt'), 'hello from candidate authority\n'); + git(repositoryPath, ['add', 'hello.txt']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const admissionOwnerToken = {}; + const managedRepositoryOwnerToken = {}; + const admitted = await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryPath, + }); + assert.equal(admitted.kind, 'accepted'); + if (admitted.kind !== 'accepted') return; + const destinationRepositoryPath = join(repositoryPath, 'managed.git'); + const imported = await importAdmittedGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, + destinationRepositoryPath, + baselineRef: 'refs/maka/accepted', + }); + + const successor = await createGitoxideSuccessorInternal({ + ...helper, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'docs/result.txt', + content: 'candidate result\n', + }); + + assert.equal(successor.baseCommitOid, imported.baselineCommitOid); + assert.equal(successor.targetRef, 'refs/maka/accepted'); + assert.equal( + gitBare(destinationRepositoryPath, ['rev-parse', 'refs/maka/accepted']), + successor.successorCommitOid, + ); + const exactRetry = await createGitoxideSuccessorInternal({ + ...helper, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'docs/result.txt', + content: 'candidate result\n', + }); + assert.equal(exactRetry.successorCommitOid, successor.successorCommitOid); + assert.equal(exactRetry.successorTreeOid, successor.successorTreeOid); + + const next = await createGitoxideSuccessorInternal({ + ...helper, + managedRepositoryOwnerToken, + managedRepositoryCapability: successor.managedRepositoryCapability, + path: 'docs/next.txt', + content: 'next candidate\n', + }); + assert.equal(next.baseCommitOid, successor.successorCommitOid); + await assert.rejects( + createGitoxideSuccessorInternal({ + ...helper, + managedRepositoryOwnerToken: {}, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'forged.txt', + content: 'forged\n', + }), + (error) => + error instanceof GitoxideRepositoryAdmissionAuthorityError && + error.code === 'gitoxide_repository_admission_capability_invalid', + ); +}); + +test('prepares an owner-bound candidate without advancing the accepted ref', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(repositoryPath, 'hello.txt'), 'candidate base\n'); + git(repositoryPath, ['add', 'hello.txt']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const admissionOwnerToken = {}; + const managedRepositoryOwnerToken = {}; + const candidateOwnerToken = {}; + const admitted = await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryPath, + }); + assert.equal(admitted.kind, 'accepted'); + if (admitted.kind !== 'accepted') return; + const destinationRepositoryPath = join(repositoryPath, 'candidate.git'); + const imported = await importAdmittedGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, + destinationRepositoryPath, + baselineRef: 'refs/maka/accepted', + }); + + const candidate = await prepareGitoxideMutationCandidateInternal({ + ...helper, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + candidateOwnerToken, + operationId: 'operation-1', + path: 'docs/result.txt', + content: 'candidate result\n', + }); + + assert.equal( + gitBare(destinationRepositoryPath, ['rev-parse', 'refs/maka/accepted']), + imported.baselineCommitOid, + ); + assert.equal( + gitBare(destinationRepositoryPath, ['rev-parse', candidate.candidateRef]), + candidate.successorCommitOid, + ); + const proof = requireGitoxideMutationCandidateInternal( + candidateOwnerToken, + candidate.candidateCapability, + ); + assert.equal(proof.baseCommitOid, imported.baselineCommitOid); + assert.equal(proof.candidateCommitOid, candidate.successorCommitOid); + assert.equal(proof.path, 'docs/result.txt'); + + const exactRetry = await prepareGitoxideMutationCandidateInternal({ + ...helper, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + candidateOwnerToken, + operationId: 'operation-1', + path: 'docs/result.txt', + content: 'candidate result\n', + }); + assert.equal(exactRetry.successorCommitOid, candidate.successorCommitOid); + assert.equal(exactRetry.candidateRef, candidate.candidateRef); + const reopenedOwnerToken = {}; + const reopenedCapability = await reopenGitoxideManagedRepositoryInternal({ + ...helper, + managedRepositoryOwnerToken: reopenedOwnerToken, + repositoryPath: destinationRepositoryPath, + acceptedRef: 'refs/maka/accepted', + expectedAcceptedCommitOid: imported.baselineCommitOid, + expectedAcceptedTreeOid: imported.baselineTreeOid, + }); + const retryAfterReopen = await prepareGitoxideMutationCandidateInternal({ + ...helper, + managedRepositoryOwnerToken: reopenedOwnerToken, + managedRepositoryCapability: reopenedCapability, + candidateOwnerToken, + operationId: 'operation-1', + path: 'docs/result.txt', + content: 'candidate result\n', + }); + assert.equal(retryAfterReopen.successorCommitOid, candidate.successorCommitOid); + await assert.rejects( + reopenGitoxideManagedRepositoryInternal({ + ...helper, + managedRepositoryOwnerToken: {}, + repositoryPath: destinationRepositoryPath, + acceptedRef: 'refs/maka/accepted', + expectedAcceptedCommitOid: imported.baselineCommitOid, + expectedAcceptedTreeOid: 'f'.repeat(40), + }), + (error) => + error instanceof GitoxideRepositoryAdmissionAuthorityError && + error.code === 'gitoxide_managed_repository_base_mismatch', + ); + await assert.rejects( + async () => requireGitoxideMutationCandidateInternal({}, candidate.candidateCapability), + (error) => + error instanceof GitoxideRepositoryAdmissionAuthorityError && + error.code === 'gitoxide_mutation_candidate_capability_invalid', + ); +}); + +test('reads dependency inputs from the immutable imported tree, not the projection filesystem', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(repositoryPath, 'package.json'), '{"name":"fixture","private":true}\n'); + git(repositoryPath, ['add', 'package.json']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const admissionOwnerToken = {}; + const managedRepositoryOwnerToken = {}; + const admitted = await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryPath, + }); + assert.equal(admitted.kind, 'accepted'); + if (admitted.kind !== 'accepted') return; + const imported = await importAdmittedGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, + destinationRepositoryPath: join(repositoryPath, 'managed.git'), + baselineRef: 'refs/maka/accepted', + }); + const result = await readGitoxideTreeFileInternal({ + ...helper, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'package.json', + }); + assert.equal(result.content, '{"name":"fixture","private":true}\n'); + assert.equal(result.acceptedCommitOid, imported.baselineCommitOid); + assert.equal(result.acceptedTreeOid, imported.baselineTreeOid); + await assert.rejects( + readGitoxideTreeFileInternal({ + ...helper, + managedRepositoryOwnerToken: {}, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'package.json', + }), + GitoxideRepositoryAdmissionAuthorityError, + ); +}); + +test('materializes and observes only the commit bound to the projection capability', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(repositoryPath, 'hello.txt'), 'hello from projection authority\n'); + git(repositoryPath, ['add', 'hello.txt']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const admissionOwnerToken = {}; + const managedRepositoryOwnerToken = {}; + const projectionOwnerToken = {}; + const admitted = await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryPath, + }); + assert.equal(admitted.kind, 'accepted'); + if (admitted.kind !== 'accepted') return; + const imported = await importAdmittedGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, + destinationRepositoryPath: join(repositoryPath, 'managed.git'), + baselineRef: 'refs/maka/accepted', + }); + const projectionPath = join(repositoryPath, 'projection'); + const projection = await materializeGitoxideProjectionInternal({ + ...helper, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + projectionOwnerToken, + destinationPath: projectionPath, + }); + + assert.equal( + await readFile(join(projectionPath, 'hello.txt'), 'utf8'), + 'hello from projection authority\n', + ); + assert.equal( + ( + await observeGitoxideProjectionInternal({ + ...helper, + projectionOwnerToken, + projectionCapability: projection.projectionCapability, + }) + ).kind, + 'projection_observed', + ); + await writeFile(join(projectionPath, 'hello.txt'), 'projection was externally changed\n'); + const drifted = await observeGitoxideProjectionInternal({ + ...helper, + projectionOwnerToken, + projectionCapability: projection.projectionCapability, + }); + assert.equal(drifted.kind, 'projection_drifted'); + if (drifted.kind === 'projection_drifted') { + assert.equal(drifted.reason, 'expected_file_size_mismatch'); + assert.equal(drifted.path, 'hello.txt'); + } + await assert.rejects( + observeGitoxideProjectionInternal({ + ...helper, + projectionOwnerToken: {}, + projectionCapability: projection.projectionCapability, + }), + GitoxideRepositoryAdmissionAuthorityError, + ); +}); + +async function admittedHelper(): Promise { + if (admittedHelperPromise) return admittedHelperPromise; + admittedHelperPromise = (async () => { + const configuredHelperPath = process.env.MAKA_GITOXIDE_HELPER_PATH; + if (!configuredHelperPath) return undefined; + const helperPath = await realpath(configuredHelperPath); + const helperBytes = await readFile(helperPath); + const helperInfo = await stat(helperPath); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath: helperPath, + expectedSha256: `sha256:${createHash('sha256').update(helperBytes).digest('hex')}`, + expectedBytes: helperInfo.size, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + const helperCapability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }); + return { invocationOwnerToken, helperCapability }; + })(); + return admittedHelperPromise; +} + +async function createRepository(t: TestContext, objectFormat: 'sha1' | 'sha256') { + const repositoryPath = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-admission-'))); + t.after(() => rm(repositoryPath, { recursive: true, force: true })); + git(repositoryPath, ['init', '--quiet', `--object-format=${objectFormat}`]); + return repositoryPath; +} + +function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim(); +} + +function gitBare(repositoryPath: string, args: readonly string[]): string { + return execFileSync('git', [`--git-dir=${repositoryPath}`, ...args], { + encoding: 'utf8', + }).trim(); +} diff --git a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts index a71c6b93bd..9324adb2aa 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts @@ -91,3 +91,28 @@ test('the headless coding profile freezes prompt, tools, memory, and foreground ); assert.equal((await schema.safeParseAsync({ command: 'true', pty: true })).success, false); }); + +test('the managed coding profile exposes only owner-backed file operations', () => { + const profile = hostedExecutionRunProfile('managed-coding-v1'); + assert.ok(profile); + assert.deepEqual(profile.toolNames, ['ManagedWorkspaceInspect', 'Write', 'Edit']); + assert.equal(profile.memoryExtraction, false); + assert.doesNotMatch(profile.systemPrompt, /Bash/u); + + const tools: MakaTool[] = ['ManagedWorkspaceInspect', 'Write', 'Edit'].map((name) => ({ + name, + description: name, + parameters: z.object({}), + impl: async () => 'ok', + })); + const projected = projectHostedExecutionTools(tools, 'managed-coding-v1'); + for (const tool of projected) { + if (tool.name === 'ManagedWorkspaceInspect') { + assert.equal(tool.recoveryMode, undefined); + assert.equal(tool.durableExecutionProfile, undefined); + } else { + assert.equal(tool.recoveryMode, 'reconcile'); + assert.equal(tool.durableExecutionProfile, 'gitoxide_managed_mutation_v1'); + } + } +}); diff --git a/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts b/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts new file mode 100644 index 0000000000..2388f35306 --- /dev/null +++ b/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts @@ -0,0 +1,690 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import * as runtimeHostServer from '../server/index.js'; +import { resolveBundledNpmRuntime } from '../server/bundled-npm-runtime.js'; +import { + isManagedNpmNodeVersionSupported, + runManagedDependencyProducerProcessInternal, + runManagedNpmDependencyProvision, +} from '../server/managed-dependency-producer-process.js'; + +const productionProfileSkip = isManagedNpmNodeVersionSupported(process.versions.node) + ? false + : `Host Node ${process.versions.node} is outside the attested managed npm profile`; + +test('keeps npm attestation and provisioning package-internal until composition owns both', () => { + assert.equal('resolveBundledNpmRuntime' in runtimeHostServer, false); + assert.equal('runManagedNpmDependencyProvision' in runtimeHostServer, false); +}); + +test('rejects a structurally valid but unissued npm runtime capability', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-npm-forged-runtime-')); + t.after(() => rm(root, { recursive: true, force: true })); + const projectRoot = join(root, 'project'); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + ]); + + await assert.rejects( + runManagedNpmDependencyProvision({ + producerInput: fixtureProducerInput(outputRoot, scratchRoot), + runtime: Object.freeze({ + npmVersion: '12.0.2', + nodeVersion: process.versions.node, + nodeAbi: process.versions.modules ?? 'unknown', + platform: process.platform, + arch: process.arch, + nodeExecutablePath: process.execPath, + npmRuntimeRoot: root, + npmCliPath: join(root, 'npm-cli.js'), + runtimeIdentitySha256: `sha256:${'6'.repeat(64)}`, + }), + } as never), + /attested runtime capability/u, + ); +}); + +test('admits only Node versions compatible with the fixed npm execution profile', () => { + assert.equal(isManagedNpmNodeVersionSupported('22.22.1'), false); + assert.equal(isManagedNpmNodeVersionSupported('22.22.2'), true); + assert.equal(isManagedNpmNodeVersionSupported('23.99.0'), false); + assert.equal(isManagedNpmNodeVersionSupported('24.14.9'), false); + assert.equal(isManagedNpmNodeVersionSupported('24.15.0'), true); + assert.equal(isManagedNpmNodeVersionSupported('25.0.0'), false); + assert.equal(isManagedNpmNodeVersionSupported('26.0.0'), true); + assert.equal(isManagedNpmNodeVersionSupported('27.0.0'), false); + assert.equal(isManagedNpmNodeVersionSupported('999.0.0'), false); + assert.equal(isManagedNpmNodeVersionSupported('invalid'), false); +}); + +test('runs the fixed npm install protocol with a hermetic environment', { + skip: productionProfileSkip, +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-npm-provision-')); + t.after(() => rm(root, { recursive: true, force: true })); + const projectRoot = join(root, 'project'); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const npmCliPath = join(root, 'fixture-npm-cli.cjs'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + ]); + await writeFile( + npmCliPath, + [ + "const fs = require('node:fs');", + "const path = require('node:path');", + 'const args = process.argv.slice(2);', + "fs.mkdirSync(path.join(process.cwd(), 'node_modules', 'fixture'), { recursive: true });", + "fs.writeFileSync(path.join(process.cwd(), 'node_modules', 'fixture', 'index.js'), 'safe\\n');", + "fs.writeFileSync(path.join(process.cwd(), 'invocation.json'), JSON.stringify({ args, env: process.env }));", + ].join(''), + 'utf8', + ); + const previousSecret = process.env.MAKA_DEPENDENCY_SECRET_FOR_TEST; + process.env.MAKA_DEPENDENCY_SECRET_FOR_TEST = 'must-not-cross'; + t.after(() => { + if (previousSecret === undefined) delete process.env.MAKA_DEPENDENCY_SECRET_FOR_TEST; + else process.env.MAKA_DEPENDENCY_SECRET_FOR_TEST = previousSecret; + }); + + await runManagedNpmDependencyProvision({ + producerInput: fixtureProducerInput(outputRoot, scratchRoot), + runtime: await attestFixtureRuntime(root, npmCliPath), + }); + + const invocation = JSON.parse(await readFile(join(projectRoot, 'invocation.json'), 'utf8')) as { + args: string[]; + env: Record; + }; + assert.deepEqual(invocation.args.slice(0, 6), [ + 'ci', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=true', + `--cache=${join(scratchRoot, 'cache')}`, + ]); + assert.equal(invocation.env.npm_config_registry, 'https://registry.npmjs.org/'); + assert.equal(invocation.env.npm_config_ignore_scripts, 'true'); + assert.equal(invocation.env.MAKA_DEPENDENCY_SECRET_FOR_TEST, undefined); + if (process.platform === 'win32') { + const relativeHome = join('.maka-runtime', 'home'); + assert.equal(invocation.env.HOME, relativeHome); + assert.equal(invocation.env.USERPROFILE, relativeHome); + assert.equal(invocation.env.APPDATA, join(relativeHome, 'AppData', 'Roaming')); + assert.equal(invocation.env.LOCALAPPDATA, join(relativeHome, 'AppData', 'Local')); + } + assert.equal(await readFile(join(outputRoot, 'fixture', 'index.js'), 'utf8'), 'safe\n'); +}); + +test('rejects lifecycle-script lock entries before starting npm', { + skip: productionProfileSkip, +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-npm-unsafe-lock-')); + t.after(() => rm(root, { recursive: true, force: true })); + const projectRoot = join(root, 'project'); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const npmCliPath = join(root, 'must-not-run.cjs'); + const marker = join(root, 'spawned'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + ]); + await writeFile( + npmCliPath, + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'spawned')`, + 'utf8', + ); + const producerInput = fixtureProducerInput(outputRoot, scratchRoot); + producerInput.lockfileBytes = Buffer.from( + '{"lockfileVersion":3,"packages":{"":{"name":"fixture"},"node_modules/unsafe":{"resolved":"https://registry.npmjs.org/unsafe/-/unsafe-1.0.0.tgz","integrity":"sha512-YQ==","hasInstallScript":true}}}\n', + ); + + await assert.rejects( + runManagedNpmDependencyProvision({ + producerInput, + runtime: await attestFixtureRuntime(root, npmCliPath), + }), + /unsafe dependency entry/u, + ); + await assert.rejects(readFile(marker, 'utf8'), { code: 'ENOENT' }); +}); + +test('rejects non-canonical lockfile package paths before starting npm', { + skip: productionProfileSkip, +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-npm-unsafe-package-path-')); + t.after(() => rm(root, { recursive: true, force: true })); + const projectRoot = join(root, 'project'); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const npmCliPath = join(root, 'must-not-run.cjs'); + const marker = join(root, 'spawned'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + ]); + await writeFile( + npmCliPath, + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'spawned')`, + 'utf8', + ); + for (const packagePath of [ + 'node_modules/../../outside', + 'node_modules/./outside', + 'node_modules//outside', + 'node_modules\\outside', + '/node_modules/outside', + 'node_modules/C:/outside', + 'node_modules/NUL', + 'node_modules/trailing.', + 'node_modules/trailing ', + ]) { + const producerInput = fixtureProducerInput(outputRoot, scratchRoot); + producerInput.lockfileBytes = Buffer.from( + `${JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { name: 'fixture' }, + [packagePath]: { + resolved: 'https://registry.npmjs.org/outside/-/outside-1.0.0.tgz', + integrity: 'sha512-YQ==', + }, + }, + })}\n`, + ); + + await assert.rejects( + runManagedNpmDependencyProvision({ + producerInput, + runtime: await attestFixtureRuntime(root, npmCliPath), + }), + /unsafe dependency entry/u, + packagePath, + ); + } + await assert.rejects(readFile(marker, 'utf8'), { code: 'ENOENT' }); +}); + +test('rejects a pre-positioned scratch redirect before starting npm', { + skip: productionProfileSkip, +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-npm-scratch-redirect-')); + t.after(() => rm(root, { recursive: true, force: true })); + const projectRoot = join(root, 'project'); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const outsideRoot = join(root, 'outside'); + const npmCliPath = join(root, 'must-not-run.cjs'); + const marker = join(root, 'spawned'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + mkdir(outsideRoot, { recursive: true }), + ]); + await symlink( + outsideRoot, + join(scratchRoot, 'home'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + await writeFile( + npmCliPath, + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'spawned')`, + 'utf8', + ); + + await assert.rejects( + runManagedNpmDependencyProvision({ + producerInput: fixtureProducerInput(outputRoot, scratchRoot), + runtime: await attestFixtureRuntime(root, npmCliPath), + }), + /scratch entry was not created/u, + ); + await assert.rejects(readFile(join(outsideRoot, 'npmrc'), 'utf8'), { code: 'ENOENT' }); + await assert.rejects(readFile(marker, 'utf8'), { code: 'ENOENT' }); +}); + +test('denies child-process creation inside the fixed npm execution profile', { + skip: productionProfileSkip, +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-npm-child-denied-')); + t.after(() => rm(root, { recursive: true, force: true })); + const projectRoot = join(root, 'project'); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const npmCliPath = join(root, 'spawning-npm-cli.cjs'); + const descendantMarker = join(root, 'descendant-ran'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + ]); + await writeFile( + npmCliPath, + [ + "const { spawn } = require('node:child_process');", + `spawn(process.execPath, ['-e', ${JSON.stringify(`require('node:fs').writeFileSync(${JSON.stringify(descendantMarker)}, 'ran')`)}]);`, + ].join(''), + 'utf8', + ); + + await assert.rejects( + runManagedNpmDependencyProvision({ + producerInput: fixtureProducerInput(outputRoot, scratchRoot), + runtime: await attestFixtureRuntime(root, npmCliPath), + }), + /child_process|permission|access denied/iu, + ); + await assert.rejects(readFile(descendantMarker, 'utf8'), { code: 'ENOENT' }); +}); + +test('waits for the producer process output tree to close before returning', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-drain-')); + t.after(() => rm(root, { recursive: true, force: true })); + const marker = join(root, 'descendant-finished'); + const descendant = [ + "const { writeFileSync } = require('node:fs');", + `setTimeout(() => { writeFileSync(${JSON.stringify(marker)}, 'done'); process.stdout.write('descendant done\\n'); }, 150);`, + ].join(''); + const producer = [ + "const { spawn } = require('node:child_process');", + `spawn(process.execPath, ['-e', ${JSON.stringify(descendant)}], { stdio: ['ignore', process.stdout, process.stderr] });`, + "process.stdout.write('producer done\\n');", + ].join(''); + + const result = await runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 5_000, + maxObservedBytes: 1024 * 1024, + maxObservedEntries: 100, + }); + + assert.equal(result.exitCode, 0); + assert.match(result.outputTail, /producer done/u); + assert.match(result.outputTail, /descendant done/u); + assert.equal(await readFile(marker, 'utf8'), 'done'); +}); + +test('aborts and reaps the complete producer process tree before rejecting', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-abort-')); + t.after(() => rm(root, { recursive: true, force: true })); + const childPidPath = join(root, 'child.pid'); + const descendant = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; + const producer = [ + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(descendant)}], { stdio: ['ignore', process.stdout, process.stderr] });`, + `writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`, + "process.on('SIGTERM', () => {});", + 'setInterval(() => {}, 1000);', + ].join(''); + const abort = new AbortController(); + const task = runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + abortSignal: abort.signal, + timeoutMs: 5_000, + maxObservedBytes: 1024 * 1024, + maxObservedEntries: 100, + }); + const childPid = Number.parseInt(await waitForFile(childPidPath), 10); + + abort.abort(); + + await assert.rejects(task, /aborted/u); + await waitForProcessExit(childPid); +}); + +test('reaps a surviving descendant after the direct producer accepts SIGTERM', { + skip: process.platform === 'win32' ? 'POSIX detached process-group semantics required' : false, +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-root-exit-')); + t.after(() => rm(root, { recursive: true, force: true })); + const childPidPath = join(root, 'child.pid'); + const descendant = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; + const producer = [ + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(descendant)}], { stdio: ['ignore', process.stdout, process.stderr] });`, + `writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`, + 'setInterval(() => {}, 1000);', + ].join(''); + const abort = new AbortController(); + const task = runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + abortSignal: abort.signal, + timeoutMs: 5_000, + maxObservedBytes: 1024 * 1024, + maxObservedEntries: 100, + }); + const childPid = Number.parseInt(await waitForFile(childPidPath), 10); + + abort.abort(); + + await assert.rejects(task, /aborted/u); + await waitForProcessExit(childPid); +}); + +test('times out and reaps the complete producer process tree before rejecting', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-timeout-')); + t.after(() => rm(root, { recursive: true, force: true })); + const childPidPath = join(root, 'child.pid'); + const descendant = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; + const producer = [ + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(descendant)}], { stdio: ['ignore', process.stdout, process.stderr] });`, + `writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`, + "process.on('SIGTERM', () => {});", + 'setInterval(() => {}, 1000);', + ].join(''); + const task = runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 100, + maxObservedBytes: 1024 * 1024, + maxObservedEntries: 100, + }); + const childPid = Number.parseInt(await waitForFile(childPidPath), 10); + + await assert.rejects(task, /timed out/u); + await waitForProcessExit(childPid); +}); + +test('enforces observed filesystem limits and reaps the producer tree before rejecting', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-quota-')); + t.after(() => rm(root, { recursive: true, force: true })); + const childPidPath = join(root, 'child.pid'); + const oversizedPath = join(root, 'oversized.bin'); + const descendant = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; + const producer = [ + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(descendant)}], { stdio: ['ignore', process.stdout, process.stderr] });`, + `writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`, + `writeFileSync(${JSON.stringify(oversizedPath)}, Buffer.alloc(4096));`, + "process.on('SIGTERM', () => {});", + 'setInterval(() => {}, 1000);', + ].join(''); + const task = runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 500, + maxObservedBytes: 1024, + maxObservedEntries: 100, + }); + const childPid = Number.parseInt(await waitForFile(childPidPath), 10); + + await assert.rejects( + task, + (error: unknown) => + error instanceof Error && + (error as { readonly reason?: unknown }).reason === 'filesystem_limit_exceeded', + ); + await waitForProcessExit(childPid); +}); + +test('accepts and accounts for a contained npm bin symlink on POSIX', { + skip: process.platform === 'win32', +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-bin-link-')); + t.after(() => rm(root, { recursive: true, force: true })); + const producer = [ + "const fs = require('node:fs');", + "const path = require('node:path');", + "const bin = path.join(process.cwd(), 'node_modules', 'package', 'bin');", + "const links = path.join(process.cwd(), 'node_modules', '.bin');", + 'fs.mkdirSync(bin, { recursive: true });', + 'fs.mkdirSync(links, { recursive: true });', + "fs.writeFileSync(path.join(bin, 'cli.js'), 'module.exports = 1;\\n');", + "fs.symlinkSync('../package/bin/cli.js', path.join(links, 'fixture-cli'));", + ].join(''); + + const result = await runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 5_000, + maxObservedBytes: 1024, + maxObservedEntries: 10, + }); + + assert.equal(result.exitCode, 0); +}); + +test('rejects an escaping producer symlink as invalid output instead of a quota failure', { + skip: process.platform === 'win32', +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-escape-link-')); + t.after(() => rm(root, { recursive: true, force: true })); + const producer = [ + "const fs = require('node:fs');", + "const path = require('node:path');", + "fs.symlinkSync('../../outside', path.join(process.cwd(), 'escape'));", + 'setInterval(() => {}, 1000);', + ].join(''); + + await assert.rejects( + runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 1_000, + maxObservedBytes: 1024, + maxObservedEntries: 10, + }), + /escaping symbolic link/u, + ); +}); + +test('counts empty files toward the producer entry quota', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-entry-quota-')); + t.after(() => rm(root, { recursive: true, force: true })); + const producer = [ + "const fs = require('node:fs');", + "const path = require('node:path');", + "const files = path.join(process.cwd(), 'many-empty-files');", + 'fs.mkdirSync(files);', + "for (let index = 0; index < 20; index += 1) fs.writeFileSync(path.join(files, String(index)), '');", + ].join(''); + + await assert.rejects( + runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 5_000, + maxObservedBytes: 1024, + maxObservedEntries: 10, + }), + /observed filesystem limit/u, + ); +}); + +test('rejects a non-zero producer exit with its bounded diagnostic tail', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-failure-')); + t.after(() => rm(root, { recursive: true, force: true })); + + await assert.rejects( + runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', "process.stderr.write('fixture failed\\n'); process.exit(7)"], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 5_000, + maxObservedBytes: 1024, + maxObservedEntries: 10, + }), + /exit code 7: fixture failed/u, + ); +}); + +async function waitForFile(path: string): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + try { + return await readFile(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Timed out waiting for ${path}`); +} + +async function attestFixtureRuntime(root: string, fixtureCliPath: string) { + const resourcesRoot = join(root, 'runtime-resources'); + const npmRoot = join(resourcesRoot, 'npm'); + const npmCliPath = join(npmRoot, 'bin', 'npm-cli.js'); + const packageJson = '{"name":"npm","version":"12.0.2","license":"Artistic-2.0"}\n'; + const license = 'Artistic License fixture\n'; + const cli = await readFile(fixtureCliPath); + await mkdir(join(npmRoot, 'bin'), { recursive: true }); + await Promise.all([ + writeFile(join(npmRoot, 'package.json'), packageJson), + writeFile(join(npmRoot, 'LICENSE'), license), + writeFile(npmCliPath, cli), + ]); + const files = [ + manifestFile('LICENSE', Buffer.from(license)), + manifestFile('bin/npm-cli.js', cli), + manifestFile('package.json', Buffer.from(packageJson)), + ]; + await writeFile( + join(resourcesRoot, 'bundled-npm.json'), + `${JSON.stringify({ + schemaVersion: 1, + protocol: 'maka_bundled_npm_runtime_v1', + provider: 'desktop/npm-cli', + npmVersion: '12.0.2', + platform: process.platform, + arch: process.arch, + runtimeRootRelativePath: 'npm', + cliRelativePath: 'npm/bin/npm-cli.js', + securityPatches: approvedSecurityPatches, + files, + distributionReady: true, + })}\n`, + ); + return await resolveBundledNpmRuntime({ resourcesRoot }); +} + +const approvedSecurityPatches = [ + { + packageName: 'tar', + fromVersion: '7.5.19', + toVersion: '7.5.22', + advisories: ['GHSA-r292-9mhp-454m'], + }, + { + packageName: 'brace-expansion', + fromVersion: '5.0.7', + toVersion: '5.0.9', + advisories: ['GHSA-mh99-v99m-4gvg', 'GHSA-rgw5-rvv9-x895'], + }, + { + packageName: 'ip-address', + fromVersion: '10.2.0', + toVersion: '10.4.0', + advisories: ['GHSA-mwp4-54f8-5fhr', 'GHSA-4xrf-jv44-h6hh', 'GHSA-22jq-vg5j-6vgg'], + }, + { + packageName: 'undici', + fromVersion: '6.27.0', + toVersion: '6.28.0', + advisories: ['GHSA-8xcm-r25x-g524', 'GHSA-m8rv-5g2x-5cg5', 'GHSA-v3r7-h72x-cjcm'], + }, +] as const; + +function manifestFile(path: string, bytes: Buffer) { + return { + path, + bytes: bytes.byteLength, + sha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + }; +} + +async function waitForProcessExit(pid: number): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Producer descendant ${pid} survived cancellation`); +} + +function fixtureProducerInput(outputRoot: string, scratchRoot: string) { + return { + identity: { + protocolVersion: 1 as const, + environmentId: `sha256:${'1'.repeat(64)}` as const, + manifestPath: 'package.json', + manifestSha256: `sha256:${'2'.repeat(64)}` as const, + lockfilePath: 'package-lock.json', + lockfileSha256: `sha256:${'3'.repeat(64)}` as const, + packageManagerName: 'npm' as const, + packageManagerVersion: '12.0.2', + nodeVersion: process.versions.node, + nodeAbi: process.versions.modules ?? 'unknown', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: `sha256:${'4'.repeat(64)}` as const, + producerPolicyIdentitySha256: `sha256:${'5'.repeat(64)}` as const, + policyVersion: 'managed_dependency_environment_v1' as const, + }, + outputRoot, + scratchRoot, + manifestBytes: Buffer.from('{"name":"fixture","packageManager":"npm@12.0.2"}\n'), + lockfileBytes: Buffer.from( + '{"name":"fixture","lockfileVersion":3,"packages":{"":{"name":"fixture"}}}\n', + ), + }; +} diff --git a/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts b/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts new file mode 100644 index 0000000000..1b0dea8f14 --- /dev/null +++ b/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { verifyGitoxideHelperArtifactForInvocationInternal } from '../server/gitoxide-helper-artifact-authority-internal.js'; +import { + PackagedGitoxideHelperError, + resolvePackagedGitoxideHelperInternal, +} from '../server/packaged-gitoxide-helper-internal.js'; + +test('turns an exact packaged helper manifest into an owner-bound invocation capability', async () => { + const fixture = await createFixture(); + try { + const invocationOwnerToken = {}; + const capability = await withPackagedResourcesRoot(fixture.root, () => + resolvePackagedGitoxideHelperInternal({ invocationOwnerToken }), + ); + const verified = await verifyGitoxideHelperArtifactForInvocationInternal( + invocationOwnerToken, + capability, + ); + assert.equal(verified.executablePath, fixture.executablePath); + assert.equal(verified.artifactSha256, fixture.artifactSha256); + assert.equal(verified.protocolVersion, 1); + } finally { + await fixture.cleanup(); + } +}); + +test('fails closed when the manifest and packaged helper no longer agree', async () => { + const fixture = await createFixture(); + try { + await writeFile(fixture.executablePath, 'tampered'); + await assert.rejects( + withPackagedResourcesRoot(fixture.root, () => + resolvePackagedGitoxideHelperInternal({ invocationOwnerToken: {} }), + ), + (error: unknown) => + error instanceof PackagedGitoxideHelperError && + error.code === 'packaged_gitoxide_helper_integrity_mismatch', + ); + } finally { + await fixture.cleanup(); + } +}); + +test('rejects an unknown or self-declared manifest shape', async () => { + const fixture = await createFixture(); + try { + await writeFile( + join(fixture.root, 'gitoxide-helper.json'), + JSON.stringify({ schemaVersion: 999, executableRelativePath: 'gitoxide/helper' }), + ); + await assert.rejects( + withPackagedResourcesRoot(fixture.root, () => + resolvePackagedGitoxideHelperInternal({ invocationOwnerToken: {} }), + ), + (error: unknown) => + error instanceof PackagedGitoxideHelperError && + error.code === 'packaged_gitoxide_helper_manifest_invalid', + ); + } finally { + await fixture.cleanup(); + } +}); + +async function withPackagedResourcesRoot(root: string, run: () => Promise): Promise { + const descriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath'); + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: root, + }); + try { + return await run(); + } finally { + if (descriptor) Object.defineProperty(process, 'resourcesPath', descriptor); + else delete (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + } +} + +async function createFixture(): Promise<{ + root: string; + executablePath: string; + artifactSha256: `sha256:${string}`; + cleanup(): Promise; +}> { + const root = await mkdtemp(join(tmpdir(), 'maka-packaged-gitoxide-')); + const runtimeRoot = join(root, 'gitoxide'); + await mkdir(runtimeRoot, { recursive: true }); + const executableName = + process.platform === 'win32' ? 'maka-gitoxide-helper.exe' : 'maka-gitoxide-helper'; + const executablePath = join(runtimeRoot, executableName); + const bytes = Buffer.from('packaged-helper'); + const artifactSha256 = `sha256:${createHash('sha256').update(bytes).digest('hex')}` as const; + await writeFile(executablePath, bytes, { mode: 0o755 }); + await writeFile( + join(root, 'gitoxide-helper.json'), + `${JSON.stringify({ + schemaVersion: 1, + protocol: 'maka_gitoxide_helper_release_v1', + provider: 'maka/gitoxide-helper', + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + executableRelativePath: `gitoxide/${executableName}`, + bytes: bytes.byteLength, + sha256: artifactSha256, + distributionReady: true, + })}\n`, + ); + return { + root, + executablePath, + artifactSha256, + cleanup: () => rm(root, { recursive: true, force: true }), + }; +} diff --git a/packages/runtime-host/src/server/bundled-npm-runtime.ts b/packages/runtime-host/src/server/bundled-npm-runtime.ts new file mode 100644 index 0000000000..09d340ae3e --- /dev/null +++ b/packages/runtime-host/src/server/bundled-npm-runtime.ts @@ -0,0 +1,457 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; +import { isAbsolute, join, normalize, relative } from 'node:path'; + +import { + isManagedNpmNodeVersionSupported, + type ManagedNpmRuntimeCapability, +} from './managed-npm-runtime-contract.js'; + +const MANIFEST_KEYS = [ + 'arch', + 'cliRelativePath', + 'distributionReady', + 'files', + 'npmVersion', + 'platform', + 'protocol', + 'provider', + 'runtimeRootRelativePath', + 'schemaVersion', + 'securityPatches', +] as const; +const FILE_KEYS = ['bytes', 'path', 'sha256'] as const; +const SECURITY_PATCH_KEYS = ['advisories', 'fromVersion', 'packageName', 'toVersion'] as const; +const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/u; +const MAX_MANIFEST_BYTES = 16 * 1024 * 1024; +const MAX_RUNTIME_FILES = 100_000; +const MAX_RUNTIME_BYTES = 128 * 1024 * 1024; + +export type BundledNpmRuntimeErrorCode = + | 'bundled_npm_unavailable' + | 'bundled_npm_manifest_invalid' + | 'bundled_npm_platform_mismatch' + | 'bundled_npm_integrity_mismatch' + | 'bundled_npm_node_unsupported'; + +export class BundledNpmRuntimeError extends Error { + constructor( + readonly code: BundledNpmRuntimeErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'BundledNpmRuntimeError'; + } +} + +export interface ResolveBundledNpmRuntimeInput { + readonly resourcesRoot: string; +} + +const attestedNpmRuntimeCapabilities = new WeakMap< + ManagedNpmRuntimeCapability, + () => Promise +>(); + +export async function resolveBundledNpmRuntime( + input: ResolveBundledNpmRuntimeInput, +): Promise { + const platform = process.platform; + const arch = process.arch; + try { + if (!isManagedNpmNodeVersionSupported(process.versions.node)) { + throw new BundledNpmRuntimeError( + 'bundled_npm_node_unsupported', + `Host Node ${process.versions.node} is outside the attested npm execution profile`, + ); + } + const nodeExecutablePath = await canonicalRegularFile(process.execPath, 'Host Node executable'); + const nodeExecutableSha256 = await sha256File(nodeExecutablePath); + const resourcesRoot = normalize(await realpath(input.resourcesRoot)); + const manifestPath = normalize(await realpath(join(resourcesRoot, 'bundled-npm.json'))); + assertWithinRoot(resourcesRoot, manifestPath, 'Bundled npm manifest'); + const manifestInfo = await lstat(manifestPath); + if (!manifestInfo.isFile() || manifestInfo.isSymbolicLink()) { + throw invalidManifest('Bundled npm manifest must be a regular non-symlink file'); + } + if (manifestInfo.size > MAX_MANIFEST_BYTES) { + throw invalidManifest('Bundled npm manifest exceeds its size limit'); + } + const manifest = decodeManifest(parseManifestJson(await readFile(manifestPath, 'utf8'))); + if (manifest.platform !== platform || manifest.arch !== arch) { + throw new BundledNpmRuntimeError( + 'bundled_npm_platform_mismatch', + `Bundled npm targets ${manifest.platform}-${manifest.arch}, not ${platform}-${arch}`, + ); + } + const npmRuntimeRoot = normalize( + await realpath(join(resourcesRoot, ...manifest.runtimeRootRelativePath.split('/'))), + ); + assertWithinRoot(resourcesRoot, npmRuntimeRoot, 'Bundled npm runtime'); + const runtimeInfo = await lstat(npmRuntimeRoot); + if (!runtimeInfo.isDirectory() || runtimeInfo.isSymbolicLink()) { + throw invalidManifest('Bundled npm runtime root must be a regular directory'); + } + const npmCliPath = normalize( + await realpath(join(resourcesRoot, ...manifest.cliRelativePath.split('/'))), + ); + assertWithinRoot(npmRuntimeRoot, npmCliPath, 'Bundled npm CLI'); + const cliInfo = await lstat(npmCliPath); + if (!cliInfo.isFile() || cliInfo.isSymbolicLink()) { + throw invalidManifest('Bundled npm CLI must be a regular non-symlink file'); + } + await assertRuntimeTreeMatchesManifest(manifest, npmRuntimeRoot); + const capability: ManagedNpmRuntimeCapability = Object.freeze({ + npmVersion: manifest.npmVersion, + nodeVersion: process.versions.node, + nodeAbi: process.versions.modules ?? 'unknown', + platform, + arch, + resourcesRoot, + nodeExecutablePath, + npmRuntimeRoot, + npmCliPath, + runtimeIdentitySha256: runtimeIdentity(manifest, nodeExecutableSha256), + }); + attestedNpmRuntimeCapabilities.set(capability, async () => { + const currentNodeExecutable = await canonicalRegularFile( + process.execPath, + 'Host Node executable', + ); + if ( + currentNodeExecutable !== nodeExecutablePath || + (await sha256File(currentNodeExecutable)) !== nodeExecutableSha256 + ) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Host Node executable changed after npm runtime attestation', + ); + } + await assertRuntimeTreeMatchesManifest(manifest, npmRuntimeRoot); + }); + return capability; + } catch (error) { + if (error instanceof BundledNpmRuntimeError) throw error; + throw new BundledNpmRuntimeError( + 'bundled_npm_unavailable', + 'Bundled npm runtime is unavailable', + { cause: error }, + ); + } +} + +/** @internal Producer-side consumption check; this module alone owns issuance. */ +export async function requireBundledNpmRuntimeCapabilityInternal( + capability: ManagedNpmRuntimeCapability, +): Promise { + const revalidate = attestedNpmRuntimeCapabilities.get(capability); + if (!revalidate) throw new Error('Managed npm producer requires an attested runtime capability'); + await revalidate(); + return capability; +} + +interface BundledNpmManifestFileV1 { + readonly path: string; + readonly bytes: number; + readonly sha256: `sha256:${string}`; +} + +interface BundledNpmManifestV1 { + readonly schemaVersion: 1; + readonly protocol: 'maka_bundled_npm_runtime_v1'; + readonly provider: 'desktop/npm-cli'; + readonly npmVersion: '12.0.2'; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly runtimeRootRelativePath: 'npm'; + readonly cliRelativePath: 'npm/bin/npm-cli.js'; + readonly files: readonly BundledNpmManifestFileV1[]; + readonly securityPatches: readonly BundledNpmSecurityPatchV1[]; + readonly distributionReady: true; +} + +interface BundledNpmSecurityPatchV1 { + readonly packageName: string; + readonly fromVersion: string; + readonly toVersion: string; + readonly advisories: readonly string[]; +} + +function decodeManifest(input: unknown): BundledNpmManifestV1 { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw invalidManifest('Bundled npm manifest must be an object'); + } + const value = input as Record; + if ( + !hasExactKeys(value, MANIFEST_KEYS) || + value.schemaVersion !== 1 || + value.protocol !== 'maka_bundled_npm_runtime_v1' || + value.provider !== 'desktop/npm-cli' || + value.npmVersion !== '12.0.2' || + (value.platform !== 'win32' && value.platform !== 'darwin' && value.platform !== 'linux') || + typeof value.arch !== 'string' || + !/^[a-z0-9_]+$/u.test(value.arch) || + value.runtimeRootRelativePath !== 'npm' || + value.cliRelativePath !== 'npm/bin/npm-cli.js' || + !Array.isArray(value.files) || + value.files.length === 0 || + value.files.length > MAX_RUNTIME_FILES || + !Array.isArray(value.securityPatches) || + !matchesApprovedSecurityPatches(value.securityPatches) || + value.distributionReady !== true + ) { + throw invalidManifest('Bundled npm manifest is invalid'); + } + let previousPath = ''; + for (const entry of value.files) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw invalidManifest('Bundled npm file entry must be an object'); + } + const file = entry as Record; + if ( + !hasExactKeys(file, FILE_KEYS) || + typeof file.path !== 'string' || + !isSafeRelativePath(file.path) || + (previousPath !== '' && + Buffer.compare(Buffer.from(file.path), Buffer.from(previousPath)) <= 0) || + !Number.isSafeInteger(file.bytes) || + (file.bytes as number) < 0 || + typeof file.sha256 !== 'string' || + !SHA256_PATTERN.test(file.sha256) + ) { + throw invalidManifest('Bundled npm file entry is invalid'); + } + previousPath = file.path; + } + return value as unknown as BundledNpmManifestV1; +} + +function decodeNpmPackageManifest(input: unknown): { readonly version: string } { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm package manifest is invalid', + ); + } + const value = input as Record; + if ( + value.name !== 'npm' || + typeof value.version !== 'string' || + value.license !== 'Artistic-2.0' + ) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm package manifest is not the approved npm distribution', + ); + } + return { version: value.version }; +} + +async function inventoryRegularFiles(root: string): Promise { + const files: BundledNpmManifestFileV1[] = []; + let totalBytes = 0; + const pending = [root]; + while (pending.length > 0) { + const directory = pending.pop(); + if (!directory) break; + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const path = join(directory, entry.name); + const info = await lstat(path); + if (entry.isDirectory() && !info.isSymbolicLink()) { + pending.push(path); + continue; + } + if (!entry.isFile() || info.isSymbolicLink()) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm runtime may contain only regular files and directories', + ); + } + totalBytes += info.size; + if (files.length >= MAX_RUNTIME_FILES || totalBytes > MAX_RUNTIME_BYTES) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm runtime exceeds its bounded inventory policy', + ); + } + files.push({ + path: relative(root, path).replaceAll('\\', '/'), + bytes: info.size, + sha256: await sha256File(path), + }); + } + } + files.sort((left, right) => Buffer.from(left.path).compare(Buffer.from(right.path))); + return files; +} + +async function assertRuntimeTreeMatchesManifest( + manifest: BundledNpmManifestV1, + npmRuntimeRoot: string, +): Promise { + const actualFiles = await inventoryRegularFiles(npmRuntimeRoot); + if (JSON.stringify(actualFiles) !== JSON.stringify(manifest.files)) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm runtime tree does not match its manifest', + ); + } + let packageManifestInput: unknown; + try { + packageManifestInput = JSON.parse(await readFile(join(npmRuntimeRoot, 'package.json'), 'utf8')); + } catch (error) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm package manifest is unreadable', + { cause: error }, + ); + } + const packageManifest = decodeNpmPackageManifest(packageManifestInput); + if (packageManifest.version !== manifest.npmVersion) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm package version does not match its runtime manifest', + ); + } +} + +function runtimeIdentity( + manifest: BundledNpmManifestV1, + nodeExecutableSha256: `sha256:${string}`, +): `sha256:${string}` { + const identity = JSON.stringify({ + protocol: 'maka_bundled_npm_runtime_identity_v1', + manifest: { + schemaVersion: manifest.schemaVersion, + protocol: manifest.protocol, + provider: manifest.provider, + npmVersion: manifest.npmVersion, + platform: manifest.platform, + arch: manifest.arch, + runtimeRootRelativePath: manifest.runtimeRootRelativePath, + cliRelativePath: manifest.cliRelativePath, + files: manifest.files.map((file) => ({ + path: file.path, + bytes: file.bytes, + sha256: file.sha256, + })), + securityPatches: manifest.securityPatches.map((patch) => ({ + packageName: patch.packageName, + fromVersion: patch.fromVersion, + toVersion: patch.toVersion, + advisories: [...patch.advisories], + })), + distributionReady: manifest.distributionReady, + }, + nodeVersion: process.versions.node, + nodeAbi: process.versions.modules ?? 'unknown', + electronVersion: process.versions.electron ?? null, + nodeExecutableSha256, + }); + return `sha256:${createHash('sha256').update(identity).digest('hex')}`; +} + +async function canonicalRegularFile(path: string, label: string): Promise { + const sourceInfo = await lstat(path); + if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink()) { + throw new BundledNpmRuntimeError('bundled_npm_unavailable', `${label} is unavailable`); + } + const canonical = normalize(await realpath(path)); + const info = await lstat(canonical); + if (!info.isFile() || info.isSymbolicLink()) { + throw new BundledNpmRuntimeError('bundled_npm_unavailable', `${label} is unavailable`); + } + return canonical; +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + return Object.keys(value).sort().join('\0') === [...keys].sort().join('\0'); +} + +function matchesApprovedSecurityPatches(input: readonly unknown[]): boolean { + const approved = [ + ['tar', '7.5.19', '7.5.22', ['GHSA-r292-9mhp-454m']], + ['brace-expansion', '5.0.7', '5.0.9', ['GHSA-mh99-v99m-4gvg', 'GHSA-rgw5-rvv9-x895']], + [ + 'ip-address', + '10.2.0', + '10.4.0', + ['GHSA-mwp4-54f8-5fhr', 'GHSA-4xrf-jv44-h6hh', 'GHSA-22jq-vg5j-6vgg'], + ], + [ + 'undici', + '6.27.0', + '6.28.0', + ['GHSA-8xcm-r25x-g524', 'GHSA-m8rv-5g2x-5cg5', 'GHSA-v3r7-h72x-cjcm'], + ], + ] as const; + return ( + input.every((candidate, index) => { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return false; + const value = candidate as Record; + const expected = approved[index]; + return ( + expected !== undefined && + hasExactKeys(value, SECURITY_PATCH_KEYS) && + value.packageName === expected[0] && + value.fromVersion === expected[1] && + value.toVersion === expected[2] && + Array.isArray(value.advisories) && + JSON.stringify(value.advisories) === JSON.stringify(expected[3]) + ); + }) && input.length === approved.length + ); +} + +function isSafeRelativePath(value: string): boolean { + if (!value || isAbsolute(value) || value.includes('\\')) return false; + return value + .split('/') + .every((segment) => segment.length > 0 && segment !== '.' && segment !== '..'); +} + +function assertWithinRoot(root: string, target: string, label: string): void { + const rel = relative(root, target); + if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) return; + throw invalidManifest(`${label} escapes its packaged authority root`); +} + +function invalidManifest(message: string): BundledNpmRuntimeError { + return new BundledNpmRuntimeError('bundled_npm_manifest_invalid', message); +} + +function parseManifestJson(input: string): unknown { + try { + return JSON.parse(input); + } catch (error) { + throw invalidManifest(`Bundled npm manifest is not valid JSON: ${String(error)}`); + } +} + +async function sha256File(path: string): Promise<`sha256:${string}`> { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return `sha256:${hash.digest('hex')}`; +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 0a5d199a9e..7ed7cc3632 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -73,6 +73,7 @@ import { createReadImageSnapshotter, } from '@maka/storage/artifact-stores'; import { isSessionNotFoundError } from '@maka/storage/execution-stores'; +import { requireExecutionStoresWorkspaceMutationAuthorityInternal } from '@maka/storage/execution-stores-workspace-authority-internal'; import { createExternalSessionAdapterRegistry } from '@maka/storage/external-sessions'; import { createGitWorktreeChildExecutor } from '@maka/storage/git-worktree-child-executor'; import { runWithStorageRootLease } from '@maka/storage/root-authority'; @@ -176,6 +177,17 @@ import { RuntimeHostWorkspaceExecutionError, type RuntimeHostWorkspaceExecutionComposition, } from './workspace-execution-composition.js'; +import { + runtimeHostPackagedResourcesRootInternal, + tryOpenPackagedGitoxideManagedInspectionComposition, + type GitoxideManagedInspectionComposition, +} from './gitoxide-managed-inspection.js'; +import { resolvePackagedGitoxideHelperInternal } from './packaged-gitoxide-helper-internal.js'; +import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artifact-authority-internal.js'; +import { + openGitoxideManagedMutationSession, + recoverGitoxideManagedMutationBeforeRunClosureInternal, +} from './gitoxide-managed-mutation-session.js'; export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition { readonly workspaceExecution: RuntimeHostWorkspaceExecutionComposition; @@ -229,6 +241,13 @@ export async function createExecutionRuntimeHostComposition( let unsubscribeTranscriptChanges: (() => void) | undefined; let unsubscribeUsageChanges: (() => void) | undefined; let workspaceExecution: RuntimeHostWorkspaceExecutionComposition | undefined; + let gitoxideManagedInspection: GitoxideManagedInspectionComposition | undefined; + let gitoxideManagedMutationRuntime: + | { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + } + | undefined; let goalExecutions: HostGoalExecutionCoordinator | undefined; try { const openedProjectCatalog = storage.projectCatalog; @@ -311,6 +330,31 @@ export async function createExecutionRuntimeHostComposition( const managedFilesystemWorker = filesystemWorker ? adaptManagedWorkspaceFilesystemWorker(filesystemWorker) : undefined; + gitoxideManagedInspection = await tryOpenPackagedGitoxideManagedInspectionComposition({ + storageRoot: context.owner.capability.canonicalPath, + ...(managedFilesystemWorker ? { filesystemWorker: managedFilesystemWorker } : {}), + onUnavailable: (error) => + console.warn( + `[runtime-host] Gitoxide managed inspection unavailable: ${generalizedErrorMessage(error)}`, + ), + }); + const packagedResourcesRoot = runtimeHostPackagedResourcesRootInternal(); + if (packagedResourcesRoot) { + const invocationOwnerToken = {}; + try { + const helperCapability = await resolvePackagedGitoxideHelperInternal({ + invocationOwnerToken, + }); + gitoxideManagedMutationRuntime = Object.freeze({ + invocationOwnerToken, + helperCapability, + }); + } catch (error) { + console.warn( + `[runtime-host] Gitoxide managed mutation unavailable: ${generalizedErrorMessage(error)}`, + ); + } + } workspaceExecution = createRuntimeHostWorkspaceExecutionComposition({ ...(managedFilesystemWorker ? { filesystemWorker: managedFilesystemWorker } : {}), }); @@ -361,6 +405,7 @@ export async function createExecutionRuntimeHostComposition( const hostTools = [ createHostWebSearchToolFromService(webSearchService), createHostWebFetchToolFromService(webFetchService), + ...(gitoxideManagedInspection ? [gitoxideManagedInspection.tool] : []), ...runtimePolicy.modelTools, ]; const childAgentTools = createHostChildAgentToolComposition({ @@ -603,8 +648,34 @@ export async function createExecutionRuntimeHostComposition( backends.register( 'ai-sdk', dependencies.primaryBackendFactory ?? - ((backendContext) => - createHostAiSdkBackend({ + (async (backendContext) => { + const managedMutationSession = + backendContext.header.toolProfile === 'managed-coding-v1' + ? await openGitoxideManagedMutationSession({ + storageRootLease: context.owner.lease, + sourceRoot: backendContext.header.cwd, + sessionId: backendContext.sessionId, + invocationOwnerToken: requireGitoxideManagedMutationRuntime( + gitoxideManagedMutationRuntime, + ).invocationOwnerToken, + helperCapability: requireGitoxideManagedMutationRuntime( + gitoxideManagedMutationRuntime, + ).helperCapability, + settlementAuthority: + requireExecutionStoresWorkspaceMutationAuthorityInternal(stores), + abortSignal: backendContext.abortSignal, + }) + : undefined; + const backendHostTools = managedMutationSession + ? hostTools.map((tool) => + tool.name === 'ManagedWorkspaceInspect' + ? requireGitoxideManagedInspection( + gitoxideManagedInspection, + ).toolForRepositoryProvider(managedMutationSession.inspectionRepositoryProvider) + : tool, + ) + : hostTools; + return createHostAiSdkBackend({ context: backendContext, runtimePolicy: runtimePolicyStores, oauthCredentials, @@ -622,7 +693,7 @@ export async function createExecutionRuntimeHostComposition( ), goalTools: requireGoal(goal).tools, builtinTools, - hostTools, + hostTools: backendHostTools, resolveRootTools: (sessionId) => requireGraphCoordinator(graphCoordinator).toolsForSession(sessionId), parentAgentTools: childAgentTools.parentTools, @@ -641,8 +712,12 @@ export async function createExecutionRuntimeHostComposition( backendContext.sessionId, ), runtimeCommitSink: stores.runtimeEventStore, + ...(managedMutationSession + ? { admitManagedMutation: managedMutationSession.admitManagedMutation } + : {}), requestDrain: context.requestDrain, - })), + }); + }), ); const runtimeAuthority: RuntimeHostedRootAuthority = { bindRun: (identity) => messages.bindRun(identity), @@ -885,6 +960,25 @@ export async function createExecutionRuntimeHostComposition( generateSessionTitle: (input) => sessionEffectCoordinator.generateTitle(input), onSessionTitleChanged: (sessionId) => continuityCoordinator.enqueueCanonicalRefresh(sessionId), + recoverManagedMutationBeforeRunClosure: async ({ id: sessionId }) => { + const header = await stores.sessionStore.readHeaderSnapshot(sessionId); + if (header.toolProfile !== 'managed-coding-v1') { + return { kind: 'no_active_mutation' as const }; + } + const runtime = gitoxideManagedMutationRuntime; + return recoverGitoxideManagedMutationBeforeRunClosureInternal({ + storageRootLease: context.owner.lease, + sourceRoot: header.cwd, + sessionId, + settlementAuthority: requireExecutionStoresWorkspaceMutationAuthorityInternal(stores), + ...(runtime + ? { + invocationOwnerToken: runtime.invocationOwnerToken, + helperCapability: runtime.helperCapability, + } + : {}), + }); + }, inspectContinuationSafety: createLocalContinuationSafetyInspector({ readSessionCwd: async (sessionId) => (await stores.sessionStore.readHeaderSnapshot(sessionId)).cwd, @@ -1479,6 +1573,7 @@ export async function createExecutionRuntimeHostComposition( }, drain: [ () => rootCoordinator?.beginDrain(), + () => gitoxideManagedInspection?.beginDrain(), () => workspaceExecution?.beginDrain(), () => runtimeResources?.beginDrain(), () => messages.beginDrain(), @@ -1492,6 +1587,7 @@ export async function createExecutionRuntimeHostComposition( await rootCloseTask; }, () => runtimeResources?.close(), + () => gitoxideManagedInspection?.close(), () => workspaceExecution?.close(), () => sessionEffects?.close(), () => messages.close(), @@ -1591,6 +1687,12 @@ export async function createExecutionRuntimeHostComposition( } catch (error) { const errors: unknown[] = [error]; goalExecutions?.beginDrain(); + gitoxideManagedInspection?.beginDrain(); + try { + await gitoxideManagedInspection?.close(); + } catch (closeError) { + errors.push(closeError); + } try { await workspaceExecution?.close(); } catch (closeError) { @@ -1645,6 +1747,30 @@ function requireWorkspaceExecution( return composition; } +function requireGitoxideManagedInspection( + composition: GitoxideManagedInspectionComposition | undefined, +): GitoxideManagedInspectionComposition { + if (!composition) throw new Error('Gitoxide managed inspection profile is unavailable'); + return composition; +} + +function requireGitoxideManagedMutationRuntime( + runtime: + | { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + } + | undefined, +): { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; +} { + if (!runtime) { + throw new Error('Gitoxide managed mutation profile is unavailable'); + } + return runtime; +} + function adaptManagedWorkspaceFilesystemWorker( worker: Pick, ): ManagedWorkspaceFilesystemWorker { diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index a18e6f2930..2ed95028cc 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -24,7 +24,7 @@ import { relayModelProfile } from '@maka/core/model-thinking'; import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; import type { ModelCallCommit } from '@maka/core/agent-run'; import type { PermissionMode } from '@maka/core/permission'; -import { AiSdkBackend } from '@maka/runtime/ai-sdk-backend'; +import { AiSdkBackend, type AiSdkBackendInput } from '@maka/runtime/ai-sdk-backend'; import { buildDefaultContextBudgetPolicy, resolveSelectedModelContextWindow, @@ -72,6 +72,7 @@ export interface HostAiSdkBackendInput { readonly usage: HostExecutionUsageAuthority; readonly requestDrain: () => void; readonly runtimeCommitSink?: RuntimeCommitSink; + readonly admitManagedMutation?: AiSdkBackendInput['admitManagedMutation']; readonly childAgents?: HostChildAgentBackendCapabilities; readonly createFetchTransport?: (proxy: ProxiedFetchProxy | null) => ProxiedFetchTransport; } @@ -424,6 +425,7 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom assertModelCallAccountingReady, recordToolInvocation: (event) => recordToolInvocation({ repo: telemetry }, event), ...(input.runtimeCommitSink ? { runtimeCommitSink: input.runtimeCommitSink } : {}), + ...(input.admitManagedMutation ? { admitManagedMutation: input.admitManagedMutation } : {}), ...(providerRequestCapture ? { recordProviderRequestCapture: providerRequestCapture, diff --git a/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts new file mode 100644 index 0000000000..8cd23aa2b7 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts @@ -0,0 +1,271 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { lstat, open, realpath } from 'node:fs/promises'; +import { isAbsolute, join, parse, relative, resolve, sep } from 'node:path'; + +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; +const HASH_BUFFER_BYTES = 64 * 1024; +const MAX_HELPER_ARTIFACT_BYTES = 256 * 1024 * 1024; + +export interface GitoxideHelperReleaseArtifactClaim { + readonly kind: 'gitoxide_helper_release_artifact_claim_v1'; +} + +export interface GitoxideHelperInvocationCapability { + readonly kind: 'gitoxide_helper_invocation_capability_v1'; +} + +export interface GitoxideHelperReleaseArtifactStateInternal { + readonly executablePath: string; + readonly expectedSha256: `sha256:${string}`; + readonly expectedBytes: number; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly protocolVersion: 1; +} + +export interface VerifiedGitoxideHelperArtifactInternal { + readonly executablePath: string; + readonly artifactSha256: `sha256:${string}`; + readonly protocolVersion: 1; +} + +export type GitoxideHelperArtifactAuthorityErrorCode = + | 'gitoxide_helper_release_claim_invalid' + | 'gitoxide_helper_release_claim_unsupported' + | 'gitoxide_helper_artifact_invalid' + | 'gitoxide_helper_artifact_identity_mismatch' + | 'gitoxide_helper_invocation_capability_invalid'; + +export class GitoxideHelperArtifactAuthorityError extends Error { + constructor( + readonly code: GitoxideHelperArtifactAuthorityErrorCode, + message: string, + ) { + super(message); + this.name = 'GitoxideHelperArtifactAuthorityError'; + } +} + +interface ReleaseClaimRecord extends GitoxideHelperReleaseArtifactStateInternal { + readonly releaseOwnerToken: object; +} + +interface InvocationCapabilityRecord { + readonly invocationOwnerToken: object; + readonly claim: ReleaseClaimRecord; + readonly canonicalExecutablePath: string; +} + +const releaseClaims = new WeakMap(); +const invocationCapabilities = new WeakMap(); + +/** + * Internal seam for the future packaged-release owner. This function is not + * exported from @maka/runtime-host/server and does not establish the platform + * signing trust root by itself. + */ +export function issueGitoxideHelperReleaseArtifactClaimInternal( + releaseOwnerToken: object, + state: GitoxideHelperReleaseArtifactStateInternal, +): GitoxideHelperReleaseArtifactClaim { + assertReleaseArtifactState(state); + const claim = Object.freeze({ + kind: 'gitoxide_helper_release_artifact_claim_v1' as const, + }); + releaseClaims.set(claim, Object.freeze({ ...state, releaseOwnerToken })); + return claim; +} + +export async function admitGitoxideHelperArtifactInternal(input: { + readonly releaseOwnerToken: object; + readonly invocationOwnerToken: object; + readonly claim: GitoxideHelperReleaseArtifactClaim; +}): Promise { + const claim = releaseClaims.get(input.claim); + if (!claim || claim.releaseOwnerToken !== input.releaseOwnerToken) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_release_claim_invalid', + 'Gitoxide helper release artifact claim is invalid for this release owner', + ); + } + if (claim.platform !== process.platform || claim.arch !== process.arch) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_release_claim_unsupported', + `Gitoxide helper release artifact does not support ${process.platform}/${process.arch}`, + ); + } + + const canonicalExecutablePath = await verifyArtifact(claim); + const capability = Object.freeze({ + kind: 'gitoxide_helper_invocation_capability_v1' as const, + }); + invocationCapabilities.set(capability, { + invocationOwnerToken: input.invocationOwnerToken, + claim, + canonicalExecutablePath, + }); + return capability; +} + +export async function verifyGitoxideHelperArtifactForInvocationInternal( + invocationOwnerToken: object, + capability: GitoxideHelperInvocationCapability, +): Promise { + const record = invocationCapabilities.get(capability); + if (!record || record.invocationOwnerToken !== invocationOwnerToken) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_invocation_capability_invalid', + 'Gitoxide helper invocation capability is invalid for this owner', + ); + } + + const canonicalExecutablePath = await verifyArtifact(record.claim); + if (canonicalExecutablePath !== record.canonicalExecutablePath) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_identity_mismatch', + 'Gitoxide helper canonical executable path changed after admission', + ); + } + return Object.freeze({ + executablePath: canonicalExecutablePath, + artifactSha256: record.claim.expectedSha256, + protocolVersion: record.claim.protocolVersion, + }); +} + +function assertReleaseArtifactState(state: GitoxideHelperReleaseArtifactStateInternal): void { + if ( + typeof state.executablePath !== 'string' || + state.executablePath.length === 0 || + !isAbsolute(state.executablePath) || + !SHA256_PATTERN.test(state.expectedSha256) || + !Number.isSafeInteger(state.expectedBytes) || + state.expectedBytes < 1 || + state.expectedBytes > MAX_HELPER_ARTIFACT_BYTES || + typeof state.platform !== 'string' || + state.platform.length === 0 || + typeof state.arch !== 'string' || + state.arch.length === 0 || + state.protocolVersion !== 1 + ) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_release_claim_invalid', + 'Gitoxide helper release artifact state is invalid', + ); + } +} + +async function verifyArtifact(claim: ReleaseClaimRecord): Promise { + let canonicalExecutablePath: string; + let handle; + try { + await assertNoSymbolicLinkComponents(claim.executablePath); + canonicalExecutablePath = await realpath(claim.executablePath); + handle = await open(canonicalExecutablePath, 'r'); + const initialInfo = await handle.stat({ bigint: true }); + if (!initialInfo.isFile() || initialInfo.size !== BigInt(claim.expectedBytes)) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_identity_mismatch', + 'Gitoxide helper artifact size or file type does not match its release claim', + ); + } + + const digest = createHash('sha256'); + const buffer = Buffer.allocUnsafe(HASH_BUFFER_BYTES); + let position = 0; + while (position < claim.expectedBytes) { + const length = Math.min(buffer.length, claim.expectedBytes - position); + const { bytesRead } = await handle.read(buffer, 0, length, position); + if (bytesRead === 0) break; + digest.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + if (position !== claim.expectedBytes) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_identity_mismatch', + 'Gitoxide helper artifact changed while its identity was verified', + ); + } + const finalHandleInfo = await handle.stat({ bigint: true }); + const finalPathInfo = await lstat(canonicalExecutablePath, { bigint: true }); + if ( + !sameFileSnapshot(initialInfo, finalHandleInfo) || + !sameFileIdentity(finalHandleInfo, finalPathInfo) + ) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_identity_mismatch', + 'Gitoxide helper artifact changed while its identity was verified', + ); + } + const actualSha256 = `sha256:${digest.digest('hex')}`; + if (actualSha256 !== claim.expectedSha256) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_identity_mismatch', + 'Gitoxide helper artifact digest does not match its release claim', + ); + } + } catch (error) { + if (error instanceof GitoxideHelperArtifactAuthorityError) throw error; + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_invalid', + `Gitoxide helper artifact could not be verified: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + await handle?.close().catch(() => undefined); + } + return canonicalExecutablePath; +} + +function sameFileIdentity( + left: Awaited>, + right: Awaited>, +): boolean { + return left.dev === right.dev && left.ino === right.ino && left.size === right.size; +} + +function sameFileSnapshot( + left: Awaited>, + right: Awaited>, +): boolean { + return ( + sameFileIdentity(left, right) && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs + ); +} + +async function assertNoSymbolicLinkComponents(path: string): Promise { + const absolutePath = resolve(path); + const root = parse(absolutePath).root; + const segments = relative(root, absolutePath).split(sep).filter(Boolean); + let cursor = root; + for (const segment of segments) { + cursor = join(cursor, segment); + const info = await lstat(cursor); + if (info.isSymbolicLink()) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_invalid', + 'Gitoxide helper artifact path must not traverse a symbolic link or junction', + ); + } + } +} diff --git a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts new file mode 100644 index 0000000000..22383edda7 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -0,0 +1,1392 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { realpath } from 'node:fs/promises'; +import { dirname, isAbsolute } from 'node:path'; +import { terminateChildProcessTree } from '@maka/runtime/process-tree-terminator'; +import { + type GitoxideHelperInvocationCapability, + verifyGitoxideHelperArtifactForInvocationInternal, +} from './gitoxide-helper-artifact-authority-internal.js'; + +const MAX_SUCCESSOR_CONTENT_BYTES = 64 * 1024 * 1024; +const MAX_TREE_FILE_BYTES = 8 * 1024 * 1024; +const MAX_REQUEST_BYTES = MAX_SUCCESSOR_CONTENT_BYTES + 64 * 1024; +const MAX_STDOUT_BYTES = MAX_TREE_FILE_BYTES * 6 + 64 * 1024; +const MAX_STDERR_BYTES = 16 * 1024; +const INVOCATION_TIMEOUT_MS = 5_000; +const PROJECTION_TIMEOUT_MS = 10 * 60_000; +const SHA1_OID_PATTERN = /^[0-9a-f]{40}$/; +const OBJECT_FORMAT_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; +const MAKA_REF_PATTERN = /^refs\/maka\/[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/; +const HELPER_ERROR_REASONS = new Set([ + 'request_read_failed', + 'request_too_large', + 'invalid_request', + 'unsupported_protocol_version', + 'unsupported_operation', + 'repository_open_failed', + 'head_commit_unavailable', + 'head_tree_unavailable', + 'baseline_commit_write_failed', + 'baseline_publish_failed', + 'baseline_ref_outside_maka_namespace', + 'candidate_publish_failed', + 'candidate_ref_outside_candidate_namespace', + 'candidate_ref_unavailable', + 'base_commit_unavailable', + 'base_path_lookup_failed', + 'base_tree_unavailable', + 'blob_write_failed', + 'commit_write_failed', + 'accepted_commit_unavailable', + 'accepted_tree_unavailable', + 'import_destination_create_failed', + 'import_destination_not_fresh', + 'import_destination_object_format_mismatch', + 'import_destination_unreadable', + 'import_hooks_cleanup_failed', + 'invalid_source_head_commit_oid', + 'invalid_base_commit_oid', + 'invalid_accepted_commit_oid', + 'invalid_successor_path', + 'source_blob_copy_failed', + 'source_blob_identity_mismatch', + 'source_blob_invalid', + 'source_blob_unavailable', + 'source_byte_limit_exceeded', + 'source_file_limit_exceeded', + 'source_head_commit_mismatch', + 'source_head_commit_unavailable', + 'source_head_tree_unavailable', + 'source_path_collision', + 'source_path_byte_limit_exceeded', + 'source_path_length_exceeded', + 'source_tree_copy_failed', + 'source_tree_depth_exceeded', + 'source_tree_entry_limit_exceeded', + 'source_tree_identity_mismatch', + 'source_tree_invalid', + 'source_tree_unavailable', + 'source_tree_visit_limit_exceeded', + 'projection_blob_invalid', + 'invalid_tree_file_path', + 'tree_file_lookup_failed', + 'tree_file_unavailable', + 'tree_file_invalid', + 'tree_file_size_limit_exceeded', + 'tree_file_identity_mismatch', + 'tree_file_not_utf8', + 'projection_blob_unavailable', + 'projection_byte_limit_exceeded', + 'projection_destination_create_failed', + 'projection_destination_not_fresh', + 'projection_directory_create_failed', + 'projection_directory_sync_failed', + 'projection_file_create_failed', + 'projection_file_limit_exceeded', + 'projection_file_sync_failed', + 'projection_file_write_failed', + 'projection_mode_update_failed', + 'projection_path_collision', + 'projection_tree_invalid', + 'projection_tree_unavailable', + 'projection_unreadable', + 'successor_content_limit_exceeded', + 'successor_publish_failed', + 'target_ref_outside_maka_namespace', + 'target_ref_unavailable', + 'tree_edit_failed', + 'tree_write_failed', + 'unsupported_base_path_kind', + 'unsupported_projection_entry_kind', + 'unsupported_projection_path', + 'unsupported_source_entry_kind', + 'unsupported_source_path', +]); + +export interface GitoxideRepositoryObservationV1 { + readonly kind: 'repository_inspected'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly headCommitOid: string; + readonly headTreeOid: string; +} + +export interface GitoxideRepositoryRejectionV1 { + readonly kind: 'repository_rejected'; + readonly protocolVersion: 1; + readonly reason: 'unsupported_object_format'; + readonly objectFormat: string; + readonly supportedObjectFormats: readonly ['sha1']; +} + +export type GitoxideRepositoryInspectionResultV1 = + | GitoxideRepositoryObservationV1 + | GitoxideRepositoryRejectionV1; + +export interface GitoxideSourceImportObservationV1 { + readonly kind: 'source_imported'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly sourceHeadCommitOid: string; + readonly sourceTreeOid: string; + readonly baselineCommitOid: string; + readonly baselineTreeOid: string; + readonly baselineRef: string; + readonly filesImported: number; + readonly bytesImported: number; +} + +export interface GitoxideSuccessorPublishedV1 { + readonly kind: 'successor_published'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly baseCommitOid: string; + readonly successorCommitOid: string; + readonly successorTreeOid: string; + readonly resultBlobOid: string; + readonly targetRef: string; + readonly path: string; +} + +export interface GitoxideSuccessorRejectedV1 { + readonly kind: 'successor_rejected'; + readonly protocolVersion: 1; + readonly reason: 'base_commit_mismatch'; + readonly objectFormat: 'sha1'; + readonly expectedBaseCommitOid: string; + readonly actualBaseCommitOid: string; + readonly targetRef: string; +} + +export type GitoxideSuccessorResultV1 = GitoxideSuccessorPublishedV1 | GitoxideSuccessorRejectedV1; + +export interface GitoxideManagedRefObservationV1 { + readonly kind: 'ref_inspected'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly commitOid: string; + readonly treeOid: string; + readonly targetRef: string; +} + +export interface GitoxideCandidatePromotedV1 { + readonly kind: 'candidate_promoted'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly baseCommitOid: string; + readonly candidateCommitOid: string; + readonly acceptedRef: string; + readonly candidateRef: string; +} + +export type GitoxideCandidatePromotionResultV1 = + | GitoxideCandidatePromotedV1 + | GitoxideSuccessorRejectedV1; + +export interface GitoxideProjectionMaterializedV1 { + readonly kind: 'projection_materialized'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; + readonly destinationPath: string; + readonly filesMaterialized: number; + readonly bytesWritten: number; +} + +export interface GitoxideProjectionObservedV1 { + readonly kind: 'projection_observed'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly state: 'clean'; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; + readonly projectionPath: string; + readonly filesObserved: number; + readonly bytesRead: number; +} + +export interface GitoxideProjectionDriftedV1 { + readonly kind: 'projection_drifted'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly state: 'drifted'; + readonly reason: string; + readonly path: string; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; + readonly projectionPath: string; +} + +export type GitoxideProjectionObservationV1 = + | GitoxideProjectionObservedV1 + | GitoxideProjectionDriftedV1; + +export interface GitoxideTreeFileReadV1 { + readonly kind: 'tree_file_read'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; + readonly blobOid: string; + readonly path: string; + readonly content: string; + readonly bytesRead: number; +} + +export type GitoxideHelperInvocationErrorCode = + | 'gitoxide_helper_invocation_invalid' + | 'gitoxide_helper_invocation_spawn_failed' + | 'gitoxide_helper_invocation_timed_out' + | 'gitoxide_helper_invocation_aborted' + | 'gitoxide_helper_invocation_output_too_large' + | 'gitoxide_helper_invocation_protocol_invalid' + | 'gitoxide_helper_operation_failed'; + +export class GitoxideHelperInvocationError extends Error { + constructor( + readonly code: GitoxideHelperInvocationErrorCode, + message: string, + readonly helperReason?: string, + ) { + super(message); + this.name = 'GitoxideHelperInvocationError'; + } +} + +export async function inspectRepositoryWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly abortSignal?: AbortSignal; +}): Promise { + throwIfAborted(input.abortSignal); + if (!isAbsolute(input.repositoryPath)) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide repository path must be absolute', + ); + } + const [artifact, repositoryPath] = await Promise.all([ + verifyGitoxideHelperArtifactForInvocationInternal(input.invocationOwnerToken, input.capability), + realpath(input.repositoryPath).catch((error) => { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + `Gitoxide repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + }), + ]); + throwIfAborted(input.abortSignal); + + const request = Buffer.from( + JSON.stringify({ + protocolVersion: artifact.protocolVersion, + operation: 'inspect_repository', + repositoryPath, + }), + ); + if (request.length > MAX_REQUEST_BYTES) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide helper request exceeds its byte limit', + ); + } + + const outcome = await invokeHelper({ + executablePath: artifact.executablePath, + request, + abortSignal: input.abortSignal, + }); + return decodeOutcome(outcome); +} + +export async function importSourceHeadWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly sourceRepositoryPath: string; + readonly expectedSourceHeadCommitOid: string; + readonly destinationRepositoryPath: string; + readonly baselineRef: string; + readonly abortSignal?: AbortSignal; +}): Promise { + throwIfAborted(input.abortSignal); + if ( + !isAbsolute(input.sourceRepositoryPath) || + !isAbsolute(input.destinationRepositoryPath) || + !SHA1_OID_PATTERN.test(input.expectedSourceHeadCommitOid) || + !MAKA_REF_PATTERN.test(input.baselineRef) + ) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide source import request is invalid', + ); + } + const [artifact, sourceRepositoryPath] = await Promise.all([ + verifyGitoxideHelperArtifactForInvocationInternal(input.invocationOwnerToken, input.capability), + realpath(input.sourceRepositoryPath).catch((error) => { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + `Gitoxide source repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + }), + ]); + throwIfAborted(input.abortSignal); + const request = Buffer.from( + JSON.stringify({ + protocolVersion: artifact.protocolVersion, + operation: 'import_source_head', + sourceRepositoryPath, + expectedSourceHeadCommitOid: input.expectedSourceHeadCommitOid, + destinationRepositoryPath: input.destinationRepositoryPath, + baselineRef: input.baselineRef, + }), + ); + if (request.length > MAX_REQUEST_BYTES) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide helper request exceeds its byte limit', + ); + } + const outcome = await invokeHelper({ + executablePath: artifact.executablePath, + request, + abortSignal: input.abortSignal, + }); + return decodeSourceImportOutcome(outcome); +} + +export async function createSuccessorWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly expectedBaseCommitOid: string; + readonly targetRef: string; + readonly path: string; + readonly content: string; + readonly abortSignal?: AbortSignal; +}): Promise { + throwIfAborted(input.abortSignal); + if ( + !isAbsolute(input.repositoryPath) || + !SHA1_OID_PATTERN.test(input.expectedBaseCommitOid) || + !MAKA_REF_PATTERN.test(input.targetRef) || + !isCanonicalSuccessorPath(input.path) || + Buffer.byteLength(input.content) > MAX_SUCCESSOR_CONTENT_BYTES + ) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide successor request is invalid', + ); + } + const [artifact, repositoryPath] = await Promise.all([ + verifyGitoxideHelperArtifactForInvocationInternal(input.invocationOwnerToken, input.capability), + realpath(input.repositoryPath).catch((error) => { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + `Gitoxide managed repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + }), + ]); + throwIfAborted(input.abortSignal); + const request = Buffer.from( + JSON.stringify({ + protocolVersion: artifact.protocolVersion, + operation: 'create_successor', + repositoryPath, + expectedBaseCommitOid: input.expectedBaseCommitOid, + targetRef: input.targetRef, + path: input.path, + content: input.content, + }), + ); + if (request.length > MAX_REQUEST_BYTES) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide helper request exceeds its byte limit', + ); + } + const outcome = await invokeHelper({ + executablePath: artifact.executablePath, + request, + abortSignal: input.abortSignal, + }); + return decodeSuccessorOutcome(outcome); +} + +export async function prepareCandidateWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly expectedBaseCommitOid: string; + readonly acceptedRef: string; + readonly candidateRef: string; + readonly path: string; + readonly content: string; + readonly abortSignal?: AbortSignal; +}): Promise { + throwIfAborted(input.abortSignal); + if ( + !isAbsolute(input.repositoryPath) || + !SHA1_OID_PATTERN.test(input.expectedBaseCommitOid) || + !MAKA_REF_PATTERN.test(input.acceptedRef) || + !isCandidateRef(input.candidateRef) || + input.candidateRef === input.acceptedRef || + !isCanonicalSuccessorPath(input.path) || + Buffer.byteLength(input.content) > MAX_SUCCESSOR_CONTENT_BYTES + ) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide candidate request is invalid', + ); + } + const [artifact, repositoryPath] = await Promise.all([ + verifyGitoxideHelperArtifactForInvocationInternal(input.invocationOwnerToken, input.capability), + realpath(input.repositoryPath).catch((error) => { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + `Gitoxide managed repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + }), + ]); + throwIfAborted(input.abortSignal); + const request = Buffer.from( + JSON.stringify({ + protocolVersion: artifact.protocolVersion, + operation: 'prepare_candidate', + repositoryPath, + expectedBaseCommitOid: input.expectedBaseCommitOid, + acceptedRef: input.acceptedRef, + candidateRef: input.candidateRef, + path: input.path, + content: input.content, + }), + ); + if (request.length > MAX_REQUEST_BYTES) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide helper request exceeds its byte limit', + ); + } + const outcome = await invokeHelper({ + executablePath: artifact.executablePath, + request, + abortSignal: input.abortSignal, + }); + return decodeSuccessorOutcome(outcome); +} + +export async function inspectManagedRefWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly targetRef: string; + readonly abortSignal?: AbortSignal; +}): Promise { + throwIfAborted(input.abortSignal); + if (!isAbsolute(input.repositoryPath) || !MAKA_REF_PATTERN.test(input.targetRef)) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide managed ref inspection request is invalid', + ); + } + const [artifact, repositoryPath] = await Promise.all([ + verifyGitoxideHelperArtifactForInvocationInternal(input.invocationOwnerToken, input.capability), + realpath(input.repositoryPath).catch((error) => { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + `Gitoxide managed repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + }), + ]); + throwIfAborted(input.abortSignal); + const request = Buffer.from( + JSON.stringify({ + protocolVersion: artifact.protocolVersion, + operation: 'inspect_ref', + repositoryPath, + targetRef: input.targetRef, + }), + ); + const outcome = await invokeHelper({ + executablePath: artifact.executablePath, + request, + abortSignal: input.abortSignal, + }); + return decodeManagedRefOutcome(outcome); +} + +export async function materializeProjectionWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly acceptedCommitOid: string; + readonly destinationPath: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const prepared = await prepareProjectionInvocation(input); + const request = encodeRequest({ + protocolVersion: prepared.protocolVersion, + operation: 'materialize_projection', + repositoryPath: prepared.repositoryPath, + acceptedCommitOid: input.acceptedCommitOid, + destinationPath: input.destinationPath, + }); + const outcome = await invokeHelper({ + executablePath: prepared.executablePath, + request, + abortSignal: input.abortSignal, + timeoutMs: PROJECTION_TIMEOUT_MS, + }); + return decodeProjectionMaterializationOutcome(outcome); +} + +export async function observeProjectionWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly acceptedCommitOid: string; + readonly projectionPath: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const prepared = await prepareProjectionInvocation(input); + if (!isAbsolute(input.projectionPath)) { + throw invocationInvalid('Gitoxide projection path is invalid'); + } + const request = encodeRequest({ + protocolVersion: prepared.protocolVersion, + operation: 'observe_projection', + repositoryPath: prepared.repositoryPath, + acceptedCommitOid: input.acceptedCommitOid, + projectionPath: input.projectionPath, + }); + const outcome = await invokeHelper({ + executablePath: prepared.executablePath, + request, + abortSignal: input.abortSignal, + timeoutMs: PROJECTION_TIMEOUT_MS, + }); + return decodeProjectionObservationOutcome(outcome); +} + +export async function readTreeFileWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly acceptedCommitOid: string; + readonly path: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const prepared = await prepareProjectionInvocation(input); + if (!isCanonicalSuccessorPath(input.path)) { + throw invocationInvalid('Gitoxide tree file path is invalid'); + } + const outcome = await invokeHelper({ + executablePath: prepared.executablePath, + request: encodeRequest({ + protocolVersion: prepared.protocolVersion, + operation: 'read_tree_file', + repositoryPath: prepared.repositoryPath, + acceptedCommitOid: input.acceptedCommitOid, + path: input.path, + }), + abortSignal: input.abortSignal, + }); + const value = decodeTreeFileOutcome(outcome); + if (value.acceptedCommitOid !== input.acceptedCommitOid || value.path !== input.path) { + throw protocolInvalid('Gitoxide tree file response is invalid'); + } + return value; +} + +async function prepareProjectionInvocation(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly acceptedCommitOid: string; + readonly destinationPath?: string; + readonly abortSignal?: AbortSignal; +}): Promise<{ + readonly executablePath: string; + readonly protocolVersion: 1; + readonly repositoryPath: string; +}> { + throwIfAborted(input.abortSignal); + if ( + !isAbsolute(input.repositoryPath) || + !SHA1_OID_PATTERN.test(input.acceptedCommitOid) || + (input.destinationPath !== undefined && !isAbsolute(input.destinationPath)) + ) { + throw invocationInvalid('Gitoxide projection request is invalid'); + } + const [artifact, repositoryPath] = await Promise.all([ + verifyGitoxideHelperArtifactForInvocationInternal(input.invocationOwnerToken, input.capability), + realpath(input.repositoryPath).catch((error) => { + throw invocationInvalid( + `Gitoxide managed repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + }), + ]); + throwIfAborted(input.abortSignal); + return { + executablePath: artifact.executablePath, + protocolVersion: artifact.protocolVersion, + repositoryPath, + }; +} + +function encodeRequest(value: object): Buffer { + const request = Buffer.from(JSON.stringify(value)); + if (request.length > MAX_REQUEST_BYTES) { + throw invocationInvalid('Gitoxide helper request exceeds its byte limit'); + } + return request; +} + +export async function promoteCandidateWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly expectedBaseCommitOid: string; + readonly acceptedRef: string; + readonly candidateRef: string; + readonly expectedCandidateCommitOid: string; + readonly abortSignal?: AbortSignal; +}): Promise { + throwIfAborted(input.abortSignal); + if ( + !isAbsolute(input.repositoryPath) || + !SHA1_OID_PATTERN.test(input.expectedBaseCommitOid) || + !SHA1_OID_PATTERN.test(input.expectedCandidateCommitOid) || + !MAKA_REF_PATTERN.test(input.acceptedRef) || + !isCandidateRef(input.candidateRef) || + input.acceptedRef === input.candidateRef + ) { + throw invocationInvalid('Gitoxide candidate promotion request is invalid'); + } + const [artifact, repositoryPath] = await Promise.all([ + verifyGitoxideHelperArtifactForInvocationInternal(input.invocationOwnerToken, input.capability), + realpath(input.repositoryPath).catch((error) => { + throw invocationInvalid( + `Gitoxide managed repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + }), + ]); + throwIfAborted(input.abortSignal); + const outcome = await invokeHelper({ + executablePath: artifact.executablePath, + request: encodeRequest({ + protocolVersion: artifact.protocolVersion, + operation: 'promote_candidate', + repositoryPath, + expectedBaseCommitOid: input.expectedBaseCommitOid, + acceptedRef: input.acceptedRef, + candidateRef: input.candidateRef, + expectedCandidateCommitOid: input.expectedCandidateCommitOid, + }), + abortSignal: input.abortSignal, + }); + return decodeCandidatePromotionOutcome(outcome); +} + +interface HelperProcessOutcome { + readonly exitCode: number | null; + readonly signal: NodeJS.Signals | null; + readonly stdout: Buffer; + readonly stderr: Buffer; +} + +function invokeHelper(input: { + readonly executablePath: string; + readonly request: Buffer; + readonly abortSignal?: AbortSignal; + readonly timeoutMs?: number; +}): Promise { + return new Promise((resolve, reject) => { + let child: ReturnType; + try { + child = spawn(input.executablePath, [], { + cwd: dirname(input.executablePath), + env: helperEnvironment(), + shell: false, + windowsHide: true, + detached: process.platform !== 'win32', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + reject( + new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_spawn_failed', + `Gitoxide helper could not be started: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + return; + } + + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + let termination: + | 'gitoxide_helper_invocation_timed_out' + | 'gitoxide_helper_invocation_aborted' + | 'gitoxide_helper_invocation_output_too_large' + | undefined; + let processFailure: GitoxideHelperInvocationError | undefined; + const timeout = setTimeout( + () => terminate('gitoxide_helper_invocation_timed_out'), + input.timeoutMs ?? INVOCATION_TIMEOUT_MS, + ); + const abort = () => terminate('gitoxide_helper_invocation_aborted'); + input.abortSignal?.addEventListener('abort', abort, { once: true }); + if (input.abortSignal?.aborted) abort(); + + child.stdout!.on('data', (chunk: Buffer) => { + if (settled) return; + stdoutBytes += chunk.length; + if (stdoutBytes > MAX_STDOUT_BYTES) { + terminate('gitoxide_helper_invocation_output_too_large'); + return; + } + stdout.push(chunk); + }); + child.stderr!.on('data', (chunk: Buffer) => { + if (settled) return; + stderrBytes += chunk.length; + if (stderrBytes > MAX_STDERR_BYTES) { + terminate('gitoxide_helper_invocation_output_too_large'); + return; + } + stderr.push(chunk); + }); + child.once('error', (error) => { + finishReject( + new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_spawn_failed', + `Gitoxide helper process failed: ${error.message}`, + ), + ); + }); + child.once('close', (exitCode, signal) => { + if (processFailure) { + finishReject(processFailure); + return; + } + if (termination) { + finishReject( + new GitoxideHelperInvocationError(termination, terminationMessage(termination)), + ); + return; + } + finishResolve({ + exitCode, + signal, + stdout: Buffer.concat(stdout, stdoutBytes), + stderr: Buffer.concat(stderr, stderrBytes), + }); + }); + child.stdin!.on('error', (error) => { + if (settled || processFailure) return; + processFailure = new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_spawn_failed', + `Gitoxide helper request could not be written: ${error.message}`, + ); + void terminateChildProcessTree(child, 'SIGKILL'); + }); + child.stdin!.end(input.request); + + function terminate(reason: NonNullable): void { + if (settled || termination) return; + termination = reason; + void terminateChildProcessTree(child, 'SIGKILL'); + } + + function finishResolve(outcome: HelperProcessOutcome): void { + if (settled) return; + settled = true; + cleanup(); + resolve(outcome); + } + + function finishReject(error: GitoxideHelperInvocationError): void { + if (settled) return; + settled = true; + cleanup(); + reject(error); + } + + function cleanup(): void { + clearTimeout(timeout); + input.abortSignal?.removeEventListener('abort', abort); + } + }); +} + +function decodeOutcome(outcome: HelperProcessOutcome): GitoxideRepositoryInspectionResultV1 { + if (outcome.signal !== null) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_protocol_invalid', + `Gitoxide helper exited from signal ${outcome.signal}`, + ); + } + let value: unknown; + try { + value = JSON.parse(outcome.stdout.toString('utf8')); + } catch { + throw protocolInvalid('Gitoxide helper stdout is not one JSON response'); + } + + if (outcome.exitCode === 0 && isRepositoryObservation(value)) return Object.freeze(value); + if (outcome.exitCode === 2 && isRepositoryRejection(value)) { + return Object.freeze({ ...value, supportedObjectFormats: Object.freeze(['sha1'] as const) }); + } + if (outcome.exitCode === 1 && isHelperError(value)) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + `Gitoxide helper could not inspect the repository: ${value.reason}`, + value.reason, + ); + } + const stderr = outcome.stderr.toString('utf8').trim(); + throw protocolInvalid( + `Gitoxide helper exit code and response disagree${stderr ? `: ${stderr}` : ''}`, + ); +} + +function decodeSourceImportOutcome( + outcome: HelperProcessOutcome, +): GitoxideSourceImportObservationV1 { + if (outcome.signal !== null) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_protocol_invalid', + `Gitoxide helper exited from signal ${outcome.signal}`, + ); + } + let value: unknown; + try { + value = JSON.parse(outcome.stdout.toString('utf8')); + } catch { + throw protocolInvalid('Gitoxide helper stdout is not one JSON response'); + } + if (outcome.exitCode === 0 && isSourceImportObservation(value)) return Object.freeze(value); + if (outcome.exitCode === 1 && isHelperError(value)) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + `Gitoxide helper could not import the source repository: ${value.reason}`, + value.reason, + ); + } + const stderr = outcome.stderr.toString('utf8').trim(); + throw protocolInvalid( + `Gitoxide helper exit code and response disagree${stderr ? `: ${stderr}` : ''}`, + ); +} + +function decodeSuccessorOutcome(outcome: HelperProcessOutcome): GitoxideSuccessorResultV1 { + if (outcome.signal !== null) { + throw protocolInvalid(`Gitoxide helper exited from signal ${outcome.signal}`); + } + let value: unknown; + try { + value = JSON.parse(outcome.stdout.toString('utf8')); + } catch { + throw protocolInvalid('Gitoxide helper stdout is not one JSON response'); + } + if (outcome.exitCode === 0 && isSuccessorPublished(value)) return Object.freeze(value); + if (outcome.exitCode === 3 && isSuccessorRejected(value)) return Object.freeze(value); + if (outcome.exitCode === 1 && isHelperError(value)) { + const stderr = outcome.stderr.toString('utf8').trim(); + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + `Gitoxide helper could not publish the successor: ${value.reason}${stderr ? `: ${stderr}` : ''}`, + value.reason, + ); + } + const stderr = outcome.stderr.toString('utf8').trim(); + throw protocolInvalid( + `Gitoxide helper exit code and response disagree${stderr ? `: ${stderr}` : ''}`, + ); +} + +function decodeManagedRefOutcome(outcome: HelperProcessOutcome): GitoxideManagedRefObservationV1 { + if (outcome.signal !== null) { + throw protocolInvalid(`Gitoxide helper exited from signal ${outcome.signal}`); + } + let value: unknown; + try { + value = JSON.parse(outcome.stdout.toString('utf8')); + } catch { + throw protocolInvalid('Gitoxide helper stdout is not one JSON response'); + } + if (outcome.exitCode === 0 && isManagedRefObservation(value)) return Object.freeze(value); + if (outcome.exitCode === 1 && isHelperError(value)) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + `Gitoxide helper could not inspect the managed ref: ${value.reason}`, + value.reason, + ); + } + const stderr = outcome.stderr.toString('utf8').trim(); + throw protocolInvalid( + `Gitoxide helper exit code and response disagree${stderr ? `: ${stderr}` : ''}`, + ); +} + +function decodeCandidatePromotionOutcome( + outcome: HelperProcessOutcome, +): GitoxideCandidatePromotionResultV1 { + const value = parseHelperOutcome(outcome); + if (outcome.exitCode === 0 && isCandidatePromoted(value)) return Object.freeze(value); + if (outcome.exitCode === 3 && isSuccessorRejected(value)) return Object.freeze(value); + if (outcome.exitCode === 1 && isHelperError(value)) { + throw operationFailed('promote the managed candidate', value.reason); + } + throw protocolInvalid('Gitoxide candidate promotion response disagrees with its exit code'); +} + +function decodeProjectionMaterializationOutcome( + outcome: HelperProcessOutcome, +): GitoxideProjectionMaterializedV1 { + const value = parseHelperOutcome(outcome); + if (outcome.exitCode === 0 && isProjectionMaterialized(value)) return Object.freeze(value); + if (outcome.exitCode === 1 && isHelperError(value)) { + throw operationFailed('materialize the projection', value.reason); + } + throw protocolInvalid( + 'Gitoxide projection materialization response disagrees with its exit code', + ); +} + +function decodeProjectionObservationOutcome( + outcome: HelperProcessOutcome, +): GitoxideProjectionObservationV1 { + const value = parseHelperOutcome(outcome); + if (outcome.exitCode === 0 && isProjectionObserved(value)) return Object.freeze(value); + if (outcome.exitCode === 3 && isProjectionDrifted(value)) return Object.freeze(value); + if (outcome.exitCode === 1 && isHelperError(value)) { + throw operationFailed('observe the projection', value.reason); + } + throw protocolInvalid('Gitoxide projection observation response disagrees with its exit code'); +} + +function decodeTreeFileOutcome(outcome: HelperProcessOutcome): GitoxideTreeFileReadV1 { + const value = parseHelperOutcome(outcome); + if (outcome.exitCode === 0 && isTreeFileRead(value)) return Object.freeze(value); + if (outcome.exitCode === 1 && isHelperError(value)) { + throw operationFailed('read the accepted tree file', value.reason); + } + throw protocolInvalid('Gitoxide tree file response disagrees with its exit code'); +} + +function parseHelperOutcome(outcome: HelperProcessOutcome): unknown { + if (outcome.signal !== null) + throw protocolInvalid(`Gitoxide helper exited from signal ${outcome.signal}`); + try { + return JSON.parse(outcome.stdout.toString('utf8')); + } catch { + throw protocolInvalid('Gitoxide helper stdout is not one JSON response'); + } +} + +function operationFailed(operation: string, reason: string): GitoxideHelperInvocationError { + return new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + `Gitoxide helper could not ${operation}: ${reason}`, + reason, + ); +} + +function isProjectionMaterialized(value: unknown): value is GitoxideProjectionMaterializedV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'acceptedCommitOid', + 'acceptedTreeOid', + 'destinationPath', + 'filesMaterialized', + 'bytesWritten', + ]) && + value.protocolVersion === 1 && + value.kind === 'projection_materialized' && + value.objectFormat === 'sha1' && + isSha1(value.acceptedCommitOid) && + isSha1(value.acceptedTreeOid) && + typeof value.destinationPath === 'string' && + isAbsolute(value.destinationPath) && + isNonNegativeSafeInteger(value.filesMaterialized) && + isNonNegativeSafeInteger(value.bytesWritten) + ); +} + +function isCandidatePromoted(value: unknown): value is GitoxideCandidatePromotedV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'baseCommitOid', + 'candidateCommitOid', + 'acceptedRef', + 'candidateRef', + ]) && + value.protocolVersion === 1 && + value.kind === 'candidate_promoted' && + value.objectFormat === 'sha1' && + isSha1(value.baseCommitOid) && + isSha1(value.candidateCommitOid) && + typeof value.acceptedRef === 'string' && + MAKA_REF_PATTERN.test(value.acceptedRef) && + typeof value.candidateRef === 'string' && + isCandidateRef(value.candidateRef) + ); +} + +function isTreeFileRead(value: unknown): value is GitoxideTreeFileReadV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'acceptedCommitOid', + 'acceptedTreeOid', + 'blobOid', + 'path', + 'content', + 'bytesRead', + ]) && + value.protocolVersion === 1 && + value.kind === 'tree_file_read' && + value.objectFormat === 'sha1' && + isSha1(value.acceptedCommitOid) && + isSha1(value.acceptedTreeOid) && + isSha1(value.blobOid) && + typeof value.path === 'string' && + isCanonicalSuccessorPath(value.path) && + typeof value.content === 'string' && + isNonNegativeSafeInteger(value.bytesRead) && + value.bytesRead <= MAX_TREE_FILE_BYTES && + Buffer.byteLength(value.content, 'utf8') === value.bytesRead + ); +} + +function isProjectionObserved(value: unknown): value is GitoxideProjectionObservedV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'state', + 'acceptedCommitOid', + 'acceptedTreeOid', + 'projectionPath', + 'filesObserved', + 'bytesRead', + ]) && + value.protocolVersion === 1 && + value.kind === 'projection_observed' && + value.objectFormat === 'sha1' && + value.state === 'clean' && + isSha1(value.acceptedCommitOid) && + isSha1(value.acceptedTreeOid) && + typeof value.projectionPath === 'string' && + isAbsolute(value.projectionPath) && + isNonNegativeSafeInteger(value.filesObserved) && + isNonNegativeSafeInteger(value.bytesRead) + ); +} + +function isProjectionDrifted(value: unknown): value is GitoxideProjectionDriftedV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'state', + 'reason', + 'path', + 'acceptedCommitOid', + 'acceptedTreeOid', + 'projectionPath', + ]) && + value.protocolVersion === 1 && + value.kind === 'projection_drifted' && + value.objectFormat === 'sha1' && + value.state === 'drifted' && + typeof value.reason === 'string' && + value.reason.length > 0 && + value.reason.length <= 128 && + typeof value.path === 'string' && + value.path.length <= 4096 && + isSha1(value.acceptedCommitOid) && + isSha1(value.acceptedTreeOid) && + typeof value.projectionPath === 'string' && + isAbsolute(value.projectionPath) + ); +} + +function isSha1(value: unknown): value is string { + return typeof value === 'string' && SHA1_OID_PATTERN.test(value); +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function isSuccessorPublished(value: unknown): value is GitoxideSuccessorPublishedV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'baseCommitOid', + 'successorCommitOid', + 'successorTreeOid', + 'resultBlobOid', + 'targetRef', + 'path', + ]) && + value.protocolVersion === 1 && + value.kind === 'successor_published' && + value.objectFormat === 'sha1' && + typeof value.baseCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.baseCommitOid) && + typeof value.successorCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.successorCommitOid) && + typeof value.successorTreeOid === 'string' && + SHA1_OID_PATTERN.test(value.successorTreeOid) && + typeof value.resultBlobOid === 'string' && + SHA1_OID_PATTERN.test(value.resultBlobOid) && + typeof value.targetRef === 'string' && + MAKA_REF_PATTERN.test(value.targetRef) && + typeof value.path === 'string' && + isCanonicalSuccessorPath(value.path) + ); +} + +function isSuccessorRejected(value: unknown): value is GitoxideSuccessorRejectedV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'reason', + 'objectFormat', + 'expectedBaseCommitOid', + 'actualBaseCommitOid', + 'targetRef', + ]) && + value.protocolVersion === 1 && + value.kind === 'successor_rejected' && + value.reason === 'base_commit_mismatch' && + value.objectFormat === 'sha1' && + typeof value.expectedBaseCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.expectedBaseCommitOid) && + typeof value.actualBaseCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.actualBaseCommitOid) && + typeof value.targetRef === 'string' && + MAKA_REF_PATTERN.test(value.targetRef) + ); +} + +function isManagedRefObservation(value: unknown): value is GitoxideManagedRefObservationV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'commitOid', + 'treeOid', + 'targetRef', + ]) && + value.protocolVersion === 1 && + value.kind === 'ref_inspected' && + value.objectFormat === 'sha1' && + typeof value.commitOid === 'string' && + SHA1_OID_PATTERN.test(value.commitOid) && + typeof value.treeOid === 'string' && + SHA1_OID_PATTERN.test(value.treeOid) && + typeof value.targetRef === 'string' && + MAKA_REF_PATTERN.test(value.targetRef) + ); +} + +function isSourceImportObservation(value: unknown): value is GitoxideSourceImportObservationV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'sourceHeadCommitOid', + 'sourceTreeOid', + 'baselineCommitOid', + 'baselineTreeOid', + 'baselineRef', + 'filesImported', + 'bytesImported', + ]) && + value.protocolVersion === 1 && + value.kind === 'source_imported' && + value.objectFormat === 'sha1' && + typeof value.sourceHeadCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.sourceHeadCommitOid) && + typeof value.sourceTreeOid === 'string' && + SHA1_OID_PATTERN.test(value.sourceTreeOid) && + typeof value.baselineCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.baselineCommitOid) && + typeof value.baselineTreeOid === 'string' && + SHA1_OID_PATTERN.test(value.baselineTreeOid) && + value.baselineTreeOid === value.sourceTreeOid && + typeof value.baselineRef === 'string' && + MAKA_REF_PATTERN.test(value.baselineRef) && + Number.isSafeInteger(value.filesImported) && + (value.filesImported as number) >= 0 && + Number.isSafeInteger(value.bytesImported) && + (value.bytesImported as number) >= 0 + ); +} + +function isRepositoryObservation(value: unknown): value is GitoxideRepositoryObservationV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'headCommitOid', + 'headTreeOid', + ]) && + value.protocolVersion === 1 && + value.kind === 'repository_inspected' && + value.objectFormat === 'sha1' && + typeof value.headCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.headCommitOid) && + typeof value.headTreeOid === 'string' && + SHA1_OID_PATTERN.test(value.headTreeOid) + ); +} + +function isRepositoryRejection(value: unknown): value is GitoxideRepositoryRejectionV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'reason', + 'objectFormat', + 'supportedObjectFormats', + ]) && + value.protocolVersion === 1 && + value.kind === 'repository_rejected' && + value.reason === 'unsupported_object_format' && + typeof value.objectFormat === 'string' && + OBJECT_FORMAT_PATTERN.test(value.objectFormat) && + Array.isArray(value.supportedObjectFormats) && + value.supportedObjectFormats.length === 1 && + value.supportedObjectFormats[0] === 'sha1' + ); +} + +function isHelperError(value: unknown): value is { + readonly protocolVersion: 1; + readonly kind: 'helper_error'; + readonly reason: string; +} { + return ( + hasExactKeys(value, ['protocolVersion', 'kind', 'reason']) && + value.protocolVersion === 1 && + value.kind === 'helper_error' && + typeof value.reason === 'string' && + HELPER_ERROR_REASONS.has(value.reason) + ); +} + +function hasExactKeys( + value: unknown, + expectedKeys: readonly string[], +): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const keys = Object.keys(value).sort(); + const expected = [...expectedKeys].sort(); + return keys.length === expected.length && keys.every((key, index) => key === expected[index]); +} + +function invocationInvalid(message: string): GitoxideHelperInvocationError { + return new GitoxideHelperInvocationError('gitoxide_helper_invocation_invalid', message); +} + +function isCanonicalSuccessorPath(path: string): boolean { + return ( + path.length > 0 && + path.length <= 4096 && + !path.startsWith('/') && + !path.includes('\\') && + !path.includes('\0') && + path + .split('/') + .every( + (component) => + component.length > 0 && + component !== '.' && + component !== '..' && + component.toLowerCase() !== '.git', + ) + ); +} + +function isCandidateRef(ref: string): boolean { + return MAKA_REF_PATTERN.test(ref) && ref.startsWith('refs/maka/candidates/'); +} + +function helperEnvironment(): NodeJS.ProcessEnv { + return { + PATH: '', + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + ...(process.env.WINDIR ? { WINDIR: process.env.WINDIR } : {}), + ...(process.env.TMP ? { TMP: process.env.TMP } : {}), + ...(process.env.TEMP ? { TEMP: process.env.TEMP } : {}), + ...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}), + }; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_aborted', + 'Gitoxide helper invocation was aborted', + ); +} + +function terminationMessage( + code: + | 'gitoxide_helper_invocation_timed_out' + | 'gitoxide_helper_invocation_aborted' + | 'gitoxide_helper_invocation_output_too_large', +): string { + if (code === 'gitoxide_helper_invocation_timed_out') + return 'Gitoxide helper invocation timed out'; + if (code === 'gitoxide_helper_invocation_aborted') + return 'Gitoxide helper invocation was aborted'; + return 'Gitoxide helper output exceeded its byte limit'; +} + +function protocolInvalid(message: string): GitoxideHelperInvocationError { + return new GitoxideHelperInvocationError('gitoxide_helper_invocation_protocol_invalid', message); +} diff --git a/packages/runtime-host/src/server/gitoxide-helper-mutation-candidate-authority-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-mutation-candidate-authority-internal.ts new file mode 100644 index 0000000000..6123bf3d5c --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-helper-mutation-candidate-authority-internal.ts @@ -0,0 +1,591 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { chmod, lstat, mkdir, open, readFile, realpath, rename, rm } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import type { WorkspaceHeadRecordV1 } from '@maka/core/workspace-version-authority'; +import { withProcessLifetimeFileUpdateLock } from '@maka/storage/process-lifetime-file-update-lock'; +import { + assertStorageRootLease, + runWithStorageRootLease, + type StorageRootLease, +} from '@maka/storage/root-authority'; +import { syncDirectory } from '@maka/storage/stable-storage'; +import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artifact-authority-internal.js'; +import { + type GitoxideMutationCandidateCapability, + materializeGitoxideProjectionInternal, + observeGitoxideProjectionInternal, + prepareGitoxideMutationCandidateInternal, + readGitoxideTreeFileInternal, + reopenGitoxideManagedRepositoryInternal, + requireGitoxideMutationCandidateInternal, +} from './gitoxide-repository-admission-authority-internal.js'; +import { + GitoxideHelperInvocationError, + promoteCandidateWithGitoxideHelperInternal, +} from './gitoxide-helper-invocation-internal.js'; + +const ACCEPTED_REF = 'refs/maka/accepted'; +const MAX_RECEIPT_BYTES = 32 * 1024; +const SHA1_PATTERN = /^[0-9a-f]{40}$/u; +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const RECEIPT_KEYS = [ + 'schemaVersion', + 'protocol', + 'repositoryId', + 'workspaceId', + 'workspaceEpochId', + 'workspaceVersionId', + 'baseAcceptedEventId', + 'baseHeadRevision', + 'baseCommitOid', + 'baseTreeOid', + 'operationIdentitySha256', + 'acceptedRef', + 'candidateRef', + 'candidateCommitOid', + 'candidateTreeOid', + 'resultBlobOid', + 'path', + 'contentSha256', + 'executionProfileDigest', +] as const; + +export interface GitoxideMutationCandidateReceiptV1 { + readonly schemaVersion: 1; + readonly protocol: 'maka_gitoxide_mutation_candidate_v1'; + readonly repositoryId: string; + readonly workspaceId: string; + readonly workspaceEpochId: string; + readonly workspaceVersionId: string; + readonly baseAcceptedEventId: string; + readonly baseHeadRevision: number; + readonly baseCommitOid: string; + readonly baseTreeOid: string; + readonly operationIdentitySha256: `sha256:${string}`; + readonly acceptedRef: typeof ACCEPTED_REF; + readonly candidateRef: string; + readonly candidateCommitOid: string; + readonly candidateTreeOid: string; + readonly resultBlobOid: string; + readonly path: string; + readonly contentSha256: `sha256:${string}`; + readonly executionProfileDigest: `sha256:${string}`; +} + +export interface GitoxideMutationCandidateCaptureInput { + readonly operationId: string; + readonly path: string; + readonly content: string; + readonly executionProfileDigest: `sha256:${string}`; + readonly abortSignal?: AbortSignal; +} + +export interface GitoxideMutationCandidateProofV1 { + readonly receipt: GitoxideMutationCandidateReceiptV1; + readonly candidateCapability: GitoxideMutationCandidateCapability; +} + +export interface GitoxideMutationCandidateAuthorityInternal { + readBaseFile( + path: string, + abortSignal?: AbortSignal, + ): Promise<{ readonly content: string; readonly blobOid: string } | null>; + materializeBaseProjection( + destinationPath: string, + abortSignal?: AbortSignal, + ): Promise<{ + readonly destinationPath: string; + verify(abortSignal?: AbortSignal): Promise; + }>; + capture(input: GitoxideMutationCandidateCaptureInput): Promise; + validate(proof: GitoxideMutationCandidateProofV1): GitoxideMutationCandidateReceiptV1; + promote( + proof: GitoxideMutationCandidateProofV1, + abortSignal?: AbortSignal, + ): Promise; + promoteDurable( + operationId: string, + abortSignal?: AbortSignal, + ): Promise; +} + +export type GitoxideMutationCandidateFailpoint = 'after_candidate_ref' | 'after_candidate_receipt'; + +export class GitoxideMutationCandidateAuthorityError extends Error { + constructor( + readonly code: + | 'gitoxide_mutation_candidate_request_invalid' + | 'gitoxide_mutation_candidate_receipt_invalid' + | 'gitoxide_mutation_candidate_identity_conflict', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'GitoxideMutationCandidateAuthorityError'; + } +} + +export async function createGitoxideMutationCandidateAuthorityInternal(input: { + readonly storageRootLease: StorageRootLease<'interactive', 'write'>; + readonly baseHead: WorkspaceHeadRecordV1; + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly failpoint?: (point: GitoxideMutationCandidateFailpoint) => void | Promise; +}): Promise { + await assertStorageRootLease(input.storageRootLease, 'interactive', 'write'); + const rootContext = await runWithStorageRootLease( + input.storageRootLease, + 'interactive', + 'write', + async (storageRoot) => { + const repositoryPath = await realpath( + gitoxideManagedRepositoryPathInternal(storageRoot, input.baseHead), + ); + assertWithin(storageRoot, repositoryPath, 'Gitoxide managed repository'); + const receiptRoot = gitoxideMutationCandidateReceiptRootInternal(storageRoot, input.baseHead); + await mkdir(receiptRoot, { recursive: true, mode: 0o700 }); + const canonicalReceiptRoot = await realpath(receiptRoot); + assertWithin(storageRoot, canonicalReceiptRoot, 'Gitoxide candidate receipt root'); + if (process.platform !== 'win32') await chmod(canonicalReceiptRoot, 0o700); + return { storageRoot, repositoryPath, canonicalReceiptRoot }; + }, + ); + + const managedRepositoryOwnerToken = {}; + const candidateOwnerToken = {}; + const issuedProofs = new WeakMap< + GitoxideMutationCandidateProofV1, + GitoxideMutationCandidateReceiptV1 + >(); + let managedRepositoryCapabilityPromise: + | ReturnType + | undefined; + const requireBaseRepositoryCapability = () => { + managedRepositoryCapabilityPromise ??= reopenGitoxideManagedRepositoryInternal({ + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + repositoryPath: rootContext.repositoryPath, + acceptedRef: ACCEPTED_REF, + expectedAcceptedCommitOid: input.baseHead.commitOid, + expectedAcceptedTreeOid: input.baseHead.treeOid, + }); + return managedRepositoryCapabilityPromise; + }; + + const capture = async ( + request: GitoxideMutationCandidateCaptureInput, + ): Promise => { + assertCaptureInput(request); + return runWithStorageRootLease( + input.storageRootLease, + 'interactive', + 'write', + async (storageRoot) => { + if (storageRoot !== rootContext.storageRoot) { + throw new GitoxideMutationCandidateAuthorityError( + 'gitoxide_mutation_candidate_identity_conflict', + 'Gitoxide candidate authority storage root identity changed', + ); + } + const operationIdentitySha256 = sha256(request.operationId); + const receiptPath = join( + rootContext.canonicalReceiptRoot, + `${operationIdentitySha256.slice(7)}.json`, + ); + return withProcessLifetimeFileUpdateLock(receiptPath, async () => { + request.abortSignal?.throwIfAborted(); + const durable = await readReceipt(receiptPath); + const managedRepositoryCapability = await requireBaseRepositoryCapability(); + const candidate = await prepareGitoxideMutationCandidateInternal({ + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + managedRepositoryCapability, + candidateOwnerToken, + operationId: request.operationId, + path: request.path, + content: request.content, + abortSignal: request.abortSignal, + }); + const expected = freezeReceipt({ + schemaVersion: 1, + protocol: 'maka_gitoxide_mutation_candidate_v1', + repositoryId: input.baseHead.repositoryId, + workspaceId: input.baseHead.workspaceId, + workspaceEpochId: input.baseHead.workspaceEpochId, + workspaceVersionId: input.baseHead.workspaceVersionId, + baseAcceptedEventId: input.baseHead.acceptedEventId, + baseHeadRevision: input.baseHead.revision, + baseCommitOid: input.baseHead.commitOid, + baseTreeOid: input.baseHead.treeOid, + operationIdentitySha256, + acceptedRef: ACCEPTED_REF, + candidateRef: candidate.candidateRef, + candidateCommitOid: candidate.successorCommitOid, + candidateTreeOid: candidate.successorTreeOid, + resultBlobOid: candidate.resultBlobOid, + path: candidate.path, + contentSha256: sha256(request.content), + executionProfileDigest: request.executionProfileDigest, + }); + if (durable && !isDeepStrictEqual(durable, expected)) { + throw new GitoxideMutationCandidateAuthorityError( + 'gitoxide_mutation_candidate_identity_conflict', + 'Durable Gitoxide candidate receipt conflicts with the exact operation candidate', + ); + } + if (!durable) { + await input.failpoint?.('after_candidate_ref'); + await writeReceiptAtomic(receiptPath, expected); + await input.failpoint?.('after_candidate_receipt'); + } + const receipt = durable ?? expected; + const proof = Object.freeze({ + receipt, + candidateCapability: candidate.candidateCapability, + }); + issuedProofs.set(proof, receipt); + return proof; + }); + }, + ); + }; + + const promoteReceipt = async ( + receipt: GitoxideMutationCandidateReceiptV1, + abortSignal?: AbortSignal, + ): Promise => { + const result = await promoteCandidateWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + repositoryPath: rootContext.repositoryPath, + expectedBaseCommitOid: receipt.baseCommitOid, + acceptedRef: receipt.acceptedRef, + candidateRef: receipt.candidateRef, + expectedCandidateCommitOid: receipt.candidateCommitOid, + ...(abortSignal ? { abortSignal } : {}), + }); + if ( + result.kind !== 'candidate_promoted' || + result.baseCommitOid !== receipt.baseCommitOid || + result.candidateCommitOid !== receipt.candidateCommitOid || + result.acceptedRef !== receipt.acceptedRef || + result.candidateRef !== receipt.candidateRef + ) { + throw new GitoxideMutationCandidateAuthorityError( + 'gitoxide_mutation_candidate_identity_conflict', + 'Gitoxide accepted ref no longer matches the candidate promotion proof', + ); + } + return receipt; + }; + + return Object.freeze({ + async readBaseFile(path: string, abortSignal?: AbortSignal) { + try { + const managedRepositoryCapability = await requireBaseRepositoryCapability(); + const result = await readGitoxideTreeFileInternal({ + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + managedRepositoryCapability, + path, + ...(abortSignal ? { abortSignal } : {}), + }); + return Object.freeze({ content: result.content, blobOid: result.blobOid }); + } catch (error) { + if ( + error instanceof GitoxideHelperInvocationError && + error.code === 'gitoxide_helper_operation_failed' && + error.helperReason === 'tree_file_unavailable' + ) { + return null; + } + throw error; + } + }, + async materializeBaseProjection(destinationPath: string, abortSignal?: AbortSignal) { + const managedRepositoryCapability = await requireBaseRepositoryCapability(); + const projectionOwnerToken = {}; + const projection = await materializeGitoxideProjectionInternal({ + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + managedRepositoryCapability, + projectionOwnerToken, + destinationPath, + ...(abortSignal ? { abortSignal } : {}), + }); + return Object.freeze({ + destinationPath: projection.destinationPath, + async verify(signal?: AbortSignal) { + const observation = await observeGitoxideProjectionInternal({ + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + projectionOwnerToken, + projectionCapability: projection.projectionCapability, + ...(signal ? { abortSignal: signal } : {}), + }); + if (observation.kind !== 'projection_observed') { + throw new Error( + `Gitoxide projection drifted at ${observation.path}: ${observation.reason}`, + ); + } + }, + }); + }, + capture, + validate(proof: GitoxideMutationCandidateProofV1) { + const issuedReceipt = issuedProofs.get(proof); + if (!issuedReceipt || proof.receipt !== issuedReceipt) { + throw new GitoxideMutationCandidateAuthorityError( + 'gitoxide_mutation_candidate_identity_conflict', + 'Gitoxide candidate proof was not issued by this authority', + ); + } + const candidate = requireGitoxideMutationCandidateInternal( + candidateOwnerToken, + proof.candidateCapability, + ); + if ( + candidate.candidateRef !== proof.receipt.candidateRef || + candidate.candidateCommitOid !== proof.receipt.candidateCommitOid || + candidate.candidateTreeOid !== proof.receipt.candidateTreeOid || + candidate.resultBlobOid !== proof.receipt.resultBlobOid || + candidate.path !== proof.receipt.path + ) { + throw new GitoxideMutationCandidateAuthorityError( + 'gitoxide_mutation_candidate_identity_conflict', + 'Gitoxide candidate capability does not match its durable receipt', + ); + } + return issuedReceipt; + }, + async promote(proof: GitoxideMutationCandidateProofV1, abortSignal?: AbortSignal) { + const receipt = this.validate(proof); + return promoteReceipt(receipt, abortSignal); + }, + async promoteDurable(operationId: string, abortSignal?: AbortSignal) { + if (operationId.length === 0 || operationId.length > 1024) { + throw new GitoxideMutationCandidateAuthorityError( + 'gitoxide_mutation_candidate_request_invalid', + 'Gitoxide durable candidate operation identity is invalid', + ); + } + const operationIdentitySha256 = sha256(operationId); + const receiptPath = join( + rootContext.canonicalReceiptRoot, + `${operationIdentitySha256.slice(7)}.json`, + ); + return withProcessLifetimeFileUpdateLock(receiptPath, async () => { + abortSignal?.throwIfAborted(); + const receipt = await readReceipt(receiptPath); + if (!receipt || !receiptMatchesBase(receipt, operationIdentitySha256, input.baseHead)) { + throw new GitoxideMutationCandidateAuthorityError( + 'gitoxide_mutation_candidate_identity_conflict', + 'Durable Gitoxide candidate receipt does not match its accepted base', + ); + } + return promoteReceipt(receipt, abortSignal); + }); + }, + }); +} + +function receiptMatchesBase( + receipt: GitoxideMutationCandidateReceiptV1, + operationIdentitySha256: `sha256:${string}`, + head: WorkspaceHeadRecordV1, +): boolean { + return ( + receipt.repositoryId === head.repositoryId && + receipt.workspaceId === head.workspaceId && + receipt.workspaceEpochId === head.workspaceEpochId && + receipt.workspaceVersionId === head.workspaceVersionId && + receipt.baseAcceptedEventId === head.acceptedEventId && + receipt.baseHeadRevision === head.revision && + receipt.baseCommitOid === head.commitOid && + receipt.baseTreeOid === head.treeOid && + receipt.operationIdentitySha256 === operationIdentitySha256 && + receipt.acceptedRef === ACCEPTED_REF + ); +} + +export function gitoxideManagedRepositoryPathInternal( + storageRoot: string, + head: Pick, +): string { + return join( + resolve(storageRoot), + 'managed-workspaces', + 'gitoxide-repositories', + identityDigest(head), + 'repository.git', + ); +} + +export function gitoxideMutationCandidateReceiptRootInternal( + storageRoot: string, + head: Pick, +): string { + return join( + resolve(storageRoot), + 'managed-workspaces', + 'gitoxide-candidates', + identityDigest(head), + ); +} + +function identityDigest( + head: Pick, +): string { + return createHash('sha256') + .update(`${head.workspaceId}\0${head.workspaceEpochId}`, 'utf8') + .digest('hex') + .slice(0, 32); +} + +function assertCaptureInput(input: GitoxideMutationCandidateCaptureInput): void { + if ( + input.operationId.length === 0 || + input.operationId.length > 1024 || + !SHA256_PATTERN.test(input.executionProfileDigest) + ) { + throw new GitoxideMutationCandidateAuthorityError( + 'gitoxide_mutation_candidate_request_invalid', + 'Gitoxide mutation candidate request is invalid', + ); + } +} + +async function readReceipt(path: string): Promise { + const info = await lstat(path).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + }); + if (!info) return undefined; + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_RECEIPT_BYTES) { + throw invalidReceipt('Candidate receipt must be a bounded regular file'); + } + try { + return decodeReceipt(JSON.parse(await readFile(path, 'utf8'))); + } catch (error) { + if (error instanceof GitoxideMutationCandidateAuthorityError) throw error; + throw invalidReceipt('Candidate receipt is not strict JSON', error); + } +} + +function decodeReceipt(value: unknown): GitoxideMutationCandidateReceiptV1 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw invalidReceipt('Candidate receipt must be an object'); + } + const record = value as Record; + if ( + Object.keys(record).sort().join('\0') !== [...RECEIPT_KEYS].sort().join('\0') || + record.schemaVersion !== 1 || + record.protocol !== 'maka_gitoxide_mutation_candidate_v1' || + typeof record.repositoryId !== 'string' || + typeof record.workspaceId !== 'string' || + typeof record.workspaceEpochId !== 'string' || + typeof record.workspaceVersionId !== 'string' || + typeof record.baseAcceptedEventId !== 'string' || + !Number.isSafeInteger(record.baseHeadRevision) || + (record.baseHeadRevision as number) < 1 || + typeof record.baseCommitOid !== 'string' || + !SHA1_PATTERN.test(record.baseCommitOid) || + typeof record.baseTreeOid !== 'string' || + !SHA1_PATTERN.test(record.baseTreeOid) || + typeof record.operationIdentitySha256 !== 'string' || + !SHA256_PATTERN.test(record.operationIdentitySha256) || + record.acceptedRef !== ACCEPTED_REF || + typeof record.candidateRef !== 'string' || + !record.candidateRef.startsWith('refs/maka/candidates/') || + typeof record.candidateCommitOid !== 'string' || + !SHA1_PATTERN.test(record.candidateCommitOid) || + typeof record.candidateTreeOid !== 'string' || + !SHA1_PATTERN.test(record.candidateTreeOid) || + typeof record.resultBlobOid !== 'string' || + !SHA1_PATTERN.test(record.resultBlobOid) || + typeof record.path !== 'string' || + typeof record.contentSha256 !== 'string' || + !SHA256_PATTERN.test(record.contentSha256) || + typeof record.executionProfileDigest !== 'string' || + !SHA256_PATTERN.test(record.executionProfileDigest) + ) { + throw invalidReceipt('Candidate receipt has an invalid envelope'); + } + return freezeReceipt(record as unknown as GitoxideMutationCandidateReceiptV1); +} + +function freezeReceipt( + value: GitoxideMutationCandidateReceiptV1, +): GitoxideMutationCandidateReceiptV1 { + return Object.freeze({ ...value }); +} + +async function writeReceiptAtomic( + path: string, + receipt: GitoxideMutationCandidateReceiptV1, +): Promise { + const encoded = `${JSON.stringify(receipt)}\n`; + if (Buffer.byteLength(encoded, 'utf8') > MAX_RECEIPT_BYTES) { + throw invalidReceipt('Candidate receipt exceeds its byte limit'); + } + const temporaryPath = `${path}.${randomUUID()}.tmp`; + try { + const handle = await open(temporaryPath, 'wx', 0o600); + try { + await handle.writeFile(encoded, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + if (process.platform !== 'win32') await chmod(temporaryPath, 0o600); + await rename(temporaryPath, path); + await syncDirectory(dirname(path)); + } finally { + await rm(temporaryPath, { force: true }); + } +} + +function sha256(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; +} + +function assertWithin(root: string, target: string, label: string): void { + const rel = relative(root, target); + if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) return; + throw new GitoxideMutationCandidateAuthorityError( + 'gitoxide_mutation_candidate_request_invalid', + `${label} escapes the storage root`, + ); +} + +function invalidReceipt(message: string, cause?: unknown): GitoxideMutationCandidateAuthorityError { + return new GitoxideMutationCandidateAuthorityError( + 'gitoxide_mutation_candidate_receipt_invalid', + message, + cause === undefined ? undefined : { cause }, + ); +} diff --git a/packages/runtime-host/src/server/gitoxide-managed-inspection.ts b/packages/runtime-host/src/server/gitoxide-managed-inspection.ts new file mode 100644 index 0000000000..5a359dae35 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-managed-inspection.ts @@ -0,0 +1,547 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { isAbsolute, join, posix, relative } from 'node:path'; +import { z } from 'zod'; +import { createReadOnlyPermissionProfile } from '@maka/core/permission-profile'; +import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; +import type { MakaTool } from '@maka/runtime/tool-runtime'; +import { + computeManagedDependencyEnvironmentIdentity, + createManagedDependencyEnvironmentAuthority, + createManagedDependencyEnvironmentProducerCapability, + type ManagedDependencyEnvironmentAuthority, + type ManagedDependencyEnvironmentProducerInput, +} from '@maka/storage/managed-dependency-environment'; +import type { + ManagedWorkspaceFilesystemWorker, + ManagedWorkspaceReadOnlyOperation, + ManagedWorkspaceReadOnlyResult, +} from '@maka/storage/managed-workspace-owner'; +import { resolveBundledNpmRuntime } from './bundled-npm-runtime.js'; +import { + runManagedNpmDependencyProvision, + type ManagedNpmRuntimeCapability, +} from './managed-dependency-producer-process.js'; +import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artifact-authority-internal.js'; +import { resolvePackagedGitoxideHelperInternal } from './packaged-gitoxide-helper-internal.js'; +import { + admitGitoxideRepositoryInternal, + importAdmittedGitoxideRepositoryInternal, + materializeGitoxideProjectionInternal, + observeGitoxideProjectionInternal, + readGitoxideTreeFileInternal, +} from './gitoxide-repository-admission-authority-internal.js'; + +const MAX_PATH_CHARS = 4_096; +const MAX_GLOB_PATTERN_CHARS = 4_096; +const MAX_RESULT_BYTES = 64 * 1024; +const MAX_GLOB_RESULTS = 256; +const BASELINE_REF = 'refs/maka/accepted'; + +const boundedPath = z.string().min(1).max(MAX_PATH_CHARS); +const managedInspectionInputSchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('read'), + path: boundedPath, + offset: z.number().int().nonnegative().optional(), + limit: z.number().int().positive().max(MAX_GLOB_RESULTS).optional(), + }) + .strict(), + z + .object({ + kind: z.literal('glob'), + path: boundedPath, + pattern: z.string().min(1).max(MAX_GLOB_PATTERN_CHARS), + limit: z.number().int().positive().max(MAX_GLOB_RESULTS).optional(), + }) + .strict(), +]); + +export type GitoxideManagedInspectionInput = z.infer; + +export interface GitoxideManagedInspectionResult { + readonly kind: 'gitoxide_managed_inspection_v1'; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; + /** Present only when this operation actually consumed the dependency environment. */ + readonly dependencyEnvironmentId?: `sha256:${string}`; + readonly result: ManagedWorkspaceReadOnlyResult; +} + +export interface GitoxideInspectionRepositoryInternal { + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; + readFile( + path: string, + abortSignal: AbortSignal, + ): Promise<{ readonly path: string; readonly content: string }>; + materializeProjection( + destinationPath: string, + abortSignal: AbortSignal, + ): Promise<{ + readonly destinationPath: string; + verify(abortSignal: AbortSignal): Promise; + }>; +} + +export type GitoxideInspectionRepositoryProviderInternal = (input: { + readonly sourceCwd: string; + readonly repositoryPath: string; + readonly abortSignal: AbortSignal; +}) => Promise; + +export interface GitoxideManagedInspectionComposition { + readonly state: 'ready' | 'draining' | 'closed'; + readonly tool: MakaTool; + toolForRepositoryProvider( + provider: GitoxideInspectionRepositoryProviderInternal, + ): MakaTool; + beginDrain(): void; + close(): Promise; +} + +export interface CreateGitoxideManagedInspectionCompositionInput { + readonly storageRoot: string; + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly npmRuntime: ManagedNpmRuntimeCapability; + readonly dependencyAuthority: ManagedDependencyEnvironmentAuthority; + readonly filesystemWorker: ManagedWorkspaceFilesystemWorker; +} + +export async function createGitoxideManagedInspectionComposition( + input: CreateGitoxideManagedInspectionCompositionInput, +): Promise { + const storageRoot = await realpath(input.storageRoot); + const stagingRoot = join(storageRoot, 'managed-workspaces', 'gitoxide-inspection-staging'); + await mkdir(stagingRoot, { recursive: true }); + const canonicalStagingRoot = await realpath(stagingRoot); + assertWithin(storageRoot, canonicalStagingRoot, 'Gitoxide inspection staging root'); + + const invocationOwnerToken = input.invocationOwnerToken; + const admissionOwnerToken = {}; + const managedRepositoryOwnerToken = {}; + const projectionOwnerToken = {}; + let state: GitoxideManagedInspectionComposition['state'] = 'ready'; + let activeOperations = 0; + const drainWaiters = new Set<() => void>(); + let closeTask: Promise | undefined; + + const openFreshSourceRepository = async (request: { + readonly sourceRoot: string; + readonly repositoryPath: string; + readonly abortSignal: AbortSignal; + }): Promise => { + const admitted = await admitGitoxideRepositoryInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + admissionOwnerToken, + repositoryPath: request.sourceRoot, + abortSignal: request.abortSignal, + }); + if (admitted.kind !== 'accepted') { + throw new Error(`Gitoxide rejected the source repository: ${admitted.reason}`); + } + const imported = await importAdmittedGitoxideRepositoryInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + admissionOwnerToken, + repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, + destinationRepositoryPath: request.repositoryPath, + baselineRef: BASELINE_REF, + abortSignal: request.abortSignal, + }); + return Object.freeze({ + acceptedCommitOid: imported.baselineCommitOid, + acceptedTreeOid: imported.baselineTreeOid, + async readFile(path: string, abortSignal: AbortSignal) { + const file = await readGitoxideTreeFileInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + path, + abortSignal, + }); + return Object.freeze({ path: file.path, content: file.content }); + }, + async materializeProjection(destinationPath: string, abortSignal: AbortSignal) { + const projection = await materializeGitoxideProjectionInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + projectionOwnerToken, + destinationPath, + abortSignal, + }); + return Object.freeze({ + destinationPath: projection.destinationPath, + async verify(signal: AbortSignal) { + const observation = await observeGitoxideProjectionInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + projectionOwnerToken, + projectionCapability: projection.projectionCapability, + abortSignal: signal, + }); + if (observation.kind !== 'projection_observed') { + throw new Error( + `Gitoxide projection drifted at ${observation.path}: ${observation.reason}`, + ); + } + }, + }); + }, + }); + }; + + const execute = async ( + operation: GitoxideManagedInspectionInput, + sourceCwd: string, + abortSignal: AbortSignal, + repositoryProvider?: GitoxideInspectionRepositoryProviderInternal, + ): Promise => { + if (state !== 'ready') throw new Error(`Gitoxide managed inspection is ${state}`); + const route = routeInspectionOperation(operation); + abortSignal.throwIfAborted(); + activeOperations += 1; + let operationRoot: string | undefined; + let dependencyLease: Awaited> | undefined; + let primaryError: unknown; + try { + const sourceRoot = await realpath(sourceCwd); + abortSignal.throwIfAborted(); + operationRoot = await mkdtemp(join(canonicalStagingRoot, 'inspection-')); + const repositoryPath = join(operationRoot, 'repository.git'); + const projectionPath = join(operationRoot, 'projection'); + const repository = repositoryProvider + ? await repositoryProvider({ sourceCwd: sourceRoot, repositoryPath, abortSignal }) + : await openFreshSourceRepository({ sourceRoot, repositoryPath, abortSignal }); + let rawResult: ManagedWorkspaceReadOnlyResult; + let dependencyEnvironmentId: `sha256:${string}` | undefined; + if (route.root === 'source_tree') { + if (operation.kind !== 'read') throw new Error('Invalid source-tree inspection route'); + const file = await repository.readFile(route.workerOperation.path, abortSignal); + rawResult = Object.freeze({ + kind: 'read' as const, + content: sliceReadContent(file.content, operation.offset, operation.limit), + }); + } else if (route.root === 'projection') { + const projection = await repository.materializeProjection(projectionPath, abortSignal); + rawResult = await input.filesystemWorker.execute({ + operation: route.workerOperation, + cwd: projection.destinationPath, + executionBoundary: route.executionBoundary, + abortSignal, + }); + await projection.verify(abortSignal); + } else { + // These reads are intentionally sequential. A rejected child cannot outlive + // the operation and race cleanup of the shared managed repository. + const manifest = await repository.readFile('package.json', abortSignal); + const lockfile = await repository.readFile('package-lock.json', abortSignal); + const manifestBytes = Buffer.from(manifest.content, 'utf8'); + const lockfileBytes = Buffer.from(lockfile.content, 'utf8'); + const producerCapability = createManagedDependencyEnvironmentProducerCapability( + input.npmRuntime.runtimeIdentitySha256, + ); + const dependencyIdentity = computeManagedDependencyEnvironmentIdentity({ + manifestPath: manifest.path, + manifestBytes, + lockfilePath: lockfile.path, + lockfileBytes, + packageManagerName: 'npm', + packageManagerVersion: input.npmRuntime.npmVersion, + nodeVersion: input.npmRuntime.nodeVersion, + nodeAbi: input.npmRuntime.nodeAbi, + platform: input.npmRuntime.platform, + arch: input.npmRuntime.arch, + producerRuntimeIdentitySha256: producerCapability.runtimeIdentitySha256, + producerPolicyIdentitySha256: producerCapability.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1', + }); + dependencyLease = await input.dependencyAuthority.acquire(dependencyIdentity, { + manifestBytes, + lockfileBytes, + abortSignal, + }); + abortSignal.throwIfAborted(); + dependencyEnvironmentId = dependencyLease.environmentId; + rawResult = await input.filesystemWorker.execute({ + operation: route.workerOperation, + cwd: dependencyLease.dependencyRoot, + executionBoundary: route.executionBoundary, + abortSignal, + }); + } + const result = remapInspectionResult(route, rawResult); + const response = Object.freeze({ + kind: 'gitoxide_managed_inspection_v1' as const, + acceptedCommitOid: repository.acceptedCommitOid, + acceptedTreeOid: repository.acceptedTreeOid, + ...(dependencyEnvironmentId ? { dependencyEnvironmentId } : {}), + result, + }); + if (Buffer.byteLength(JSON.stringify(response), 'utf8') > MAX_RESULT_BYTES) { + throw new Error('Managed inspection result exceeds its response limit; narrow the request'); + } + return response; + } catch (error) { + primaryError = error; + throw error; + } finally { + const cleanupErrors: unknown[] = []; + try { + await dependencyLease?.release(); + } catch (error) { + cleanupErrors.push(error); + } + try { + if (operationRoot) await rm(operationRoot, { recursive: true, force: true }); + } catch (error) { + cleanupErrors.push(error); + } + activeOperations -= 1; + if (activeOperations === 0) { + for (const resolveWaiter of drainWaiters) resolveWaiter(); + drainWaiters.clear(); + } + if (primaryError instanceof Error && cleanupErrors.length > 0) { + const cleanupCause = new AggregateError( + cleanupErrors, + 'Gitoxide managed inspection cleanup also failed', + ); + if (primaryError.cause === undefined) { + Object.defineProperty(primaryError, 'cause', { + configurable: true, + value: cleanupCause, + }); + } + } + if (primaryError === undefined && cleanupErrors.length === 1) throw cleanupErrors[0]; + if (primaryError === undefined && cleanupErrors.length > 1) { + throw new AggregateError(cleanupErrors, 'Gitoxide managed inspection cleanup failed'); + } + } + }; + + const createTool = ( + repositoryProvider?: GitoxideInspectionRepositoryProviderInternal, + ): MakaTool => ({ + name: 'ManagedWorkspaceInspect', + displayName: 'Inspect isolated workspace', + description: + 'Read or glob a project through a Maka-owned Gitoxide accepted tree and its attested npm dependency environment. ' + + 'This operation may provision dependencies and is intentionally unavailable in read-only Plan Mode.', + parameters: managedInspectionInputSchema, + categoryHint: 'custom_tool', + recoveryMode: 'never_auto_retry', + executionSemantics: 'exclusive_step', + impl: async (operation, context) => + execute(operation, context.cwd, context.abortSignal, repositoryProvider), + }); + const tool = createTool(); + + return Object.freeze({ + get state() { + return state; + }, + tool, + toolForRepositoryProvider(provider: GitoxideInspectionRepositoryProviderInternal) { + return createTool(provider); + }, + beginDrain() { + if (state === 'ready') state = 'draining'; + }, + close() { + closeTask ??= (async () => { + if (state === 'ready') state = 'draining'; + if (activeOperations > 0) { + await new Promise((resolveWaiter) => drainWaiters.add(resolveWaiter)); + } + await input.dependencyAuthority.close(); + state = 'closed'; + })(); + return closeTask; + }, + }); +} + +export async function tryOpenPackagedGitoxideManagedInspectionComposition(input: { + readonly storageRoot: string; + readonly filesystemWorker?: ManagedWorkspaceFilesystemWorker; + readonly onUnavailable?: (error: unknown) => void; +}): Promise { + const resourcesRoot = runtimeHostPackagedResourcesRootInternal(); + if (!resourcesRoot || !input.filesystemWorker) return undefined; + const invocationOwnerToken = {}; + try { + const helperCapability = await resolvePackagedGitoxideHelperInternal({ + invocationOwnerToken, + }); + const npmRuntime = await resolveBundledNpmRuntime({ resourcesRoot }); + const producerCapability = createManagedDependencyEnvironmentProducerCapability( + npmRuntime.runtimeIdentitySha256, + ); + const dependencyAuthority = await createManagedDependencyEnvironmentAuthority({ + storageRoot: input.storageRoot, + producer: Object.freeze({ + capability: producerCapability, + packageManagerName: 'npm' as const, + packageManagerVersion: npmRuntime.npmVersion, + nodeRuntime: Object.freeze({ + version: npmRuntime.nodeVersion, + abi: npmRuntime.nodeAbi, + platform: npmRuntime.platform, + arch: npmRuntime.arch, + }), + provision: async (producerInput: ManagedDependencyEnvironmentProducerInput) => + runManagedNpmDependencyProvision({ producerInput, runtime: npmRuntime }), + }), + }); + try { + return await createGitoxideManagedInspectionComposition({ + storageRoot: input.storageRoot, + invocationOwnerToken, + helperCapability, + npmRuntime, + dependencyAuthority, + filesystemWorker: input.filesystemWorker, + }); + } catch (error) { + await dependencyAuthority.close().catch(() => undefined); + throw error; + } + } catch (error) { + input.onUnavailable?.(error); + return undefined; + } +} + +export function runtimeHostPackagedResourcesRootInternal(): string | undefined { + if (!process.versions.electron) return undefined; + const resourcesPath = (process as NodeJS.Process & { readonly resourcesPath?: string }) + .resourcesPath; + return typeof resourcesPath === 'string' && isAbsolute(resourcesPath) ? resourcesPath : undefined; +} + +interface InspectionRoute { + readonly root: 'source_tree' | 'projection' | 'dependency'; + readonly logicalPath: string; + readonly workerOperation: ManagedWorkspaceReadOnlyOperation; + readonly executionBoundary: Parameters< + ManagedWorkspaceFilesystemWorker['execute'] + >[0]['executionBoundary']; +} + +function routeInspectionOperation(operation: GitoxideManagedInspectionInput): InspectionRoute { + const canonicalPath = canonicalProjectPath(operation.path); + const segments = canonicalPath === '.' ? [] : canonicalPath.split('/'); + const dependencyRoot = + segments.length > 0 && + (process.platform === 'win32' + ? segments[0]?.toLowerCase() === 'node_modules' + : segments[0] === 'node_modules'); + const logicalPath = dependencyRoot + ? ['node_modules', ...segments.slice(1)].join('/') + : canonicalPath; + const workerPath = dependencyRoot ? segments.slice(1).join('/') || '.' : canonicalPath; + return Object.freeze({ + root: dependencyRoot ? 'dependency' : operation.kind === 'read' ? 'source_tree' : 'projection', + logicalPath, + workerOperation: Object.freeze({ ...operation, path: workerPath }), + executionBoundary: createReadOnlyBoundary(), + }); +} + +function sliceReadContent(content: string, offset?: number, limit?: number): string { + if (offset === undefined && limit === undefined) return content; + const lines = content.split('\n'); + const start = offset ?? 0; + const end = limit === undefined ? lines.length : start + limit; + return lines.slice(start, end).join('\n'); +} + +function canonicalProjectPath(value: string): string { + if ( + value.includes('\\') || + value.includes('\0') || + value.startsWith('/') || + /^[a-zA-Z]:/u.test(value) + ) { + throw new TypeError('Managed inspection path must be a canonical project-relative path'); + } + if (value === '.') return value; + const segments = value.split('/'); + if (segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) { + throw new TypeError('Managed inspection path must not contain empty, dot, or dot-dot segments'); + } + const normalized = posix.normalize(value); + if (normalized !== value) { + throw new TypeError('Managed inspection path must already be canonical'); + } + return normalized; +} + +function remapInspectionResult( + route: InspectionRoute, + result: ManagedWorkspaceReadOnlyResult, +): ManagedWorkspaceReadOnlyResult { + if (result.kind !== 'glob') return result; + return Object.freeze({ + kind: 'glob' as const, + files: Object.freeze( + result.files.map((file) => { + const relativeFile = canonicalWorkerResultPath(file); + return route.logicalPath === '.' + ? relativeFile + : posix.join(route.logicalPath, relativeFile); + }), + ), + }); +} + +function canonicalWorkerResultPath(value: string): string { + const normalized = value.replaceAll('\\', '/'); + if (normalized.length === 0 || normalized.startsWith('/') || /^[a-zA-Z]:/u.test(normalized)) { + throw new Error('Filesystem worker returned a non-relative glob path'); + } + const segments = normalized.split('/'); + if (segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) { + throw new Error('Filesystem worker returned a non-canonical glob path'); + } + return posix.normalize(normalized); +} + +function createReadOnlyBoundary(): Parameters< + ManagedWorkspaceFilesystemWorker['execute'] +>[0]['executionBoundary'] { + return createManagedExecutionBoundary(createReadOnlyPermissionProfile(), 0); +} + +function assertWithin(root: string, target: string, label: string): void { + const rel = relative(root, target); + if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) return; + throw new Error(`${label} escapes the Runtime Host storage root`); +} diff --git a/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts b/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts new file mode 100644 index 0000000000..b2027d7425 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts @@ -0,0 +1,714 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { isCanonicalManagedMutationPathV1 } from '@maka/core/runtime-event'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; +import type { + WorkspaceHeadRecordV1, + WorkspaceSuccessorAuthorityInput, + WorkspaceVersionRecordV1, +} from '@maka/core/workspace-version-authority'; +import { + GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST, + MANAGED_MUTATION_CANDIDATE_REJECTED_MESSAGE, + transformManagedMutation, +} from '@maka/runtime/managed-mutation-transform'; +import { formatSyntheticToolErrorText } from '@maka/runtime/tool-runtime'; +import type { RuntimeManagedMutationAdmission, ToolRuntimeInput } from '@maka/runtime/tool-runtime'; +import type { + ManagedMutationReservationRecordV1, + ManagedMutationTerminalCommitInput, + ManagedMutationTerminalCommitResult, + WorkspaceSuccessorCommitInput, + WorkspaceSuccessorCommitResult, +} from '@maka/storage/workspace-version-authority-internal'; +import { GitoxideHelperInvocationError } from './gitoxide-helper-invocation-internal.js'; + +type AdmissionInput = Parameters>[0]; + +interface CandidateReceipt { + readonly repositoryId: string; + readonly workspaceId: string; + readonly workspaceEpochId: string; + readonly workspaceVersionId: string; + readonly baseAcceptedEventId: string; + readonly baseHeadRevision: number; + readonly baseCommitOid: string; + readonly baseTreeOid: string; + readonly candidateCommitOid: string; + readonly candidateTreeOid: string; + readonly resultBlobOid: string; + readonly path: string; + readonly contentSha256: `sha256:${string}`; + readonly executionProfileDigest: `sha256:${string}`; +} + +interface CandidateProof { + readonly receipt: CandidateReceipt; +} + +type CandidateCaptureResult = + | { readonly kind: 'captured'; readonly proof: CandidateProof } + | { readonly kind: 'rejected_before_publication' } + | { readonly kind: 'publication_indeterminate'; readonly error: unknown }; + +const CANDIDATE_PREPUBLICATION_POLICY_REASONS = new Set([ + 'successor_content_limit_exceeded', + 'unsupported_base_path_kind', + 'unsupported_source_path', + 'unsupported_source_entry_kind', + 'source_tree_depth_exceeded', + 'source_tree_entry_limit_exceeded', + 'source_tree_visit_limit_exceeded', + 'source_path_collision', + 'source_path_byte_limit_exceeded', + 'source_path_length_exceeded', + 'source_file_limit_exceeded', + 'source_byte_limit_exceeded', +]); + +export interface GitoxideManagedMutationCandidateAuthorityInternal { + readBaseFile( + path: string, + abortSignal?: AbortSignal, + ): Promise<{ readonly content: string; readonly blobOid: string } | null>; + capture(input: { + readonly operationId: string; + readonly path: string; + readonly content: string; + readonly executionProfileDigest: `sha256:${string}`; + readonly abortSignal?: AbortSignal; + }): Promise; + promote(proof: CandidateProof, abortSignal?: AbortSignal): Promise; + promoteDurable(operationId: string, abortSignal?: AbortSignal): Promise; +} + +export interface GitoxideManagedMutationSettlementAuthorityInternal { + readHead( + workspaceId: string, + workspaceEpochId: string, + ): Promise; + readVersion(workspaceVersionId: string): Promise; + commitSuccessor(input: WorkspaceSuccessorCommitInput): Promise; + commitTerminal( + input: ManagedMutationTerminalCommitInput, + ): Promise; +} + +export type GitoxideManagedMutationAdmissionFailpoint = 'after_successor_commit'; + +interface PreparedToolOperation { + readonly operationId: string; + readonly invocationId: string; + readonly runId: string; + readonly turnId: string; + readonly providerToolCallId: string; + readonly toolName: string; + readonly canonicalArgsHash: string; + readonly recoveryMode: string; + readonly currentState: string; + readonly callEventId: string; + readonly dispatchEventId?: string; +} + +/** + * Reconciles one T1-owned mutation without invoking a filesystem tool again. + * The immutable accepted Git tree and persisted call arguments are the only + * transform inputs; SQLite remains the sole terminal-fact owner. + */ +export async function reconcilePreparedGitoxideManagedMutationInternal(input: { + readonly sessionId: string; + readonly reservation: ManagedMutationReservationRecordV1; + readonly operation: PreparedToolOperation; + readonly runtimeEvents: readonly RuntimeEvent[]; + readonly settlementAuthority: GitoxideManagedMutationSettlementAuthorityInternal; + readonly candidateAuthorityForHead: ( + head: WorkspaceHeadRecordV1, + ) => Promise; + readonly abortSignal?: AbortSignal; +}): Promise<'successor_committed' | 'terminal_committed'> { + input.abortSignal?.throwIfAborted(); + const { reservation, operation } = input; + if ( + operation.operationId !== reservation.operationId || + operation.currentState !== 'prepared' || + operation.recoveryMode !== 'reconcile' || + operation.dispatchEventId !== reservation.dispatchEventId || + (operation.toolName !== 'Write' && operation.toolName !== 'Edit') + ) { + throw new Error('Active managed mutation reservation conflicts with its tool operation'); + } + const callEvent = input.runtimeEvents.find((event) => event.id === operation.callEventId); + const dispatchEvent = input.runtimeEvents.find((event) => event.id === operation.dispatchEventId); + const call = callEvent?.content; + const dispatch = dispatchEvent?.actions?.toolDispatch; + const managed = dispatch?.managedMutation; + if ( + !callEvent || + call?.kind !== 'function_call' || + call.id !== operation.providerToolCallId || + call.name !== operation.toolName || + callEvent.sessionId !== input.sessionId || + callEvent.runId !== operation.runId || + callEvent.invocationId !== operation.invocationId || + callEvent.turnId !== operation.turnId || + canonicalToolArgsHash(call.name, call.args) !== operation.canonicalArgsHash || + !dispatchEvent || + dispatchEvent.sessionId !== input.sessionId || + dispatchEvent.runId !== operation.runId || + dispatchEvent.invocationId !== operation.invocationId || + dispatchEvent.turnId !== operation.turnId || + dispatch?.operationId !== operation.operationId || + dispatch.providerToolCallId !== operation.providerToolCallId || + dispatch.toolName !== operation.toolName || + dispatch.canonicalArgsHash !== operation.canonicalArgsHash || + dispatch.recoveryMode !== 'reconcile' || + !managed || + !managedMutationMatchesReservation(managed, reservation) + ) { + throw new Error('Prepared managed mutation ledger identity is corrupt'); + } + + const head = await input.settlementAuthority.readHead( + reservation.workspaceId, + reservation.workspaceEpochId, + ); + if (!head || !reservationMatchesHead(reservation, head)) { + throw new Error('Prepared managed mutation base is no longer the accepted head'); + } + const version = await input.settlementAuthority.readVersion(head.workspaceVersionId); + if (!version || !versionMatchesHead(version, head)) { + throw new Error('Prepared managed mutation base version is unavailable'); + } + const candidateAuthority = await input.candidateAuthorityForHead(head); + const path = reservation.expectedPaths[0]; + if (!path || reservation.expectedPaths.length !== 1) { + throw new Error('Prepared managed mutation path identity is invalid'); + } + const baseFile = await candidateAuthority.readBaseFile(path, input.abortSignal); + let transformed: ReturnType | undefined; + let errorMessage: string | undefined; + try { + transformed = transformManagedMutation({ + toolName: operation.toolName, + canonicalPath: path, + baseContent: baseFile?.content ?? null, + args: call.args, + }); + } catch (error) { + errorMessage = formatSyntheticToolErrorText(error); + } + const result = errorMessage + ? Object.freeze({ kind: 'json' as const, value: Object.freeze({ error: errorMessage }) }) + : coerceRecoveredResult(transformed!.providerResult); + const outcome = buildRecoveredOutcome(callEvent, operation, result, errorMessage !== undefined); + if (errorMessage) { + await input.settlementAuthority.commitTerminal({ + disposition: 'operation_failed_no_effect_committed', + toolOutcome: toolOutcomeInput(operation.operationId, outcome), + }); + return 'terminal_committed'; + } + if (!transformed!.changed) { + await input.settlementAuthority.commitTerminal({ + disposition: 'no_workspace_change_committed', + toolOutcome: toolOutcomeInput(operation.operationId, outcome), + }); + return 'terminal_committed'; + } + const candidateCapture = await captureCandidate(candidateAuthority, { + operationId: operation.operationId, + path, + content: transformed!.content, + executionProfileDigest: GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST, + abortSignal: input.abortSignal, + }); + if (candidateCapture.kind === 'rejected_before_publication') { + const rejectedResult = coerceRecoveredResult({ + error: MANAGED_MUTATION_CANDIDATE_REJECTED_MESSAGE, + }); + const rejectedOutcome = buildRecoveredOutcome(callEvent, operation, rejectedResult, true); + await input.settlementAuthority.commitTerminal({ + disposition: 'operation_failed_no_effect_committed', + toolOutcome: toolOutcomeInput(operation.operationId, rejectedOutcome), + }); + return 'terminal_committed'; + } + if (candidateCapture.kind === 'publication_indeterminate') { + throw candidateCapture.error; + } + const candidate = candidateCapture.proof; + assertCandidateReceipt(candidate.receipt, head, path, transformed!.content); + await input.settlementAuthority.commitSuccessor({ + successor: successorInput({ + operationId: operation.operationId, + outcomeEventId: outcome.id, + outcomeTimestamp: outcome.ts, + version, + head, + receipt: candidate.receipt, + }), + toolOutcome: toolOutcomeInput(operation.operationId, outcome), + }); + await candidateAuthority.promote(candidate, input.abortSignal); + return 'successor_committed'; +} + +export function createGitoxideManagedMutationAdmissionInternal(input: { + readonly workspaceInstanceId: string; + readonly workspaceId: string; + readonly workspaceEpochId: string; + readonly settlementAuthority: GitoxideManagedMutationSettlementAuthorityInternal; + readonly candidateAuthorityForHead: ( + head: WorkspaceHeadRecordV1, + ) => Promise; + readonly failpoint?: (point: GitoxideManagedMutationAdmissionFailpoint) => void | Promise; +}): NonNullable { + return async (request: AdmissionInput): Promise => { + if (request.toolName !== 'Write' && request.toolName !== 'Edit') { + throw new Error('Gitoxide managed mutation admits only Write and Edit'); + } + const path = canonicalPath(request.persistedArgs); + const head = await input.settlementAuthority.readHead( + input.workspaceId, + input.workspaceEpochId, + ); + if (!head) throw new Error('Gitoxide managed mutation has no accepted workspace head'); + const version = await input.settlementAuthority.readVersion(head.workspaceVersionId); + if (!version || !versionMatchesHead(version, head)) { + throw new Error('Gitoxide managed mutation workspace version is unavailable'); + } + const candidateAuthority = await input.candidateAuthorityForHead(head); + const baseFile = await candidateAuthority.readBaseFile(path, request.abortSignal); + request.abortSignal.throwIfAborted(); + const durableDispatch = Object.freeze({ + protocol: 'managed_mutation_v1' as const, + repositoryId: head.repositoryId, + workspaceId: head.workspaceId, + workspaceEpochId: head.workspaceEpochId, + workspaceInstanceId: input.workspaceInstanceId, + objectFormat: 'sha1' as const, + baseWorkspaceVersionId: head.workspaceVersionId, + baseAcceptedEventId: head.acceptedEventId, + baseHeadRevision: head.revision, + baseCommitOid: head.commitOid, + baseTreeOid: head.treeOid, + expectedPaths: Object.freeze([path]), + executionProfileDigest: GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST, + }); + + return Object.freeze({ + durableDispatch, + gitoxideTransform: Object.freeze({ + canonicalPath: path, + baseContent: baseFile?.content ?? null, + }), + async execute(operation: Parameters[0]) { + const proof = await operation(); + if (proof.isError) { + await input.settlementAuthority.commitTerminal({ + disposition: 'operation_failed_no_effect_committed', + toolOutcome: toolOutcomeInput(request.operationId, proof.durableOutcome), + }); + return Object.freeze({ + kind: 'operation_failed_no_effect_committed' as const, + durableOutcome: proof.durableOutcome, + }); + } + const mutation = proof.managedMutationResult; + if (!mutation || mutation.canonicalPath !== path) { + return Object.freeze({ + kind: 'unsettled' as const, + error: new Error('Gitoxide managed mutation has no exact success transform'), + }); + } + if (!mutation.changed) { + await input.settlementAuthority.commitTerminal({ + disposition: 'no_workspace_change_committed', + toolOutcome: toolOutcomeInput(request.operationId, proof.durableOutcome), + }); + return Object.freeze({ + kind: 'no_workspace_change_committed' as const, + durableOutcome: proof.durableOutcome, + }); + } + const candidateCapture = await captureCandidate(candidateAuthority, { + operationId: request.operationId, + path, + content: mutation.content, + executionProfileDigest: GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST, + abortSignal: request.abortSignal, + }); + if (candidateCapture.kind === 'rejected_before_publication') { + if (!operation.rejectNoEffect) { + return Object.freeze({ + kind: 'unsettled' as const, + error: new Error('Runtime no-effect rejection capability is unavailable'), + }); + } + const rejectedProof = await operation.rejectNoEffect( + 'candidate_rejected_before_publication', + ); + await input.settlementAuthority.commitTerminal({ + disposition: 'operation_failed_no_effect_committed', + toolOutcome: toolOutcomeInput(request.operationId, rejectedProof.durableOutcome), + }); + return Object.freeze({ + kind: 'operation_failed_no_effect_committed' as const, + durableOutcome: rejectedProof.durableOutcome, + }); + } + if (candidateCapture.kind === 'publication_indeterminate') { + return Object.freeze({ + kind: 'unsettled' as const, + error: candidateCapture.error, + }); + } + const candidate = candidateCapture.proof; + assertCandidateReceipt(candidate.receipt, head, path, mutation.content); + const successor = successorInput({ + operationId: request.operationId, + outcomeEventId: proof.durableOutcome.id, + outcomeTimestamp: proof.durableOutcome.ts, + version, + head, + receipt: candidate.receipt, + }); + await input.settlementAuthority.commitSuccessor({ + successor, + toolOutcome: toolOutcomeInput(request.operationId, proof.durableOutcome), + }); + await input.failpoint?.('after_successor_commit'); + await candidateAuthority.promote(candidate, request.abortSignal); + return Object.freeze({ + kind: 'workspace_successor_committed' as const, + durableOutcome: proof.durableOutcome, + }); + }, + async dispose() {}, + }); + }; +} + +async function captureCandidate( + authority: GitoxideManagedMutationCandidateAuthorityInternal, + input: Parameters[0], +): Promise { + try { + return Object.freeze({ kind: 'captured' as const, proof: await authority.capture(input) }); + } catch (error) { + if ( + error instanceof GitoxideHelperInvocationError && + error.code === 'gitoxide_helper_operation_failed' && + error.helperReason !== undefined && + CANDIDATE_PREPUBLICATION_POLICY_REASONS.has(error.helperReason) + ) { + return Object.freeze({ kind: 'rejected_before_publication' as const }); + } + return Object.freeze({ kind: 'publication_indeterminate' as const, error }); + } +} + +function toolOutcomeInput( + operationId: string, + durableOutcome: import('@maka/core/runtime-event').RuntimeEvent, +) { + return { + operationId, + journalEventId: `${operationId}_outcome`, + runtimeEvent: durableOutcome, + committedAt: durableOutcome.ts, + }; +} + +function buildRecoveredOutcome( + callEvent: RuntimeEvent, + operation: PreparedToolOperation, + result: unknown, + isError: boolean, +): RuntimeEvent { + return Object.freeze({ + id: `${operation.operationId}_response`, + invocationId: operation.invocationId, + runId: operation.runId, + sessionId: callEvent.sessionId, + turnId: operation.turnId, + ts: Math.max(callEvent.ts, 0), + partial: false, + role: 'tool' as const, + author: 'tool' as const, + origin: callEvent.origin ?? ('provider' as const), + modelVisibility: callEvent.modelVisibility ?? ('visible' as const), + content: Object.freeze({ + kind: 'function_response' as const, + id: operation.providerToolCallId, + name: operation.toolName, + result, + ...(isError ? { isError: true } : {}), + }), + refs: Object.freeze({ + operationId: operation.operationId, + toolCallId: operation.providerToolCallId, + ...(callEvent.refs?.parentToolCallId + ? { parentToolCallId: callEvent.refs.parentToolCallId } + : {}), + ...(callEvent.refs?.parentOperationId + ? { parentOperationId: callEvent.refs.parentOperationId } + : {}), + }), + actions: Object.freeze({ stateDelta: Object.freeze({ durationMs: 0 }) }), + }); +} + +function coerceRecoveredResult(raw: unknown): unknown { + if (typeof raw === 'string') return Object.freeze({ kind: 'text' as const, text: raw }); + if (raw && typeof raw === 'object') { + const record = raw as Record; + if (typeof record.kind === 'string') return Object.freeze({ ...record }); + if (typeof record.text === 'string') { + return Object.freeze({ kind: 'text' as const, text: record.text }); + } + return Object.freeze({ kind: 'json' as const, value: Object.freeze({ ...record }) }); + } + return Object.freeze({ kind: 'text' as const, text: String(raw ?? '') }); +} + +function managedMutationMatchesReservation( + managed: NonNullable['toolDispatch']>['managedMutation'], + reservation: ManagedMutationReservationRecordV1, +): boolean { + return ( + managed?.protocol === 'managed_mutation_v1' && + managed.repositoryId === reservation.repositoryId && + managed.workspaceId === reservation.workspaceId && + managed.workspaceEpochId === reservation.workspaceEpochId && + managed.workspaceInstanceId === reservation.workspaceInstanceId && + managed.baseWorkspaceVersionId === reservation.baseWorkspaceVersionId && + managed.baseAcceptedEventId === reservation.baseAcceptedEventId && + managed.baseHeadRevision === reservation.baseHeadRevision && + managed.baseCommitOid === reservation.baseCommitOid && + managed.baseTreeOid === reservation.baseTreeOid && + managed.executionProfileDigest === reservation.executionProfileDigest && + managed.expectedPaths.length === reservation.expectedPaths.length && + managed.expectedPaths.every((path, index) => path === reservation.expectedPaths[index]) + ); +} + +function reservationMatchesHead( + reservation: ManagedMutationReservationRecordV1, + head: WorkspaceHeadRecordV1, +): boolean { + return ( + reservation.repositoryId === head.repositoryId && + reservation.workspaceId === head.workspaceId && + reservation.workspaceEpochId === head.workspaceEpochId && + reservation.baseWorkspaceVersionId === head.workspaceVersionId && + reservation.baseAcceptedEventId === head.acceptedEventId && + reservation.baseHeadRevision === head.revision && + reservation.baseCommitOid === head.commitOid && + reservation.baseTreeOid === head.treeOid && + reservation.executionProfileDigest === GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST + ); +} + +export async function reconcileGitoxideManagedMutationProjectionInternal(input: { + readonly workspaceId: string; + readonly workspaceEpochId: string; + readonly settlementAuthority: GitoxideManagedMutationSettlementAuthorityInternal; + readonly candidateAuthorityForHead: ( + head: WorkspaceHeadRecordV1, + ) => Promise; + readonly abortSignal?: AbortSignal; +}): Promise<'already_at_baseline' | 'promoted'> { + input.abortSignal?.throwIfAborted(); + const head = await input.settlementAuthority.readHead(input.workspaceId, input.workspaceEpochId); + if (!head) throw new Error('Gitoxide projection reconciliation has no accepted head'); + const version = await input.settlementAuthority.readVersion(head.workspaceVersionId); + if (!version || !versionMatchesHead(version, head)) { + throw new Error('Gitoxide projection reconciliation head is corrupt'); + } + if (version.protocol === 'workspace_baseline_accepted_v1') return 'already_at_baseline'; + if (version.parents.length !== 1 || version.baseHeadRevision + 1 !== head.revision) { + throw new Error('Gitoxide projection reconciliation successor ancestry is corrupt'); + } + const parent = await input.settlementAuthority.readVersion(version.parents[0]); + if (!parent || !parentMatchesSuccessor(parent, version)) { + throw new Error('Gitoxide projection reconciliation parent is corrupt'); + } + const parentHead: WorkspaceHeadRecordV1 = Object.freeze({ + repositoryId: parent.repositoryId, + workspaceId: parent.workspaceId, + workspaceEpochId: parent.workspaceEpochId, + workspaceVersionId: parent.workspaceVersionId, + acceptedEventId: parent.acceptedEventId, + commitOid: parent.commitOid, + treeOid: parent.treeOid, + revision: version.baseHeadRevision, + }); + const candidateAuthority = await input.candidateAuthorityForHead(parentHead); + const receipt = await candidateAuthority.promoteDurable( + version.origin.operationId, + input.abortSignal, + ); + assertPromotedReceipt(receipt, parentHead, version); + return 'promoted'; +} + +function canonicalPath(args: unknown): string { + if (!args || typeof args !== 'object' || Array.isArray(args)) { + throw new Error('Gitoxide managed mutation arguments are invalid'); + } + const path = (args as Record).path; + if (!isCanonicalManagedMutationPathV1(path)) { + throw new Error('Gitoxide managed mutation path must already be canonical'); + } + return path; +} + +function versionMatchesHead( + version: WorkspaceVersionRecordV1, + head: WorkspaceHeadRecordV1, +): boolean { + return ( + version.repositoryId === head.repositoryId && + version.workspaceId === head.workspaceId && + version.workspaceEpochId === head.workspaceEpochId && + version.workspaceVersionId === head.workspaceVersionId && + version.acceptedEventId === head.acceptedEventId && + version.commitOid === head.commitOid && + version.treeOid === head.treeOid + ); +} + +function parentMatchesSuccessor( + parent: WorkspaceVersionRecordV1, + successor: Extract, +): boolean { + return ( + parent.repositoryId === successor.repositoryId && + parent.workspaceId === successor.workspaceId && + parent.workspaceEpochId === successor.workspaceEpochId && + parent.workspaceVersionId === successor.parents[0] && + parent.acceptedEventId === successor.baseAcceptedEventId && + successor.baseHeadRevision >= 1 + ); +} + +function assertPromotedReceipt( + receipt: CandidateReceipt, + parent: WorkspaceHeadRecordV1, + successor: Extract, +): void { + if ( + receipt.repositoryId !== parent.repositoryId || + receipt.workspaceId !== parent.workspaceId || + receipt.workspaceEpochId !== parent.workspaceEpochId || + receipt.workspaceVersionId !== parent.workspaceVersionId || + receipt.baseAcceptedEventId !== parent.acceptedEventId || + receipt.baseHeadRevision !== parent.revision || + receipt.baseCommitOid !== parent.commitOid || + receipt.baseTreeOid !== parent.treeOid || + receipt.candidateCommitOid !== successor.commitOid || + receipt.candidateTreeOid !== successor.treeOid || + receipt.path !== successor.changedPaths[0] || + successor.changedPaths.length !== 1 || + receipt.executionProfileDigest !== successor.executionProfileDigest + ) { + throw new Error('Gitoxide durable candidate conflicts with the accepted successor'); + } +} + +function assertCandidateReceipt( + receipt: CandidateReceipt, + head: WorkspaceHeadRecordV1, + path: string, + content: string, +): void { + if ( + receipt.repositoryId !== head.repositoryId || + receipt.workspaceId !== head.workspaceId || + receipt.workspaceEpochId !== head.workspaceEpochId || + receipt.workspaceVersionId !== head.workspaceVersionId || + receipt.baseAcceptedEventId !== head.acceptedEventId || + receipt.baseHeadRevision !== head.revision || + receipt.baseCommitOid !== head.commitOid || + receipt.baseTreeOid !== head.treeOid || + receipt.path !== path || + receipt.contentSha256 !== sha256(content) || + receipt.executionProfileDigest !== GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST + ) { + throw new Error('Gitoxide candidate receipt conflicts with the admitted operation'); + } +} + +function successorInput(input: { + readonly operationId: string; + readonly outcomeEventId: string; + readonly outcomeTimestamp: number; + readonly version: WorkspaceVersionRecordV1; + readonly head: WorkspaceHeadRecordV1; + readonly receipt: CandidateReceipt; +}): WorkspaceSuccessorAuthorityInput { + const identity = digest( + 'accepted-successor', + input.operationId, + input.receipt.candidateCommitOid, + ); + return { + acceptedEventId: `workspace-successor-${identity}`, + committedAt: input.outcomeTimestamp, + successor: { + repositoryId: input.head.repositoryId, + workspaceId: input.head.workspaceId, + workspaceEpochId: input.head.workspaceEpochId, + workspaceVersionId: `version_${identity}`, + objectFormat: 'sha1', + parentWorkspaceVersionId: input.head.workspaceVersionId, + baseAcceptedEventId: input.head.acceptedEventId, + baseHeadRevision: input.head.revision, + commitOid: input.receipt.candidateCommitOid, + treeOid: input.receipt.candidateTreeOid, + policyHash: input.version.policyHash, + treeDeltaDigest: sha256( + `gitoxide-tree-delta-v1\0${input.head.treeOid}\0${input.receipt.candidateTreeOid}\0${input.receipt.path}\0${input.receipt.resultBlobOid}`, + ), + changedPaths: Object.freeze([input.receipt.path]), + changedFileCount: 1, + deletedFileCount: 0, + executionProfileDigest: GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST, + }, + origin: { + operationId: input.operationId, + dispatchEventId: `${input.operationId}_dispatch`, + outcomeEventId: input.outcomeEventId, + }, + }; +} + +function digest(domain: string, ...values: readonly string[]): string { + const hash = createHash('sha256').update(`maka-${domain}-v1\0`, 'utf8'); + for (const value of values) hash.update(value).update('\0'); + return hash.digest('hex').slice(0, 32); +} + +function sha256(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; +} diff --git a/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts b/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts new file mode 100644 index 0000000000..c171f20149 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts @@ -0,0 +1,580 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { workspaceMutationPolicyHashV1 } from '@maka/core/workspace-version-authority'; +import { GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST } from '@maka/runtime/managed-mutation-transform'; +import type { ToolRuntimeInput } from '@maka/runtime/tool-runtime'; +import type { WorkspaceHeadRecordV1 } from '@maka/core/workspace-version-authority'; +import type { ExecutionStoresWorkspaceMutationAuthorityInternal } from '@maka/storage/execution-stores-workspace-authority-internal'; +import { runWithStorageRootLease, type StorageRootLease } from '@maka/storage/root-authority'; +import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artifact-authority-internal.js'; +import { verifyGitoxideHelperArtifactForInvocationInternal } from './gitoxide-helper-artifact-authority-internal.js'; +import { + importSourceHeadWithGitoxideHelperInternal, + inspectManagedRefWithGitoxideHelperInternal, +} from './gitoxide-helper-invocation-internal.js'; +import { + admitGitoxideRepositoryInternal, + requireGitoxideRepositoryAdmissionInternal, +} from './gitoxide-repository-admission-authority-internal.js'; +import { + createGitoxideMutationCandidateAuthorityInternal, + gitoxideManagedRepositoryPathInternal, +} from './gitoxide-helper-mutation-candidate-authority-internal.js'; +import { + createGitoxideManagedMutationAdmissionInternal, + reconcilePreparedGitoxideManagedMutationInternal, + type GitoxideManagedMutationAdmissionFailpoint, + reconcileGitoxideManagedMutationProjectionInternal, +} from './gitoxide-managed-mutation-admission.js'; +import type { GitoxideInspectionRepositoryProviderInternal } from './gitoxide-managed-inspection.js'; + +const ACCEPTED_REF = 'refs/maka/accepted'; +const RECEIPT_PROTOCOL = 'maka_gitoxide_managed_mutation_baseline_v1'; + +interface BaselineIntentV1 { + readonly schemaVersion: 1; + readonly protocol: 'maka_gitoxide_managed_mutation_baseline_intent_v1'; + readonly sourceRoot: string; + readonly repositoryId: string; + readonly workspaceId: string; + readonly workspaceEpochId: string; + readonly workspaceInstanceId: string; + readonly workspaceVersionId: string; + readonly sourceCommitOid: string; + readonly sourceTreeOid: string; + readonly helperArtifactSha256: `sha256:${string}`; +} + +interface BaselineReceiptV1 extends Omit { + readonly protocol: typeof RECEIPT_PROTOCOL; + readonly baselineCommitOid: string; + readonly baselineTreeOid: string; + readonly filesImported: number; + readonly bytesImported: number; +} + +export interface GitoxideManagedMutationSession { + readonly head: WorkspaceHeadRecordV1; + readonly admitManagedMutation: NonNullable; + readonly inspectionRepositoryProvider: GitoxideInspectionRepositoryProviderInternal; + readonly reconcileProjection: (abortSignal?: AbortSignal) => Promise; +} + +export type GitoxideManagedMutationRecoveryGate = + | { readonly kind: 'settled' } + | { readonly kind: 'no_active_mutation' } + | { readonly kind: 'parked'; readonly reason: string }; + +/** + * Runs before generic AgentRun recovery is allowed to append a terminal fact. + * It deliberately proves the absence of a reservation without opening a new + * baseline, and fails closed when an active T1 cannot be reconciled. + */ +export async function recoverGitoxideManagedMutationBeforeRunClosureInternal(input: { + readonly storageRootLease: StorageRootLease<'interactive', 'write'>; + readonly sourceRoot: string; + readonly sessionId: string; + readonly settlementAuthority: ExecutionStoresWorkspaceMutationAuthorityInternal; + readonly invocationOwnerToken?: object; + readonly helperCapability?: GitoxideHelperInvocationCapability; + readonly abortSignal?: AbortSignal; +}): Promise { + try { + input.abortSignal?.throwIfAborted(); + const sourceRoot = await realpath(input.sourceRoot); + const identity = managedMutationIdentity(sourceRoot, input.sessionId); + input.settlementAuthority.adoptRootForManagedExecution(); + const active = await input.settlementAuthority.readActiveManagedMutation( + identity.workspaceInstanceId, + ); + if (!active) return Object.freeze({ kind: 'no_active_mutation' as const }); + if (!input.invocationOwnerToken || !input.helperCapability) { + return Object.freeze({ + kind: 'parked' as const, + reason: 'Gitoxide managed mutation recovery capability is unavailable', + }); + } + await openGitoxideManagedMutationSession({ + storageRootLease: input.storageRootLease, + sourceRoot, + sessionId: input.sessionId, + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + settlementAuthority: input.settlementAuthority, + ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), + }); + return Object.freeze({ kind: 'settled' as const }); + } catch (error) { + return Object.freeze({ + kind: 'parked' as const, + reason: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * Opens one explicit managed-coding session. The source observation is frozen + * before import, Gitoxide owns the immutable repository, SQLite owns accepted + * versions, and Runtime owns each operation result. + */ +export async function openGitoxideManagedMutationSession(input: { + readonly storageRootLease: StorageRootLease<'interactive', 'write'>; + readonly sourceRoot: string; + readonly sessionId: string; + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly settlementAuthority: ExecutionStoresWorkspaceMutationAuthorityInternal; + readonly abortSignal?: AbortSignal; + readonly failpoint?: (point: GitoxideManagedMutationAdmissionFailpoint) => void | Promise; +}): Promise { + input.abortSignal?.throwIfAborted(); + const [storageRoot, sourceRoot, helper] = await Promise.all([ + runWithStorageRootLease(input.storageRootLease, 'interactive', 'write', async (root) => root), + realpath(input.sourceRoot), + verifyGitoxideHelperArtifactForInvocationInternal( + input.invocationOwnerToken, + input.helperCapability, + ), + ]); + const identity = managedMutationIdentity(sourceRoot, input.sessionId); + const materializationProfileDigest = sha256( + `maka-gitoxide-materialization-v1\0${helper.artifactSha256}\0`, + ); + const workspacePolicyHash = workspaceMutationPolicyHashV1( + materializationProfileDigest, + GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST, + ); + const repositoryPath = gitoxideManagedRepositoryPathInternal(storageRoot, identity); + const controlRoot = dirname(repositoryPath); + const intentPath = join(controlRoot, 'baseline-intent.json'); + const receiptPath = join(controlRoot, 'baseline-receipt.json'); + await mkdir(controlRoot, { recursive: true }); + input.settlementAuthority.adoptRootForManagedExecution(); + + let head = await input.settlementAuthority.readHead( + identity.workspaceId, + identity.workspaceEpochId, + ); + let receipt = await readBaselineReceipt(receiptPath); + if (!head) { + let intent = await readBaselineIntent(intentPath); + if (!intent) { + const admissionOwnerToken = {}; + const admitted = await admitGitoxideRepositoryInternal({ + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + admissionOwnerToken, + repositoryPath: sourceRoot, + abortSignal: input.abortSignal, + }); + if (admitted.kind !== 'accepted') { + throw new Error(`Gitoxide managed coding rejected source: ${admitted.reason}`); + } + const observed = requireGitoxideRepositoryAdmissionInternal( + admissionOwnerToken, + admitted.capability, + ); + intent = freezeIntent({ + schemaVersion: 1, + protocol: 'maka_gitoxide_managed_mutation_baseline_intent_v1', + sourceRoot, + ...identity, + sourceCommitOid: observed.headCommitOid, + sourceTreeOid: observed.headTreeOid, + helperArtifactSha256: helper.artifactSha256, + }); + await writeJsonAtomic(intentPath, intent); + } + assertIntent(intent, sourceRoot, identity, helper.artifactSha256); + if (!receipt) { + const admissionOwnerToken = {}; + const admitted = await admitGitoxideRepositoryInternal({ + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + admissionOwnerToken, + repositoryPath: sourceRoot, + abortSignal: input.abortSignal, + }); + if (admitted.kind !== 'accepted') { + throw new Error(`Gitoxide managed coding rejected source: ${admitted.reason}`); + } + const observed = requireGitoxideRepositoryAdmissionInternal( + admissionOwnerToken, + admitted.capability, + ); + if ( + observed.headCommitOid !== intent.sourceCommitOid || + observed.headTreeOid !== intent.sourceTreeOid + ) { + throw new Error('Gitoxide source changed before its baseline became durable'); + } + const imported = await importSourceHeadWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + sourceRepositoryPath: sourceRoot, + expectedSourceHeadCommitOid: intent.sourceCommitOid, + destinationRepositoryPath: repositoryPath, + baselineRef: ACCEPTED_REF, + abortSignal: input.abortSignal, + }); + receipt = freezeReceipt({ + ...intent, + protocol: RECEIPT_PROTOCOL, + baselineCommitOid: imported.baselineCommitOid, + baselineTreeOid: imported.baselineTreeOid, + filesImported: imported.filesImported, + bytesImported: imported.bytesImported, + }); + await writeJsonAtomic(receiptPath, receipt); + } + assertReceipt(receipt, intent); + await verifyAcceptedRef( + input, + repositoryPath, + receipt.baselineCommitOid, + receipt.baselineTreeOid, + ); + const committed = await input.settlementAuthority.commitBaseline({ + epochOpenedEventId: `workspace-epoch-${digest('epoch-event', identity.workspaceEpochId)}`, + baselineAcceptedEventId: `workspace-baseline-${digest('baseline-event', identity.workspaceEpochId)}`, + committedAt: 0, + epoch: { + repositoryId: identity.repositoryId, + workspaceId: identity.workspaceId, + workspaceEpochId: identity.workspaceEpochId, + workspaceInstanceId: identity.workspaceInstanceId, + mode: 'managed_worktree', + objectFormat: 'sha1', + sourceCommitOid: receipt.sourceCommitOid, + sourceTreeOid: receipt.sourceTreeOid, + materializationProfileDigest, + materializationSemantics: 'git_tree_materialized_with_fixed_config_v1', + policyHash: workspacePolicyHash, + }, + baseline: { + workspaceVersionId: receipt.workspaceVersionId, + commitOid: receipt.baselineCommitOid, + treeOid: receipt.baselineTreeOid, + treeDeltaDigest: sha256(`maka-gitoxide-baseline-tree-v1\0${receipt.baselineTreeOid}\0`), + changedFileCount: receipt.filesImported, + deletedFileCount: 0, + }, + }); + head = committed.head; + } + if (!receipt) throw new Error('Gitoxide managed coding baseline receipt is unavailable'); + if (receipt.helperArtifactSha256 !== helper.artifactSha256) { + throw new Error('Gitoxide managed coding helper identity changed for this workspace epoch'); + } + const baselineVersion = await input.settlementAuthority.readVersion(receipt.workspaceVersionId); + if ( + !baselineVersion || + baselineVersion.protocol !== 'workspace_baseline_accepted_v1' || + baselineVersion.repositoryId !== receipt.repositoryId || + baselineVersion.workspaceId !== receipt.workspaceId || + baselineVersion.workspaceEpochId !== receipt.workspaceEpochId || + baselineVersion.commitOid !== receipt.baselineCommitOid || + baselineVersion.treeOid !== receipt.baselineTreeOid || + baselineVersion.policyHash !== workspacePolicyHash + ) { + throw new Error('Gitoxide baseline receipt conflicts with accepted baseline authority'); + } + + const candidateAuthorityForHead = (baseHead: WorkspaceHeadRecordV1) => + createGitoxideMutationCandidateAuthorityInternal({ + storageRootLease: input.storageRootLease, + baseHead, + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + }); + const activeReservation = await input.settlementAuthority.readActiveManagedMutation( + identity.workspaceInstanceId, + ); + if (activeReservation) { + const operation = await input.settlementAuthority.readToolOperation( + activeReservation.operationId, + ); + if (!operation) { + throw new Error('Active managed mutation reservation has no durable tool operation'); + } + const runtimeEvents = await input.settlementAuthority.readRuntimeEvents( + input.sessionId, + operation.runId, + ); + await reconcilePreparedGitoxideManagedMutationInternal({ + sessionId: input.sessionId, + reservation: activeReservation, + operation, + runtimeEvents, + settlementAuthority: input.settlementAuthority, + candidateAuthorityForHead, + abortSignal: input.abortSignal, + }); + } + await reconcileGitoxideManagedMutationProjectionInternal({ + workspaceId: identity.workspaceId, + workspaceEpochId: identity.workspaceEpochId, + settlementAuthority: input.settlementAuthority, + candidateAuthorityForHead, + abortSignal: input.abortSignal, + }); + head = await input.settlementAuthority.readHead(identity.workspaceId, identity.workspaceEpochId); + if (!head) throw new Error('Gitoxide managed coding lost its accepted workspace head'); + await verifyAcceptedRef(input, repositoryPath, head.commitOid, head.treeOid); + const admitManagedMutation = createGitoxideManagedMutationAdmissionInternal({ + workspaceInstanceId: identity.workspaceInstanceId, + workspaceId: identity.workspaceId, + workspaceEpochId: identity.workspaceEpochId, + settlementAuthority: input.settlementAuthority, + candidateAuthorityForHead, + failpoint: input.failpoint, + }); + return Object.freeze({ + head, + admitManagedMutation, + inspectionRepositoryProvider: async ({ + abortSignal, + }: { + readonly sourceCwd: string; + readonly repositoryPath: string; + readonly abortSignal: AbortSignal; + }) => { + abortSignal.throwIfAborted(); + const currentHead = await input.settlementAuthority.readHead( + identity.workspaceId, + identity.workspaceEpochId, + ); + if (!currentHead) throw new Error('Gitoxide managed inspection has no accepted head'); + const currentVersion = await input.settlementAuthority.readVersion( + currentHead.workspaceVersionId, + ); + if ( + !currentVersion || + currentVersion.repositoryId !== currentHead.repositoryId || + currentVersion.workspaceId !== currentHead.workspaceId || + currentVersion.workspaceEpochId !== currentHead.workspaceEpochId || + currentVersion.workspaceVersionId !== currentHead.workspaceVersionId || + currentVersion.acceptedEventId !== currentHead.acceptedEventId || + currentVersion.commitOid !== currentHead.commitOid || + currentVersion.treeOid !== currentHead.treeOid || + currentVersion.policyHash !== workspacePolicyHash + ) { + throw new Error('Gitoxide managed inspection head conflicts with workspace authority'); + } + const authority = await candidateAuthorityForHead(currentHead); + return Object.freeze({ + acceptedCommitOid: currentHead.commitOid, + acceptedTreeOid: currentHead.treeOid, + async readFile(path: string, signal: AbortSignal) { + const file = await authority.readBaseFile(path, signal); + if (!file) throw new Error(`Managed accepted tree file is unavailable: ${path}`); + return Object.freeze({ path, content: file.content }); + }, + materializeProjection(destinationPath: string, signal: AbortSignal) { + return authority.materializeBaseProjection(destinationPath, signal); + }, + }); + }, + reconcileProjection: async (abortSignal?: AbortSignal) => { + await reconcileGitoxideManagedMutationProjectionInternal({ + workspaceId: identity.workspaceId, + workspaceEpochId: identity.workspaceEpochId, + settlementAuthority: input.settlementAuthority, + candidateAuthorityForHead, + abortSignal, + }); + }, + }); +} + +async function verifyAcceptedRef( + input: Pick< + Parameters[0], + 'invocationOwnerToken' | 'helperCapability' | 'abortSignal' + >, + repositoryPath: string, + expectedCommitOid: string, + expectedTreeOid: string, +): Promise { + const observed = await inspectManagedRefWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + repositoryPath, + targetRef: ACCEPTED_REF, + abortSignal: input.abortSignal, + }); + if (observed.commitOid !== expectedCommitOid || observed.treeOid !== expectedTreeOid) { + throw new Error('Gitoxide managed coding repository conflicts with its baseline receipt'); + } +} + +function managedMutationIdentity(sourceRoot: string, sessionId: string) { + return Object.freeze({ + repositoryId: `repository_${digest('repository', sourceRoot)}`, + workspaceId: `workspace_${digest('workspace', sourceRoot, sessionId)}`, + workspaceEpochId: `epoch_${digest('epoch', sourceRoot, sessionId)}`, + workspaceInstanceId: `instance_${digest('instance', sourceRoot, sessionId)}`, + workspaceVersionId: `version_${digest('version', sourceRoot, sessionId)}`, + }); +} + +function digest(domain: string, ...values: readonly string[]): string { + const hash = createHash('sha256').update(`maka-gitoxide-${domain}-v1\0`, 'utf8'); + for (const value of values) hash.update(value, 'utf8').update('\0', 'utf8'); + return hash.digest('hex').slice(0, 32); +} + +function sha256(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; +} + +function freezeIntent(value: BaselineIntentV1): BaselineIntentV1 { + return Object.freeze({ ...value }); +} + +function freezeReceipt(value: BaselineReceiptV1): BaselineReceiptV1 { + return Object.freeze({ ...value }); +} + +async function readBaselineIntent(path: string): Promise { + return readJson(path, isBaselineIntent, freezeIntent); +} + +async function readBaselineReceipt(path: string): Promise { + return readJson(path, isBaselineReceipt, freezeReceipt); +} + +async function readJson( + path: string, + validate: (value: unknown) => value is T, + freeze: (value: T) => T, +): Promise { + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw new Error(`Invalid Gitoxide durable JSON at ${path}`); + } + if (!validate(value)) throw new Error(`Invalid Gitoxide durable record at ${path}`); + return freeze(value); +} + +async function writeJsonAtomic(path: string, value: unknown): Promise { + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(value)}\n`, { encoding: 'utf8', flag: 'wx' }); + try { + await rename(temporaryPath, path); + } catch (error) { + const existing = await readFile(path, 'utf8').catch(() => undefined); + await rm(temporaryPath, { force: true }); + if (existing === undefined || !isDeepStrictEqual(JSON.parse(existing), value)) throw error; + } +} + +function assertIntent( + intent: BaselineIntentV1, + sourceRoot: string, + identity: ReturnType, + helperArtifactSha256: `sha256:${string}`, +): void { + if ( + intent.sourceRoot !== sourceRoot || + intent.repositoryId !== identity.repositoryId || + intent.workspaceId !== identity.workspaceId || + intent.workspaceEpochId !== identity.workspaceEpochId || + intent.workspaceInstanceId !== identity.workspaceInstanceId || + intent.workspaceVersionId !== identity.workspaceVersionId || + intent.helperArtifactSha256 !== helperArtifactSha256 + ) { + throw new Error('Gitoxide baseline intent conflicts with this managed session'); + } +} + +function assertReceipt(receipt: BaselineReceiptV1, intent: BaselineIntentV1): void { + if ( + receipt.sourceRoot !== intent.sourceRoot || + receipt.repositoryId !== intent.repositoryId || + receipt.workspaceId !== intent.workspaceId || + receipt.workspaceEpochId !== intent.workspaceEpochId || + receipt.workspaceInstanceId !== intent.workspaceInstanceId || + receipt.workspaceVersionId !== intent.workspaceVersionId || + receipt.sourceCommitOid !== intent.sourceCommitOid || + receipt.sourceTreeOid !== intent.sourceTreeOid || + receipt.helperArtifactSha256 !== intent.helperArtifactSha256 + ) { + throw new Error('Gitoxide baseline receipt conflicts with its durable intent'); + } +} + +function isBaselineIntent(value: unknown): value is BaselineIntentV1 { + return isBaselineRecord(value, 'maka_gitoxide_managed_mutation_baseline_intent_v1'); +} + +function isBaselineReceipt(value: unknown): value is BaselineReceiptV1 { + if (!isBaselineRecord(value, RECEIPT_PROTOCOL)) return false; + const record = value as Record; + return ( + typeof record.baselineCommitOid === 'string' && + /^[0-9a-f]{40}$/u.test(record.baselineCommitOid) && + typeof record.baselineTreeOid === 'string' && + /^[0-9a-f]{40}$/u.test(record.baselineTreeOid) && + Number.isSafeInteger(record.filesImported) && + (record.filesImported as number) >= 0 && + Number.isSafeInteger(record.bytesImported) && + (record.bytesImported as number) >= 0 + ); +} + +function isBaselineRecord( + value: unknown, + protocol: 'maka_gitoxide_managed_mutation_baseline_intent_v1' | typeof RECEIPT_PROTOCOL, +): value is BaselineIntentV1 & Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + const expectedKeys = protocol === RECEIPT_PROTOCOL ? 15 : 11; + return ( + Object.keys(record).length === expectedKeys && + record.schemaVersion === 1 && + record.protocol === protocol && + typeof record.sourceRoot === 'string' && + typeof record.repositoryId === 'string' && + typeof record.workspaceId === 'string' && + typeof record.workspaceEpochId === 'string' && + typeof record.workspaceInstanceId === 'string' && + typeof record.workspaceVersionId === 'string' && + typeof record.sourceCommitOid === 'string' && + /^[0-9a-f]{40}$/u.test(record.sourceCommitOid) && + typeof record.sourceTreeOid === 'string' && + /^[0-9a-f]{40}$/u.test(record.sourceTreeOid) && + typeof record.helperArtifactSha256 === 'string' && + /^sha256:[0-9a-f]{64}$/u.test(record.helperArtifactSha256) + ); +} diff --git a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts new file mode 100644 index 0000000000..d934350e8a --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -0,0 +1,537 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { realpath } from 'node:fs/promises'; +import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artifact-authority-internal.js'; +import { + importSourceHeadWithGitoxideHelperInternal, + inspectManagedRefWithGitoxideHelperInternal, + inspectRepositoryWithGitoxideHelperInternal, + createSuccessorWithGitoxideHelperInternal, + prepareCandidateWithGitoxideHelperInternal, + materializeProjectionWithGitoxideHelperInternal, + observeProjectionWithGitoxideHelperInternal, + readTreeFileWithGitoxideHelperInternal, + type GitoxideProjectionMaterializedV1, + type GitoxideProjectionObservationV1, + type GitoxideSuccessorPublishedV1, + type GitoxideSourceImportObservationV1, + type GitoxideTreeFileReadV1, + type GitoxideRepositoryRejectionV1, +} from './gitoxide-helper-invocation-internal.js'; + +export interface GitoxideRepositoryAdmissionCapability { + readonly kind: 'gitoxide_repository_admission_capability_v1'; +} + +export interface GitoxideManagedRepositoryCapability { + readonly kind: 'gitoxide_managed_repository_capability_v1'; +} + +export interface GitoxideMutationCandidateCapability { + readonly kind: 'gitoxide_mutation_candidate_capability_v1'; +} + +export interface GitoxideProjectionCapability { + readonly kind: 'gitoxide_projection_capability_v1'; +} + +export interface GitoxideManagedRepositoryImportResultV1 extends GitoxideSourceImportObservationV1 { + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; +} + +export interface GitoxideManagedRepositorySuccessorResultV1 extends GitoxideSuccessorPublishedV1 { + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; +} + +export interface GitoxideMutationCandidatePreparedV1 extends GitoxideSuccessorPublishedV1 { + readonly candidateRef: string; + readonly candidateCapability: GitoxideMutationCandidateCapability; +} + +export interface GitoxideMutationCandidateStateInternal { + readonly protocolVersion: 1; + readonly repositoryPath: string; + readonly acceptedRef: string; + readonly candidateRef: string; + readonly baseCommitOid: string; + readonly candidateCommitOid: string; + readonly candidateTreeOid: string; + readonly resultBlobOid: string; + readonly path: string; +} + +export interface GitoxideProjectionMaterializationResultV1 + extends GitoxideProjectionMaterializedV1 { + readonly projectionCapability: GitoxideProjectionCapability; +} + +export interface GitoxideRepositoryAdmissionStateInternal { + readonly protocolVersion: 1; + readonly repositoryPath: string; + readonly objectFormat: 'sha1'; + readonly headCommitOid: string; + readonly headTreeOid: string; +} + +export type GitoxideRepositoryAdmissionResultV1 = + | { + readonly kind: 'accepted'; + readonly capability: GitoxideRepositoryAdmissionCapability; + } + | GitoxideRepositoryRejectionV1; + +export class GitoxideRepositoryAdmissionAuthorityError extends Error { + constructor( + readonly code: + | 'gitoxide_repository_admission_capability_invalid' + | 'gitoxide_managed_repository_base_mismatch' + | 'gitoxide_mutation_candidate_capability_invalid', + ) { + super( + code === 'gitoxide_managed_repository_base_mismatch' + ? 'Gitoxide managed repository base no longer matches' + : code === 'gitoxide_mutation_candidate_capability_invalid' + ? 'Gitoxide mutation candidate capability is invalid' + : 'Gitoxide repository admission capability is invalid', + ); + this.name = 'GitoxideRepositoryAdmissionAuthorityError'; + } +} + +interface AdmissionCapabilityRecord { + readonly admissionOwnerToken: object; + readonly state: GitoxideRepositoryAdmissionStateInternal; +} + +const admissions = new WeakMap(); + +interface ManagedRepositoryCapabilityRecord { + readonly managedRepositoryOwnerToken: object; + readonly repositoryPath: string; + readonly acceptedRef: string; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; +} + +const managedRepositories = new WeakMap(); + +interface MutationCandidateCapabilityRecord { + readonly candidateOwnerToken: object; + readonly state: GitoxideMutationCandidateStateInternal; +} + +const mutationCandidates = new WeakMap(); + +interface ProjectionCapabilityRecord { + readonly projectionOwnerToken: object; + readonly repositoryPath: string; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; + readonly projectionPath: string; +} + +const projections = new WeakMap(); + +export async function admitGitoxideRepositoryInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly admissionOwnerToken: object; + readonly repositoryPath: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const repositoryPath = await realpath(input.repositoryPath); + const observation = await inspectRepositoryWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + repositoryPath, + abortSignal: input.abortSignal, + }); + if (observation.kind === 'repository_rejected') return observation; + + const capability = Object.freeze({ + kind: 'gitoxide_repository_admission_capability_v1' as const, + }); + admissions.set( + capability, + Object.freeze({ + admissionOwnerToken: input.admissionOwnerToken, + state: Object.freeze({ + protocolVersion: observation.protocolVersion, + repositoryPath, + objectFormat: observation.objectFormat, + headCommitOid: observation.headCommitOid, + headTreeOid: observation.headTreeOid, + }), + }), + ); + return Object.freeze({ kind: 'accepted' as const, capability }); +} + +export function requireGitoxideRepositoryAdmissionInternal( + admissionOwnerToken: object, + capability: GitoxideRepositoryAdmissionCapability, +): GitoxideRepositoryAdmissionStateInternal { + const state = admissions.get(capability); + if (!state || state.admissionOwnerToken !== admissionOwnerToken) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + return state.state; +} + +export async function importAdmittedGitoxideRepositoryInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly admissionOwnerToken: object; + readonly repositoryCapability: GitoxideRepositoryAdmissionCapability; + readonly managedRepositoryOwnerToken: object; + readonly destinationRepositoryPath: string; + readonly baselineRef: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const source = requireGitoxideRepositoryAdmissionInternal( + input.admissionOwnerToken, + input.repositoryCapability, + ); + const result = await importSourceHeadWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + sourceRepositoryPath: source.repositoryPath, + expectedSourceHeadCommitOid: source.headCommitOid, + destinationRepositoryPath: input.destinationRepositoryPath, + baselineRef: input.baselineRef, + abortSignal: input.abortSignal, + }); + if ( + result.sourceHeadCommitOid !== source.headCommitOid || + result.sourceTreeOid !== source.headTreeOid + ) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + const managedRepositoryCapability = issueManagedRepositoryCapability({ + managedRepositoryOwnerToken: input.managedRepositoryOwnerToken, + repositoryPath: input.destinationRepositoryPath, + acceptedRef: result.baselineRef, + acceptedCommitOid: result.baselineCommitOid, + acceptedTreeOid: result.baselineTreeOid, + }); + return Object.freeze({ ...result, managedRepositoryCapability }); +} + +export async function reopenGitoxideManagedRepositoryInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly managedRepositoryOwnerToken: object; + readonly repositoryPath: string; + readonly acceptedRef: string; + readonly expectedAcceptedCommitOid: string; + readonly expectedAcceptedTreeOid: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const repositoryPath = await realpath(input.repositoryPath); + const observation = await inspectManagedRefWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + repositoryPath, + targetRef: input.acceptedRef, + abortSignal: input.abortSignal, + }); + if ( + observation.targetRef !== input.acceptedRef || + observation.commitOid !== input.expectedAcceptedCommitOid || + observation.treeOid !== input.expectedAcceptedTreeOid + ) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_managed_repository_base_mismatch', + ); + } + return issueManagedRepositoryCapability({ + managedRepositoryOwnerToken: input.managedRepositoryOwnerToken, + repositoryPath, + acceptedRef: observation.targetRef, + acceptedCommitOid: observation.commitOid, + acceptedTreeOid: observation.treeOid, + }); +} + +export async function createGitoxideSuccessorInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly managedRepositoryOwnerToken: object; + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; + readonly path: string; + readonly content: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const managed = requireManagedRepositoryCapability( + input.managedRepositoryOwnerToken, + input.managedRepositoryCapability, + ); + const result = await createSuccessorWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + repositoryPath: managed.repositoryPath, + expectedBaseCommitOid: managed.acceptedCommitOid, + targetRef: managed.acceptedRef, + path: input.path, + content: input.content, + abortSignal: input.abortSignal, + }); + if (result.kind === 'successor_rejected') { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_managed_repository_base_mismatch', + ); + } + if ( + result.baseCommitOid !== managed.acceptedCommitOid || + result.targetRef !== managed.acceptedRef + ) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + const managedRepositoryCapability = issueManagedRepositoryCapability({ + managedRepositoryOwnerToken: input.managedRepositoryOwnerToken, + repositoryPath: managed.repositoryPath, + acceptedRef: managed.acceptedRef, + acceptedCommitOid: result.successorCommitOid, + acceptedTreeOid: result.successorTreeOid, + }); + return Object.freeze({ ...result, managedRepositoryCapability }); +} + +export async function prepareGitoxideMutationCandidateInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly managedRepositoryOwnerToken: object; + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; + readonly candidateOwnerToken: object; + readonly operationId: string; + readonly path: string; + readonly content: string; + readonly abortSignal?: AbortSignal; +}): Promise { + if (input.operationId.length === 0 || input.operationId.length > 1024) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_mutation_candidate_capability_invalid', + ); + } + const managed = requireManagedRepositoryCapability( + input.managedRepositoryOwnerToken, + input.managedRepositoryCapability, + ); + const candidateRef = `refs/maka/candidates/${createHash('sha256') + .update(input.operationId) + .digest('hex') + .slice(0, 32)}`; + const result = await prepareCandidateWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + repositoryPath: managed.repositoryPath, + expectedBaseCommitOid: managed.acceptedCommitOid, + acceptedRef: managed.acceptedRef, + candidateRef, + path: input.path, + content: input.content, + abortSignal: input.abortSignal, + }); + if (result.kind === 'successor_rejected') { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_mutation_candidate_capability_invalid', + ); + } + if ( + result.baseCommitOid !== managed.acceptedCommitOid || + result.targetRef !== candidateRef || + result.path !== input.path + ) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_mutation_candidate_capability_invalid', + ); + } + const state = Object.freeze({ + protocolVersion: 1 as const, + repositoryPath: managed.repositoryPath, + acceptedRef: managed.acceptedRef, + candidateRef, + baseCommitOid: result.baseCommitOid, + candidateCommitOid: result.successorCommitOid, + candidateTreeOid: result.successorTreeOid, + resultBlobOid: result.resultBlobOid, + path: result.path, + }); + const candidateCapability = Object.freeze({ + kind: 'gitoxide_mutation_candidate_capability_v1' as const, + }); + mutationCandidates.set( + candidateCapability, + Object.freeze({ candidateOwnerToken: input.candidateOwnerToken, state }), + ); + return Object.freeze({ ...result, candidateRef, candidateCapability }); +} + +export function requireGitoxideMutationCandidateInternal( + candidateOwnerToken: object, + capability: GitoxideMutationCandidateCapability, +): GitoxideMutationCandidateStateInternal { + const record = mutationCandidates.get(capability); + if (!record || record.candidateOwnerToken !== candidateOwnerToken) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_mutation_candidate_capability_invalid', + ); + } + return record.state; +} + +export async function materializeGitoxideProjectionInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly managedRepositoryOwnerToken: object; + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; + readonly projectionOwnerToken: object; + readonly destinationPath: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const managed = requireManagedRepositoryCapability( + input.managedRepositoryOwnerToken, + input.managedRepositoryCapability, + ); + const result = await materializeProjectionWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + repositoryPath: managed.repositoryPath, + acceptedCommitOid: managed.acceptedCommitOid, + destinationPath: input.destinationPath, + abortSignal: input.abortSignal, + }); + if ( + result.acceptedCommitOid !== managed.acceptedCommitOid || + result.acceptedTreeOid !== managed.acceptedTreeOid + ) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + const projectionCapability = Object.freeze({ + kind: 'gitoxide_projection_capability_v1' as const, + }); + projections.set( + projectionCapability, + Object.freeze({ + projectionOwnerToken: input.projectionOwnerToken, + repositoryPath: managed.repositoryPath, + acceptedCommitOid: managed.acceptedCommitOid, + acceptedTreeOid: managed.acceptedTreeOid, + projectionPath: result.destinationPath, + }), + ); + return Object.freeze({ ...result, projectionCapability }); +} + +export async function observeGitoxideProjectionInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly projectionOwnerToken: object; + readonly projectionCapability: GitoxideProjectionCapability; + readonly abortSignal?: AbortSignal; +}): Promise { + const projection = projections.get(input.projectionCapability); + if (!projection || projection.projectionOwnerToken !== input.projectionOwnerToken) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + const result = await observeProjectionWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + repositoryPath: projection.repositoryPath, + acceptedCommitOid: projection.acceptedCommitOid, + projectionPath: projection.projectionPath, + abortSignal: input.abortSignal, + }); + if ( + result.acceptedCommitOid !== projection.acceptedCommitOid || + result.acceptedTreeOid !== projection.acceptedTreeOid || + result.projectionPath !== projection.projectionPath + ) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + return result; +} + +export async function readGitoxideTreeFileInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly managedRepositoryOwnerToken: object; + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; + readonly path: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const managed = requireManagedRepositoryCapability( + input.managedRepositoryOwnerToken, + input.managedRepositoryCapability, + ); + const result = await readTreeFileWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + repositoryPath: managed.repositoryPath, + acceptedCommitOid: managed.acceptedCommitOid, + path: input.path, + abortSignal: input.abortSignal, + }); + if ( + result.acceptedCommitOid !== managed.acceptedCommitOid || + result.acceptedTreeOid !== managed.acceptedTreeOid + ) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + return result; +} + +function issueManagedRepositoryCapability( + record: ManagedRepositoryCapabilityRecord, +): GitoxideManagedRepositoryCapability { + const capability = Object.freeze({ + kind: 'gitoxide_managed_repository_capability_v1' as const, + }); + managedRepositories.set(capability, Object.freeze({ ...record })); + return capability; +} + +function requireManagedRepositoryCapability( + ownerToken: object, + capability: GitoxideManagedRepositoryCapability, +): ManagedRepositoryCapabilityRecord { + const record = managedRepositories.get(capability); + if (!record || record.managedRepositoryOwnerToken !== ownerToken) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + return record; +} diff --git a/packages/runtime-host/src/server/hosted-execution-tool-profile.ts b/packages/runtime-host/src/server/hosted-execution-tool-profile.ts index 151aebb05a..1d0d2c7ea0 100644 --- a/packages/runtime-host/src/server/hosted-execution-tool-profile.ts +++ b/packages/runtime-host/src/server/hosted-execution-tool-profile.ts @@ -31,6 +31,8 @@ const HEADLESS_CODING_V1_TOOL_NAMES = [ 'apply_patch', ] as const; +const MANAGED_CODING_V1_TOOL_NAMES = ['ManagedWorkspaceInspect', 'Write', 'Edit'] as const; + const HEADLESS_CODING_V1_SYSTEM_PROMPT = [ 'Complete the task by acting with the available tools, not by narrating.', 'Prefer Read, Glob, and Grep for inspection, Edit and Write for file changes, and Bash for shell commands and tests.', @@ -38,6 +40,15 @@ const HEADLESS_CODING_V1_SYSTEM_PROMPT = [ 'Stop when the task is complete.', ].join('\n'); +const MANAGED_CODING_V1_SYSTEM_PROMPT = [ + 'Complete the task by acting with the available tools, not by narrating.', + 'Use Edit or Write for the requested file changes.', + 'Use ManagedWorkspaceInspect before this task when repository inspection is required.', + 'Project mutations are accepted through Maka-owned immutable Gitoxide candidates.', + 'Shell commands and patch tools are unavailable in this execution profile.', + 'Stop when the requested file changes are complete.', +].join('\n'); + const HEADLESS_CODING_V1_BASH_DESCRIPTION = 'Run a foreground shell command in the session cwd. Use Bash for inspection, builds, tests, and task-local generation. Background execution and PTY sessions are unavailable in this profile.'; @@ -65,6 +76,13 @@ export function hostedExecutionRunProfile( memoryExtraction: false, }; } + if (profile === 'managed-coding-v1') { + return { + toolNames: MANAGED_CODING_V1_TOOL_NAMES, + systemPrompt: MANAGED_CODING_V1_SYSTEM_PROMPT, + memoryExtraction: false, + }; + } profile satisfies never; throw new Error('Unknown Session tool profile'); } @@ -79,13 +97,21 @@ export function projectHostedExecutionTools( ): readonly MakaTool[] { if (profile === undefined) return tools; hostedExecutionRunProfile(profile); - return tools.map((tool) => - tool.name === 'Bash' - ? { - ...tool, - description: HEADLESS_CODING_V1_BASH_DESCRIPTION, - parameters: HEADLESS_CODING_V1_BASH_PARAMETERS, - } - : tool, - ); + return tools.map((tool) => { + if (tool.name === 'Bash') { + return { + ...tool, + description: HEADLESS_CODING_V1_BASH_DESCRIPTION, + parameters: HEADLESS_CODING_V1_BASH_PARAMETERS, + }; + } + if (profile === 'managed-coding-v1' && (tool.name === 'Write' || tool.name === 'Edit')) { + return { + ...tool, + recoveryMode: 'reconcile' as const, + durableExecutionProfile: 'gitoxide_managed_mutation_v1' as const, + }; + } + return tool; + }); } diff --git a/packages/runtime-host/src/server/managed-dependency-producer-process.ts b/packages/runtime-host/src/server/managed-dependency-producer-process.ts new file mode 100644 index 0000000000..70cb0f61cb --- /dev/null +++ b/packages/runtime-host/src/server/managed-dependency-producer-process.ts @@ -0,0 +1,611 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import { lstat, mkdir, readdir, readlink, realpath, writeFile } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from 'node:path'; +import type { Readable } from 'node:stream'; +import { + DEFAULT_PROCESS_IO_DRAIN_TIMEOUT_MS, + manageChildProcessLifecycle, +} from '@maka/runtime/child-process-lifecycle'; +import type { ManagedDependencyEnvironmentProducerInput } from '@maka/storage/managed-dependency-environment'; +import { requireBundledNpmRuntimeCapabilityInternal } from './bundled-npm-runtime.js'; +import { + MANAGED_NPM_PACKAGE_MANAGER_VERSION, + isManagedNpmNodeVersionSupported, + type ManagedNpmRuntimeCapability, +} from './managed-npm-runtime-contract.js'; + +export { + MANAGED_NPM_PACKAGE_MANAGER_VERSION, + isManagedNpmNodeVersionSupported, + type ManagedNpmRuntimeCapability, +} from './managed-npm-runtime-contract.js'; + +const DEFAULT_PRODUCER_TIMEOUT_MS = 10 * 60 * 1_000; +const DEFAULT_KILL_GRACE_MS = 2_000; +const MAX_OUTPUT_TAIL_BYTES = 1024 * 1024; +const QUOTA_MONITOR_INTERVAL_MS = 100; +const MANAGED_NPM_MAX_OBSERVED_BYTES = 2 * 1024 * 1024 * 1024; +const MANAGED_NPM_MAX_OBSERVED_ENTRIES = 250_000; + +export interface RunManagedNpmDependencyProvisionInput { + readonly producerInput: ManagedDependencyEnvironmentProducerInput; + readonly runtime: ManagedNpmRuntimeCapability; +} + +export async function runManagedNpmDependencyProvision( + input: RunManagedNpmDependencyProvisionInput, +): Promise { + const runtime = await requireBundledNpmRuntimeCapabilityInternal(input.runtime); + assertSafeNpmInputs(input.producerInput); + const outputRoot = normalize(await realpath(input.producerInput.outputRoot)); + const scratchRoot = normalize(await realpath(input.producerInput.scratchRoot)); + const projectRoot = dirname(outputRoot); + if ( + basename(outputRoot) !== 'node_modules' || + basename(scratchRoot) !== '.maka-runtime' || + dirname(scratchRoot) !== projectRoot + ) { + throw new TypeError('Managed npm producer requires one exact owned staging project'); + } + const nodeExecutablePath = await canonicalRegularFile( + runtime.nodeExecutablePath, + 'Managed npm Node runtime', + ); + const npmRuntimeRoot = await canonicalDirectory(runtime.npmRuntimeRoot, 'Managed npm runtime'); + const npmCliPath = await canonicalRegularFile(runtime.npmCliPath, 'Managed npm CLI'); + if (!isPathWithin(npmCliPath, npmRuntimeRoot)) { + throw new Error('Managed npm CLI escapes its verified runtime root'); + } + const [homeRoot, npmCache, temporaryRoot, compileCacheRoot] = await Promise.all([ + createOwnedScratchDirectory(scratchRoot, 'home'), + createOwnedScratchDirectory(scratchRoot, 'cache'), + createOwnedScratchDirectory(scratchRoot, 'temp'), + createOwnedScratchDirectory(scratchRoot, 'node-compile-cache'), + ]); + const userConfig = join(homeRoot, 'npmrc'); + const globalConfig = join(homeRoot, 'global-npmrc'); + const exactConfig = 'registry=https://registry.npmjs.org/\n'; + await Promise.all([ + writeFile(userConfig, exactConfig, { encoding: 'utf8', flag: 'wx' }), + writeFile(globalConfig, exactConfig, { encoding: 'utf8', flag: 'wx' }), + writeFile(join(projectRoot, 'package.json'), input.producerInput.manifestBytes, { + flag: 'wx', + }), + writeFile(join(projectRoot, 'package-lock.json'), input.producerInput.lockfileBytes, { + flag: 'wx', + }), + ]); + await runManagedDependencyProducerProcessInternal({ + argv: [ + nodeExecutablePath, + '--permission', + ...(requiresExplicitNetworkPermission(runtime.nodeVersion) ? ['--allow-net'] : []), + `--allow-fs-read=${npmRuntimeRoot}`, + `--allow-fs-read=${projectRoot}`, + `--allow-fs-write=${projectRoot}`, + npmCliPath, + 'ci', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=true', + `--cache=${npmCache}`, + `--userconfig=${userConfig}`, + `--globalconfig=${globalConfig}`, + ], + cwd: projectRoot, + env: hermeticNpmEnvironment( + homeRoot, + userConfig, + globalConfig, + temporaryRoot, + compileCacheRoot, + ), + monitorRoot: projectRoot, + ...(input.producerInput.abortSignal ? { abortSignal: input.producerInput.abortSignal } : {}), + timeoutMs: DEFAULT_PRODUCER_TIMEOUT_MS, + maxObservedBytes: MANAGED_NPM_MAX_OBSERVED_BYTES, + maxObservedEntries: MANAGED_NPM_MAX_OBSERVED_ENTRIES, + }); +} + +function requiresExplicitNetworkPermission(nodeVersion: string): boolean { + const major = Number.parseInt(nodeVersion.split('.')[0] ?? '', 10); + return Number.isSafeInteger(major) && major >= 26; +} + +function assertSafeNpmInputs(input: ManagedDependencyEnvironmentProducerInput): void { + if ( + input.identity.packageManagerName !== 'npm' || + input.identity.packageManagerVersion !== MANAGED_NPM_PACKAGE_MANAGER_VERSION || + !isManagedNpmNodeVersionSupported(input.identity.nodeVersion) || + input.identity.platform !== process.platform || + input.identity.arch !== process.arch + ) { + throw new Error('Managed npm producer identity mismatch'); + } + if ( + input.manifestBytes.byteLength > 1024 * 1024 || + input.lockfileBytes.byteLength > 64 * 1024 * 1024 + ) { + throw new Error('Managed npm producer input exceeds its bounded size policy'); + } + const manifest = decodeJsonObject(input.manifestBytes, 'manifest'); + const lockfile = decodeJsonObject(input.lockfileBytes, 'lockfile'); + if ( + manifest.packageManager !== `npm@${MANAGED_NPM_PACKAGE_MANAGER_VERSION}` || + manifest.workspaces !== undefined || + lockfile.lockfileVersion !== 3 || + !lockfile.packages || + typeof lockfile.packages !== 'object' || + Array.isArray(lockfile.packages) + ) { + throw new Error('Managed npm producer accepts only exact non-workspace package-lock v3 input'); + } + const packageEntries = Object.entries(lockfile.packages as Record); + if (packageEntries.length > 25_000) { + throw new Error('Managed npm producer lockfile exceeds its package-count policy'); + } + for (const [packagePath, value] of packageEntries) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Managed npm producer rejected an unsafe dependency entry'); + } + const entry = value as Record; + if (packagePath === '') continue; + if ( + !isSafeManagedNpmPackagePath(packagePath) || + entry.link === true || + entry.hasInstallScript === true || + typeof entry.resolved !== 'string' || + !entry.resolved.startsWith('https://registry.npmjs.org/') || + typeof entry.integrity !== 'string' || + !/^sha(?:1|256|384|512)-[A-Za-z0-9+/=]+$/u.test(entry.integrity) + ) { + throw new Error('Managed npm producer rejected an unsafe dependency entry'); + } + } +} + +function isSafeManagedNpmPackagePath(packagePath: string): boolean { + if ( + packagePath.includes('\\') || + packagePath.includes('\0') || + Buffer.byteLength(packagePath, 'utf8') > 32_768 + ) { + return false; + } + const segments = packagePath.split('/'); + if (segments[0] !== 'node_modules') return false; + let index = 0; + while (index < segments.length) { + if (segments[index] !== 'node_modules') return false; + index += 1; + const packageSegment = segments[index]; + if (!isSafeManagedNpmPathSegment(packageSegment)) return false; + index += 1; + if (packageSegment.startsWith('@')) { + if (packageSegment.length === 1) return false; + if (!isSafeManagedNpmPathSegment(segments[index])) return false; + index += 1; + } + } + return true; +} + +function isSafeManagedNpmPathSegment(segment: string | undefined): segment is string { + if ( + !segment || + segment === '.' || + segment === '..' || + segment.endsWith('.') || + segment.endsWith(' ') || + /[<>:"|?*]/u.test(segment) || + /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/iu.test(segment) + ) { + return false; + } + return Buffer.byteLength(segment, 'utf8') <= 255; +} + +function decodeJsonObject(bytes: Uint8Array, label: string): Record { + let value: unknown; + try { + value = JSON.parse(Buffer.from(bytes).toString('utf8')); + } catch (cause) { + throw new Error(`Managed npm producer ${label} is invalid JSON`, { cause }); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Managed npm producer ${label} must be an object`); + } + return value as Record; +} + +async function canonicalRegularFile(path: string, label: string): Promise { + const sourceInfo = await lstat(path); + if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink()) { + throw new Error(`${label} is unavailable`); + } + const canonical = normalize(await realpath(path)); + const info = await lstat(canonical); + if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${label} is unavailable`); + return canonical; +} + +async function canonicalDirectory(path: string, label: string): Promise { + const sourceInfo = await lstat(path); + if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) { + throw new Error(`${label} is unavailable`); + } + const canonical = normalize(await realpath(path)); + const info = await lstat(canonical); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`${label} is unavailable`); + return canonical; +} + +async function createOwnedScratchDirectory(root: string, name: string): Promise { + const path = join(root, name); + try { + await mkdir(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + throw new Error('Managed npm scratch entry was not created by this provision', { + cause: error, + }); + } + const info = await lstat(path); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error('Managed npm scratch entry is not an owned directory'); + } + const canonical = normalize(await realpath(path)); + if (!isPathWithin(canonical, root)) { + throw new Error('Managed npm scratch entry escapes its authority root'); + } + return canonical; +} + +function hermeticNpmEnvironment( + homeRoot: string, + userConfig: string, + globalConfig: string, + temporaryRoot: string, + compileCacheRoot: string, +): NodeJS.ProcessEnv { + // libuv's Windows home lookup rejects USERPROFILE values at MAX_PATH even + // though Node's filesystem APIs can access the owned long path. The cwd is + // the exact staging project, so a relative home preserves the same authority + // boundary without depending on that fixed-size OS lookup buffer. + const effectiveHomeRoot = process.platform === 'win32' ? join('.maka-runtime', 'home') : homeRoot; + return { + HOME: effectiveHomeRoot, + USERPROFILE: effectiveHomeRoot, + npm_config_audit: 'false', + npm_config_fund: 'false', + npm_config_ignore_scripts: 'true', + npm_config_update_notifier: 'false', + npm_config_registry: 'https://registry.npmjs.org/', + npm_config_userconfig: userConfig, + npm_config_globalconfig: globalConfig, + TEMP: temporaryRoot, + TMP: temporaryRoot, + TMPDIR: temporaryRoot, + NODE_COMPILE_CACHE: compileCacheRoot, + ...(process.platform === 'win32' + ? { + APPDATA: join(effectiveHomeRoot, 'AppData', 'Roaming'), + LOCALAPPDATA: join(effectiveHomeRoot, 'AppData', 'Local'), + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + } + : {}), + ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}), + }; +} + +export interface ManagedDependencyProducerProcessInput { + readonly argv: readonly string[]; + readonly cwd: string; + readonly env: Readonly>; + readonly monitorRoot: string; + readonly abortSignal?: AbortSignal; + readonly timeoutMs?: number; + /** Soft observation limit, not an OS-enforced peak disk quota. */ + readonly maxObservedBytes: number; + /** Soft observation limit, not an OS-enforced peak inode quota. */ + readonly maxObservedEntries: number; +} + +export interface ManagedDependencyProducerProcessResult { + readonly exitCode: number; + readonly outputTail: string; +} + +export type ManagedDependencyProducerProcessFailureReason = + | 'aborted' + | 'timeout' + | 'filesystem_limit_exceeded' + | 'filesystem_invalid' + | 'output_drain_incomplete' + | 'process_failed'; + +export class ManagedDependencyProducerProcessError extends Error { + readonly name = 'ManagedDependencyProducerProcessError'; + + constructor( + readonly reason: ManagedDependencyProducerProcessFailureReason, + message?: string, + options?: ErrorOptions, + ) { + super(message ?? defaultFailureMessage(reason), options); + } +} + +/** @internal Not a package export; PR3 must bind it to an attested runtime. */ +export async function runManagedDependencyProducerProcessInternal( + input: ManagedDependencyProducerProcessInput, +): Promise { + const executable = input.argv[0]; + if (!executable) throw new TypeError('Managed dependency producer argv must include a program'); + assertPositiveLimit(input.maxObservedBytes, 'observed byte limit'); + assertPositiveLimit(input.maxObservedEntries, 'observed entry limit'); + if (input.abortSignal?.aborted) throw new ManagedDependencyProducerProcessError('aborted'); + const cwd = normalize(await realpath(input.cwd)); + const monitorRoot = normalize(await realpath(input.monitorRoot)); + if (cwd !== monitorRoot) { + throw new TypeError('Managed dependency producer monitor root must equal its owned cwd'); + } + const child = spawn(executable, input.argv.slice(1), { + cwd, + env: input.env as NodeJS.ProcessEnv, + detached: process.platform !== 'win32', + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) as ChildProcessByStdio; + return await observeProducerProcess(child, { ...input, cwd, monitorRoot }); +} + +async function observeProducerProcess( + child: ChildProcessByStdio, + input: ManagedDependencyProducerProcessInput, +): Promise { + const output = createBoundedTail(MAX_OUTPUT_TAIL_BYTES); + child.stdout.on('data', (chunk: Buffer) => output.append(chunk)); + child.stderr.on('data', (chunk: Buffer) => output.append(chunk)); + const lifecycle = manageChildProcessLifecycle( + child, + [ + { key: 'stdout', stream: child.stdout }, + { key: 'stderr', stream: child.stderr }, + ], + { + killGraceMs: DEFAULT_KILL_GRACE_MS, + ioDrainTimeoutMs: DEFAULT_PROCESS_IO_DRAIN_TIMEOUT_MS, + }, + ); + let termination: ManagedDependencyProducerProcessFailureReason | undefined; + let monitorFailure: Error | undefined; + const terminate = (reason: ManagedDependencyProducerProcessFailureReason) => { + if (termination) return; + termination = reason; + lifecycle.terminate(); + }; + const timeout = setTimeout( + () => terminate('timeout'), + input.timeoutMs ?? DEFAULT_PRODUCER_TIMEOUT_MS, + ); + const abort = () => terminate('aborted'); + let quotaCheck: Promise | undefined; + const monitor = setInterval(() => { + if (quotaCheck || termination) return; + const current = enforceFilesystemLimit( + input.monitorRoot, + input.maxObservedBytes, + input.maxObservedEntries, + ) + .catch((error: unknown) => { + monitorFailure = asError(error); + terminate( + error instanceof ManagedDependencyProducerProcessError && + error.reason === 'filesystem_limit_exceeded' + ? 'filesystem_limit_exceeded' + : 'filesystem_invalid', + ); + }) + .finally(() => { + if (quotaCheck === current) quotaCheck = undefined; + }); + quotaCheck = current; + }, QUOTA_MONITOR_INTERVAL_MS); + if (input.abortSignal?.aborted) abort(); + else input.abortSignal?.addEventListener('abort', abort, { once: true }); + try { + const result = await lifecycle.completion; + clearInterval(monitor); + await quotaCheck; + if (termination) { + if (termination === 'filesystem_limit_exceeded') { + throw new ManagedDependencyProducerProcessError( + termination, + 'Managed dependency producer exceeded its observed filesystem limit', + monitorFailure ? { cause: monitorFailure } : undefined, + ); + } + if (termination === 'filesystem_invalid') { + throw new ManagedDependencyProducerProcessError( + termination, + `Managed dependency producer output is invalid${monitorFailure ? `: ${monitorFailure.message}` : ''}`, + monitorFailure ? { cause: monitorFailure } : undefined, + ); + } + throw new ManagedDependencyProducerProcessError(termination); + } + if (!result.ioDrained) { + throw new ManagedDependencyProducerProcessError( + 'output_drain_incomplete', + 'Managed dependency producer output did not drain before its deadline', + ); + } + try { + await enforceFilesystemLimit( + input.monitorRoot, + input.maxObservedBytes, + input.maxObservedEntries, + ); + } catch (error) { + if (error instanceof ManagedDependencyProducerProcessError) throw error; + throw new ManagedDependencyProducerProcessError( + 'filesystem_invalid', + `Managed dependency producer output is invalid: ${asError(error).message}`, + { cause: error }, + ); + } + const exitCode = result.exitCode ?? 1; + if (exitCode !== 0) { + throw new ManagedDependencyProducerProcessError( + 'process_failed', + `Managed dependency producer failed with exit code ${exitCode}${output.text ? `: ${output.text}` : ''}`, + ); + } + return Object.freeze({ exitCode, outputTail: output.text }); + } finally { + clearTimeout(timeout); + clearInterval(monitor); + input.abortSignal?.removeEventListener('abort', abort); + } +} + +async function enforceFilesystemLimit( + root: string, + maxBytes: number, + maxEntries: number, +): Promise { + const inventory = await measureProducerTree(root); + if (inventory.bytes > maxBytes || inventory.entries > maxEntries) { + throw new ManagedDependencyProducerProcessError( + 'filesystem_limit_exceeded', + 'Managed dependency producer exceeded its observed filesystem limit', + ); + } +} + +async function measureProducerTree( + root: string, +): Promise<{ readonly bytes: number; readonly entries: number }> { + let bytes = 0; + let entries = 0; + const pending = [root]; + while (pending.length > 0) { + const directory = pending.pop(); + if (!directory) break; + let names: string[]; + try { + names = await readdir(directory); + } catch (error) { + if (isMissingPathError(error)) continue; + throw error; + } + for (const name of names) { + const path = resolve(directory, name); + let info; + try { + info = await lstat(path); + } catch (error) { + if (isMissingPathError(error)) continue; + throw error; + } + entries += 1; + if (info.isDirectory() && !info.isSymbolicLink()) { + pending.push(path); + } else if (info.isFile() && !info.isSymbolicLink()) { + bytes += info.size; + } else if (info.isSymbolicLink() && process.platform !== 'win32') { + const target = await readlink(path); + if (isAbsolute(target) || !isPathWithin(resolve(dirname(path), target), root)) { + throw new Error('Managed dependency producer created an escaping symbolic link'); + } + bytes += Buffer.byteLength(target, 'utf8'); + } else { + throw new Error('Managed dependency producer created an unsupported filesystem entry'); + } + if (bytes > Number.MAX_SAFE_INTEGER || entries > Number.MAX_SAFE_INTEGER) { + throw new Error('Managed dependency producer inventory overflowed'); + } + } + } + return Object.freeze({ bytes, entries }); +} + +function isPathWithin(path: string, root: string): boolean { + const value = relative(root, path); + return value === '' || (!value.startsWith('..') && !isAbsolute(value)); +} + +function isMissingPathError(error: unknown): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +function assertPositiveLimit(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`Managed dependency producer ${label} must be a positive safe integer`); + } +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function defaultFailureMessage(reason: ManagedDependencyProducerProcessFailureReason): string { + switch (reason) { + case 'aborted': + return 'Managed dependency producer process was aborted'; + case 'timeout': + return 'Managed dependency producer process timed out'; + case 'filesystem_limit_exceeded': + return 'Managed dependency producer exceeded its observed filesystem limit'; + case 'filesystem_invalid': + return 'Managed dependency producer output is invalid'; + case 'output_drain_incomplete': + return 'Managed dependency producer output did not drain before its deadline'; + case 'process_failed': + return 'Managed dependency producer process failed'; + } +} + +function createBoundedTail(maxBytes: number) { + let tail: Buffer = Buffer.alloc(0); + return { + append(chunk: Buffer) { + tail = appendBoundedTail(tail, chunk, maxBytes); + }, + get text() { + return tail.toString('utf8').trim(); + }, + }; +} + +function appendBoundedTail(current: Buffer, chunk: Buffer, limit: number): Buffer { + if (chunk.length >= limit) return Buffer.from(chunk.subarray(chunk.length - limit)); + if (current.length + chunk.length <= limit) return Buffer.concat([current, chunk]); + return Buffer.concat([current.subarray(current.length - (limit - chunk.length)), chunk]); +} diff --git a/packages/runtime-host/src/server/managed-npm-runtime-contract.ts b/packages/runtime-host/src/server/managed-npm-runtime-contract.ts new file mode 100644 index 0000000000..63890bce60 --- /dev/null +++ b/packages/runtime-host/src/server/managed-npm-runtime-contract.ts @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const MANAGED_NPM_PACKAGE_MANAGER_VERSION = '12.0.2'; + +export function isManagedNpmNodeVersionSupported(version: string): boolean { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/u.exec(version); + if (!match) return false; + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + if (major === 26) return true; + if (major === 24) return minor > 15 || (minor === 15 && patch >= 0); + if (major === 22) return minor > 22 || (minor === 22 && patch >= 2); + return false; +} + +export interface ManagedNpmRuntimeCapability { + readonly npmVersion: typeof MANAGED_NPM_PACKAGE_MANAGER_VERSION; + readonly nodeVersion: string; + readonly nodeAbi: string; + readonly platform: NodeJS.Platform; + readonly arch: string; + /** Canonical root whose release owner supplied the attested runtime. */ + readonly resourcesRoot: string; + readonly nodeExecutablePath: string; + readonly npmRuntimeRoot: string; + readonly npmCliPath: string; + readonly runtimeIdentitySha256: `sha256:${string}`; +} diff --git a/packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts b/packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts new file mode 100644 index 0000000000..027c57358d --- /dev/null +++ b/packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts @@ -0,0 +1,195 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { lstat, readFile, realpath } from 'node:fs/promises'; +import { isAbsolute, join, normalize, relative } from 'node:path'; +import { + admitGitoxideHelperArtifactInternal, + GitoxideHelperArtifactAuthorityError, + issueGitoxideHelperReleaseArtifactClaimInternal, + type GitoxideHelperInvocationCapability, +} from './gitoxide-helper-artifact-authority-internal.js'; + +const MANIFEST_KEYS = [ + 'arch', + 'bytes', + 'distributionReady', + 'executableRelativePath', + 'platform', + 'protocol', + 'protocolVersion', + 'provider', + 'schemaVersion', + 'sha256', +] as const; +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const MAX_MANIFEST_BYTES = 64 * 1024; +const MAX_HELPER_BYTES = 256 * 1024 * 1024; +const packagedReleaseOwnerToken = Object.freeze({ + kind: 'packaged_gitoxide_release_owner_v1' as const, +}); + +export type PackagedGitoxideHelperErrorCode = + | 'packaged_gitoxide_helper_unavailable' + | 'packaged_gitoxide_helper_manifest_invalid' + | 'packaged_gitoxide_helper_platform_mismatch' + | 'packaged_gitoxide_helper_integrity_mismatch'; + +export class PackagedGitoxideHelperError extends Error { + constructor( + readonly code: PackagedGitoxideHelperErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'PackagedGitoxideHelperError'; + } +} + +export async function resolvePackagedGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; +}): Promise { + try { + const resourcesRoot = normalize(await realpath(requirePackagedProcessResourcesRoot())); + const manifestPath = normalize(await realpath(join(resourcesRoot, 'gitoxide-helper.json'))); + assertWithinRoot(resourcesRoot, manifestPath, 'Gitoxide helper manifest'); + const manifestInfo = await lstat(manifestPath); + if ( + !manifestInfo.isFile() || + manifestInfo.isSymbolicLink() || + manifestInfo.size > MAX_MANIFEST_BYTES + ) { + throw invalidManifest('Gitoxide helper manifest must be a bounded regular file'); + } + const manifest = decodeManifest(parseManifest(await readFile(manifestPath, 'utf8'))); + if (manifest.platform !== process.platform || manifest.arch !== process.arch) { + throw new PackagedGitoxideHelperError( + 'packaged_gitoxide_helper_platform_mismatch', + `Packaged Gitoxide helper targets ${manifest.platform}/${manifest.arch}, not ${process.platform}/${process.arch}`, + ); + } + const executablePath = normalize( + await realpath(join(resourcesRoot, ...manifest.executableRelativePath.split('/'))), + ); + assertWithinRoot(resourcesRoot, executablePath, 'Gitoxide helper executable'); + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(packagedReleaseOwnerToken, { + executablePath, + expectedSha256: manifest.sha256, + expectedBytes: manifest.bytes, + platform: manifest.platform, + arch: manifest.arch, + protocolVersion: manifest.protocolVersion, + }); + return await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken: packagedReleaseOwnerToken, + invocationOwnerToken: input.invocationOwnerToken, + claim, + }); + } catch (error) { + if (error instanceof PackagedGitoxideHelperError) throw error; + if (error instanceof GitoxideHelperArtifactAuthorityError) { + throw new PackagedGitoxideHelperError( + error.code === 'gitoxide_helper_artifact_identity_mismatch' + ? 'packaged_gitoxide_helper_integrity_mismatch' + : 'packaged_gitoxide_helper_unavailable', + 'Packaged Gitoxide helper failed release admission', + { cause: error }, + ); + } + throw new PackagedGitoxideHelperError( + 'packaged_gitoxide_helper_unavailable', + 'Packaged Gitoxide helper is unavailable', + { cause: error }, + ); + } +} + +function requirePackagedProcessResourcesRoot(): string { + const resourcesPath = (process as NodeJS.Process & { readonly resourcesPath?: unknown }) + .resourcesPath; + if (typeof resourcesPath !== 'string' || resourcesPath.length === 0 || !isAbsolute(resourcesPath)) { + throw new PackagedGitoxideHelperError( + 'packaged_gitoxide_helper_unavailable', + 'Packaged process resources root is unavailable', + ); + } + return resourcesPath; +} + +interface PackagedGitoxideHelperManifestV1 { + readonly schemaVersion: 1; + readonly protocol: 'maka_gitoxide_helper_release_v1'; + readonly provider: 'maka/gitoxide-helper'; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly protocolVersion: 1; + readonly executableRelativePath: string; + readonly bytes: number; + readonly sha256: `sha256:${string}`; + readonly distributionReady: true; +} + +function decodeManifest(input: unknown): PackagedGitoxideHelperManifestV1 { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw invalidManifest('Gitoxide helper manifest must be an object'); + } + const value = input as Record; + const expectedExecutable = + value.platform === 'win32' + ? 'gitoxide/maka-gitoxide-helper.exe' + : 'gitoxide/maka-gitoxide-helper'; + if ( + Object.keys(value).sort().join('\0') !== [...MANIFEST_KEYS].sort().join('\0') || + value.schemaVersion !== 1 || + value.protocol !== 'maka_gitoxide_helper_release_v1' || + value.provider !== 'maka/gitoxide-helper' || + (value.platform !== 'win32' && value.platform !== 'darwin' && value.platform !== 'linux') || + typeof value.arch !== 'string' || + !/^[a-z0-9_]+$/u.test(value.arch) || + value.protocolVersion !== 1 || + value.executableRelativePath !== expectedExecutable || + !Number.isSafeInteger(value.bytes) || + (value.bytes as number) < 1 || + (value.bytes as number) > MAX_HELPER_BYTES || + typeof value.sha256 !== 'string' || + !SHA256_PATTERN.test(value.sha256) || + value.distributionReady !== true + ) { + throw invalidManifest('Gitoxide helper manifest is invalid'); + } + return value as unknown as PackagedGitoxideHelperManifestV1; +} + +function parseManifest(value: string): unknown { + try { + return JSON.parse(value); + } catch (error) { + throw invalidManifest(`Gitoxide helper manifest is not valid JSON: ${String(error)}`); + } +} + +function assertWithinRoot(root: string, target: string, label: string): void { + const rel = relative(root, target); + if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) return; + throw invalidManifest(`${label} escapes the packaged resources root`); +} + +function invalidManifest(message: string): PackagedGitoxideHelperError { + return new PackagedGitoxideHelperError('packaged_gitoxide_helper_manifest_invalid', message); +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 693b3d88f6..84a7bcc34d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -19,6 +19,7 @@ "./context-budget": "./dist/context-budget.js", "./test-connection": "./dist/test-connection.js", "./model-fetcher": "./dist/model-fetcher.js", + "./child-process-lifecycle": "./dist/child-process-lifecycle.js", "./session-manager": "./dist/session-manager.js", "./test-only/fake-backend": "./dist/test-only/fake-backend.js", "./test-only/observation-text-reader": "./dist/__tests__/observation-text-reader.js", @@ -79,6 +80,7 @@ "./request-customization-fetch": "./dist/request-customization-fetch.js", "./request-shape": "./dist/request-shape.js", "./runtime-commit-sink": "./dist/runtime-commit-sink.js", + "./managed-mutation-transform": "./dist/managed-mutation-transform.js", "./runtime-read-model": "./dist/runtime-read-model.js", "./sandbox-boundary-tool": "./dist/sandbox-boundary-tool.js", "./scheduled-task-tools": "./dist/scheduled-task-tools.js", diff --git a/packages/runtime/src/__tests__/child-process-lifecycle.test.ts b/packages/runtime/src/__tests__/child-process-lifecycle.test.ts index 970ad3e80b..5ad5049675 100644 --- a/packages/runtime/src/__tests__/child-process-lifecycle.test.ts +++ b/packages/runtime/src/__tests__/child-process-lifecycle.test.ts @@ -80,6 +80,35 @@ test('completion waits for the bounded process-tree signal attempt after root ex }); }); +test('incomplete descendant output forces a tree kill after the direct root exits', async () => { + const child = new EventEmitter() as ChildProcess; + const inheritedOutput = new PassThrough(); + const signals: string[] = []; + const lifecycle = manageChildProcessLifecycle( + child, + [{ key: 'stdout', stream: inheritedOutput }], + { + killGraceMs: 100, + ioDrainTimeoutMs: 10, + async signalProcessTree(signal) { + signals.push(signal); + return true; + }, + }, + ); + + lifecycle.terminate(); + child.emit('exit', 0, 'SIGTERM'); + + assert.deepEqual(await lifecycle.completion, { + exitCode: 0, + signal: 'SIGTERM', + ioDrained: false, + incompleteOutputs: new Set(['stdout']), + }); + assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']); +}); + test('forced termination rejects boundedly when the direct root never acknowledges exit', async () => { const child = new EventEmitter() as ChildProcess; const signals: string[] = []; diff --git a/packages/runtime/src/__tests__/managed-mutation-transform.test.ts b/packages/runtime/src/__tests__/managed-mutation-transform.test.ts new file mode 100644 index 0000000000..9c7d3338f1 --- /dev/null +++ b/packages/runtime/src/__tests__/managed-mutation-transform.test.ts @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { transformManagedMutation } from '../managed-mutation-transform.js'; + +test('derives Write from the immutable Git base without touching a checkout', () => { + const result = transformManagedMutation({ + toolName: 'Write', + canonicalPath: 'docs/hello.txt', + baseContent: 'before\n', + args: { path: 'docs/hello.txt', content: 'after\n' }, + }); + assert.equal(result.content, 'after\n'); + assert.equal(result.changed, true); + assert.equal((result.providerResult as { kind: string }).kind, 'file_diff'); +}); + +test('uses the production Edit matcher and rejects an absent target', () => { + const result = transformManagedMutation({ + toolName: 'Edit', + canonicalPath: 'src/value.ts', + baseContent: 'const value = 1;\n', + args: { + path: 'src/value.ts', + old_string: 'const value = 1;', + new_string: 'const value = 2;', + }, + }); + assert.equal(result.content, 'const value = 2;\n'); + assert.equal(result.changed, true); + assert.throws( + () => + transformManagedMutation({ + toolName: 'Edit', + canonicalPath: 'src/missing.ts', + baseContent: null, + args: { path: 'src/missing.ts', old_string: 'a', new_string: 'b' }, + }), + /does not exist/u, + ); +}); + +test('keeps the durable provider result bounded independently of file size', () => { + const content = `${'x'.repeat(2 * 1024 * 1024)}\n`; + const result = transformManagedMutation({ + toolName: 'Write', + canonicalPath: 'artifacts/large.txt', + baseContent: 'before\n', + args: { path: 'artifacts/large.txt', content }, + }); + + assert.equal(result.content, content); + assert.ok(Buffer.byteLength(JSON.stringify(result.providerResult), 'utf8') <= 512); +}); diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 89732d52aa..36e242cef5 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1772,6 +1772,30 @@ const ACTION_COVERAGE_SAMPLES: ActionCoverageSamples = { }, }, }, + managedMutationTerminal: { + action: { + protocol: 'managed_mutation_terminal_v1', + disposition: 'no_workspace_change_committed', + operationId: 'coverage-op', + dispatchEventId: 'coverage-dispatch', + outcomeEventId: 'coverage-outcome', + mutation: { + protocol: 'managed_mutation_v1', + repositoryId: `repository_${'1'.repeat(32)}`, + workspaceId: `workspace_${'2'.repeat(32)}`, + workspaceEpochId: `epoch_${'3'.repeat(32)}`, + workspaceInstanceId: `instance_${'4'.repeat(32)}`, + objectFormat: 'sha1', + baseWorkspaceVersionId: `version_${'5'.repeat(32)}`, + baseAcceptedEventId: 'coverage-baseline', + baseHeadRevision: 1, + baseCommitOid: '1'.repeat(40), + baseTreeOid: '2'.repeat(40), + expectedPaths: ['notes.txt'], + executionProfileDigest: `sha256:${'6'.repeat(64)}`, + }, + }, + }, runtimeProtocol: { action: { toolBoundary: 't1_after_preflight_v1' } }, }; diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index 5e37609b57..7cb00f25ac 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -1723,6 +1723,50 @@ describe('SessionManager terminal ledger invariants', () => { expect(view.terminalFacts[0]?.failureClass).toBe('app_restarted'); }); + test('startup recovery does not seal a run while managed mutation recovery is parked', async () => { + const store = new TinySessionStore(); + const runStore = new TinyAgentRunStore(); + const gatedSessions: string[] = []; + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends: new BackendRegistry(), + newId: nextId(), + now: nextNow(55_000), + runtimeSource: 'test', + recoverManagedMutationBeforeRunClosure: async (session) => { + gatedSessions.push(session.id); + return { kind: 'parked', reason: 'candidate publication state is indeterminate' }; + }, + }); + const session = await store.create(makeInput({ status: 'active' })); + const run = await runStore.createRun( + makeRunHeader({ + sessionId: session.id, + runId: 'run-managed-mutation-parked', + turnId: 'turn-managed-mutation-parked', + status: 'running', + }), + ); + await runStore.appendEvent(session.id, run.runId, { + type: 'run_started', + id: 'run-started-managed-mutation', + sessionId: session.id, + runId: run.runId, + turnId: run.turnId, + ts: 2, + }); + + await manager.recoverInterruptedSessions(); + + expect(gatedSessions).toEqual([session.id]); + expect((await runStore.readRun(session.id, run.runId)).status).toBe('running'); + expect( + (await runStore.readRuntimeEvents(session.id, run.runId)).filter(isTerminalRuntimeEvent), + ).toHaveLength(0); + }); + test('startup recovery completes an existing aborted terminal RuntimeEvent without appending another', async () => { const store = new TinySessionStore(); const runStore = new TinyAgentRunStore(); diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index e67c74e8fc..777caf1836 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -33,6 +33,7 @@ import { ToolRuntime, type MakaTool, type RuntimeManagedMutationAdmission, + type RuntimeManagedMutationOperationProof, type ToolRuntimeInput, } from '../tool-runtime.js'; @@ -261,6 +262,127 @@ describe('ToolRuntime durable boundary', () => { ); }); + it('derives a Gitoxide managed Write from the owner-observed immutable base', async () => { + let observedProof: RuntimeManagedMutationOperationProof | undefined; + let operationId = ''; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a Gitoxide managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return { + ...managedAdmission(async (operation) => { + observedProof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, observedProof.content, false, { + durationMs: observedProof.durationMs, + }), + }; + }), + gitoxideTransform: { + canonicalPath: 'notes.txt', + baseContent: 'before\n', + }, + durableDispatch: { + ...managedMutationDispatch(), + executionProfileDigest: + 'sha256:4d9d03626705fdc7f895256b7a94b6c6fdd04c7bf76c70e67bab6a6f177e4b99', + }, + }; + }, + }, + ); + const managedTool = tool(() => { + throw new Error('mutable filesystem implementation must not run'); + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'gitoxide_managed_mutation_v1'; + + const result = await harness.execute(managedTool, new AbortController().signal, { + path: 'notes.txt', + content: 'after\n', + }); + + assert.equal((result as { kind: string }).kind, 'file_diff'); + assert.deepEqual(observedProof?.managedMutationResult, { + canonicalPath: 'notes.txt', + content: 'after\n', + changed: true, + }); + assert.equal(observedProof?.durableOutcome.content?.kind, 'function_response'); + assert.equal(observedProof?.durableOutcome.refs?.operationId, operationId); + }); + + it('keeps candidate rejection result ownership inside Runtime', async () => { + let genericOutcomeCalls = 0; + let rejectedProof: RuntimeManagedMutationOperationProof | undefined; + let operationId = ''; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return { + ...managedAdmission(async (operation) => { + await operation(); + rejectedProof = await operation.rejectNoEffect!( + 'candidate_rejected_before_publication', + ); + return { + kind: 'operation_failed_no_effect_committed', + durableOutcome: rejectedProof.durableOutcome, + }; + }), + gitoxideTransform: { + canonicalPath: 'notes.txt', + baseContent: 'before\n', + }, + durableDispatch: { + ...managedMutationDispatch(), + executionProfileDigest: + 'sha256:4d9d03626705fdc7f895256b7a94b6c6fdd04c7bf76c70e67bab6a6f177e4b99', + }, + }; + }, + }, + ); + const managedTool = tool(() => { + throw new Error('mutable filesystem implementation must not run'); + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'gitoxide_managed_mutation_v1'; + + const result = await harness.execute(managedTool, new AbortController().signal, { + path: 'notes.txt', + content: 'after\n', + }); + + assert.deepEqual(result, { + error: 'Managed workspace candidate was rejected before publication', + }); + assert.equal(rejectedProof?.isError, true); + assert.equal(rejectedProof?.durableOutcome.refs?.operationId, operationId); + assert.equal(genericOutcomeCalls, 0); + }); + it('does not replace a committed managed result when admission cleanup fails', async () => { let operationId = ''; const harness = makeHarness( @@ -574,22 +696,16 @@ describe('ToolRuntime durable boundary', () => { admitManagedMutation: async (input) => { operationId = input.operationId; return managedAdmission(async (operation) => { - await operation(); - const result = { error: 'candidate was safely discarded' }; + const proof = await operation(); return { - kind: 'safely_discarded', - providerResult: result, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: result }, - true, - ), + kind: 'operation_failed_no_effect_committed', + durableOutcome: proof.durableOutcome, }; }); }, }, ); - const managedTool = tool(() => ({ ok: true })); + const managedTool = tool(() => ({ error: 'candidate was safely discarded' })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; managedTool.durableExecutionProfile = 'managed_mutation_v1'; @@ -624,21 +740,16 @@ describe('ToolRuntime durable boundary', () => { admitManagedMutation: async (input) => { operationId = input.operationId; return managedAdmission(async (operation) => { - await operation(); + const proof = await operation(); return { - kind: 'safely_discarded', - providerResult: ownerResult, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: { error: 'discarded-A' } }, - true, - ), + kind: 'operation_failed_no_effect_committed', + durableOutcome: proof.durableOutcome, }; }); }, }, ); - const managedTool = tool(() => ({ ok: true })); + const managedTool = tool(() => ownerResult); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; managedTool.durableExecutionProfile = 'managed_mutation_v1'; @@ -673,15 +784,10 @@ describe('ToolRuntime durable boundary', () => { operationId = input.operationId; return managedAdmission(async (operation) => { retainedOperation = operation; - const result = { error: 'candidate was safely discarded' }; + const proof = await operation(); return { - kind: 'safely_discarded', - providerResult: result, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: result }, - true, - ), + kind: 'operation_failed_no_effect_committed', + durableOutcome: proof.durableOutcome, }; }); }, @@ -689,7 +795,7 @@ describe('ToolRuntime durable boundary', () => { ); const managedTool = tool(() => { implementationCalls += 1; - return { ok: true }; + return { error: 'candidate was safely discarded' }; }); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; @@ -700,7 +806,7 @@ describe('ToolRuntime durable boundary', () => { }); assert.ok(retainedOperation); await assert.rejects(retainedOperation(), /operation capability is closed/i); - assert.equal(implementationCalls, 0); + assert.equal(implementationCalls, 1); }); it('does not accept terminal settlement while a detached operation is running', async () => { @@ -725,8 +831,7 @@ describe('ToolRuntime durable boundary', () => { void operation().catch(() => undefined); const result = { error: 'candidate was safely discarded' }; return { - kind: 'safely_discarded', - providerResult: result, + kind: 'operation_failed_no_effect_committed', durableOutcome: managedOutcomeEvent( operationId, { kind: 'json', value: result }, @@ -780,8 +885,7 @@ describe('ToolRuntime durable boundary', () => { return managedAdmission(async (operation) => { await operation(); return { - kind: 'safely_discarded', - providerResult: { error: 'live provider error A' }, + kind: 'operation_failed_no_effect_committed', durableOutcome: managedOutcomeEvent( operationId, { kind: 'json', value: { error: 'durable replay error B' } }, @@ -827,21 +931,16 @@ describe('ToolRuntime durable boundary', () => { admitManagedMutation: async (input) => { operationId = input.operationId; return managedAdmission(async (operation) => { - await operation(); + const proof = await operation(); return { - kind: 'safely_discarded', - providerResult, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: { error: 'discarded' } }, - true, - ), + kind: 'operation_failed_no_effect_committed', + durableOutcome: proof.durableOutcome, }; }); }, }, ); - const managedTool = tool(() => ({ ok: true })); + const managedTool = tool(() => providerResult); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; managedTool.durableExecutionProfile = 'managed_mutation_v1'; @@ -875,28 +974,16 @@ describe('ToolRuntime durable boundary', () => { admitManagedMutation: async (input) => { operationId = input.operationId; return managedAdmission(async (operation) => { - await operation(); + const proof = await operation(); return { - kind: 'safely_discarded', - providerResult: oversized, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: oversized }, - true, - { - origin: 'code_mode', - modelVisibility: 'hidden', - toolCallId: 'nested-call-1', - parentToolCallId: 'exec-1', - parentOperationId: 'exec-op-1', - }, - ), + kind: 'operation_failed_no_effect_committed', + durableOutcome: proof.durableOutcome, }; }); }, }, ); - const managedTool = tool(() => ({ ok: true })); + const managedTool = tool(() => oversized); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; managedTool.durableExecutionProfile = 'managed_mutation_v1'; @@ -1467,13 +1554,17 @@ function makeHarness( return { messages, events, - execute: async (target: MakaTool, abortSignal: AbortSignal = new AbortController().signal) => + execute: async ( + target: MakaTool, + abortSignal: AbortSignal = new AbortController().signal, + input: unknown = {}, + ) => ( await runtime.settleToolCall({ tool: target, turnId: 'turn-1', toolCallId: 'provider-call-1', - input: {}, + input, abortSignal, eventSink: { push: (event) => { diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 48bb09af68..7bc4c4dc12 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -757,6 +757,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { recordToolInvocation?: ToolTelemetryRecorder; /** Optional Phase 2 SQLite T1/T2 boundary for real tool execution. */ runtimeCommitSink?: RuntimeCommitSink; + /** Owner-issued managed mutation admission for an explicit managed-coding profile. */ + admitManagedMutation?: ToolRuntimeInput['admitManagedMutation']; /** Durable session-lifetime cumulative usage checkpoint after each completed provider step. */ recordUsageCheckpoint?: ( usage: NormalizedAiSdkUsage & { costUsd?: number }, @@ -1350,6 +1352,7 @@ export class AiSdkBackend implements AgentBackend { getRunTrace: () => identity.scope().runTrace, recordToolInvocation: input.recordToolInvocation, runtimeCommitSink: input.runtimeCommitSink, + admitManagedMutation: input.admitManagedMutation, recordToolArtifacts: input.recordToolArtifacts, }); } diff --git a/packages/runtime/src/child-process-lifecycle.ts b/packages/runtime/src/child-process-lifecycle.ts index 9ce9037eff..a25a6c3916 100644 --- a/packages/runtime/src/child-process-lifecycle.ts +++ b/packages/runtime/src/child-process-lifecycle.ts @@ -263,6 +263,10 @@ export function manageChildProcessLifecycle( function maybeFinish(): void { if (!rootExited || !outputDrainResult || signalsInFlight > 0) return; + if (terminationStarted && !killSent) { + if (outputDrainResult.incomplete.size > 0) forceKill(); + return; + } finish(); } diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index bff9cc5309..c558afc563 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -1055,8 +1055,10 @@ function rewriteRuntimeEventActions( ): RuntimeEvent['actions'] { const dispatch = actions?.toolDispatch; const recovery = actions?.toolRecovery; - if (!dispatch && !recovery) return actions; - const operationId = dispatch?.operationId ?? recovery?.payload.operationId; + const managedTerminal = actions?.managedMutationTerminal; + if (!dispatch && !recovery && !managedTerminal) return actions; + const operationId = + dispatch?.operationId ?? recovery?.payload.operationId ?? managedTerminal?.operationId; const targetOperationId = operationId ? rewriteOwnedId(operationId, references.operationIds, 'tool operation') : undefined; @@ -1070,6 +1072,24 @@ function rewriteRuntimeEventActions( toolRecovery: rewriteToolRecoveryFact(recovery, targetOperationId, references), } : {}), + ...(managedTerminal && targetOperationId + ? { + managedMutationTerminal: { + ...managedTerminal, + operationId: targetOperationId, + dispatchEventId: requiredMappedId( + references.runtimeEventIds, + managedTerminal.dispatchEventId, + 'RuntimeEvent', + ), + outcomeEventId: requiredMappedId( + references.runtimeEventIds, + managedTerminal.outcomeEventId, + 'RuntimeEvent', + ), + }, + } + : {}), }; } diff --git a/packages/runtime/src/managed-mutation-transform.ts b/packages/runtime/src/managed-mutation-transform.ts new file mode 100644 index 0000000000..20a8524770 --- /dev/null +++ b/packages/runtime/src/managed-mutation-transform.ts @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { computeEditedSource } from './edit-replace.js'; +import { createUnifiedDiff } from './unified-diff.js'; + +export interface ManagedMutationTransformResult { + readonly content: string; + readonly providerResult: unknown; + readonly changed: boolean; +} + +/** Frozen semantic identity of the Runtime-owned immutable Git transform. */ +export const GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST = + 'sha256:4d9d03626705fdc7f895256b7a94b6c6fdd04c7bf76c70e67bab6a6f177e4b99' as const; + +export const MANAGED_MUTATION_CANDIDATE_REJECTED_MESSAGE = + 'Managed workspace candidate was rejected before publication' as const; + +/** + * Pure Write/Edit transform for Git-backed managed workspaces. It never reads + * or writes a checkout: the accepted Git tree supplies the sole base content. + */ +export function transformManagedMutation(input: { + readonly toolName: 'Write' | 'Edit'; + readonly canonicalPath: string; + readonly baseContent: string | null; + readonly args: unknown; +}): ManagedMutationTransformResult { + const args = requireArgs(input.args); + if (args.path !== input.canonicalPath) { + throw new Error('Managed mutation path does not match its canonical path'); + } + if (input.toolName === 'Write') { + if (typeof args.content !== 'string') throw new Error('Managed Write content is invalid'); + const diff = createUnifiedDiff( + input.canonicalPath, + input.baseContent ?? undefined, + args.content, + ); + return Object.freeze({ + content: args.content, + changed: input.baseContent !== args.content, + providerResult: + diff === undefined + ? Object.freeze({ + kind: 'file_write' as const, + path: input.canonicalPath, + bytes: Buffer.byteLength(args.content, 'utf8'), + }) + : Object.freeze({ + kind: 'file_diff' as const, + paths: Object.freeze([input.canonicalPath]), + diff, + }), + }); + } + if (input.baseContent === null) throw new Error('Managed Edit target does not exist'); + if (typeof args.old_string !== 'string' || typeof args.new_string !== 'string') { + throw new Error('Managed Edit arguments are invalid'); + } + const edited = computeEditedSource( + input.baseContent, + args.old_string, + args.new_string, + input.canonicalPath, + ); + const diff = createUnifiedDiff(input.canonicalPath, input.baseContent, edited.content); + return Object.freeze({ + content: edited.content, + changed: edited.content !== input.baseContent, + providerResult: + diff === undefined + ? Object.freeze({ + ok: true, + path: input.canonicalPath, + replacements: 1, + matchedVia: edited.matchedVia, + startLine: edited.startLine, + endLine: edited.endLine, + }) + : Object.freeze({ + kind: 'file_diff' as const, + paths: Object.freeze([input.canonicalPath]), + diff, + }), + }); +} + +function requireArgs(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Managed mutation arguments are invalid'); + } + return value as Record; +} diff --git a/packages/runtime/src/process-tree-terminator.ts b/packages/runtime/src/process-tree-terminator.ts index 95284230fd..6c662bcfb9 100644 --- a/packages/runtime/src/process-tree-terminator.ts +++ b/packages/runtime/src/process-tree-terminator.ts @@ -67,8 +67,8 @@ export async function terminateProcessTree( options: ProcessTreeTerminationOptions, ): Promise { const { pid, signal, fallback, hasExited, beforeSignal } = options; - if (hasExited?.()) return false; if (process.platform === 'win32') { + if (hasExited?.()) return false; if (beforeSignal && !beforeSignal()) return false; if (await killWindowsTree(pid)) return true; if (hasExited?.()) return false; @@ -76,12 +76,14 @@ export async function terminateProcessTree( } const processes = await readPosixProcesses(); - if (hasExited?.()) return false; if (beforeSignal && !beforeSignal()) return false; const escapedDescendantSignaled = forceKillEscapedDescendants(pid, processes); - if (hasExited?.()) return escapedDescendantSignaled; try { + // All production callers spawn a detached POSIX group whose PGID is the + // direct root PID. The group survives the root, so signal it even after + // Node has observed root exit; inherited descendants may still own output + // handles and otherwise escape lifecycle completion. process.kill(-pid, signal); return true; } catch (error) { diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index 29a16105e7..962dd7c602 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -323,6 +323,13 @@ export function projectRuntimeEventsToStoredMessages( projected = true; } + if (event.actions?.managedMutationTerminal) { + // The exact function_response owns the provider-visible result. This + // storage-owned fact only proves that the managed reservation ended + // without advancing the canonical workspace head. + projected = true; + } + if (event.actions?.artifactDelta) { // Artifact counters are storage bookkeeping. The tool result that owns the // artifact owns its row; this delta has none of its own. diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 0707b96998..ee9027a783 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -864,8 +864,21 @@ interface SessionManagerBaseDeps { sourceText: string; }) => Promise; onSessionTitleChanged?: (sessionId: string) => void; + /** + * Host-owned durable mutation gate. Generic app-restart recovery may only + * seal a Run after this owner has either settled its active T1 or proved + * that the Session has no active managed mutation. + */ + recoverManagedMutationBeforeRunClosure?: ( + session: Readonly>, + ) => Promise; } +export type ManagedMutationRecoveryGate = + | { readonly kind: 'settled' } + | { readonly kind: 'no_active_mutation' } + | { readonly kind: 'parked'; readonly reason: string }; + export interface ResolvedChildToolActivation { readonly tools: readonly MakaTool[]; readonly shell?: TurnShellPlan; @@ -1502,6 +1515,19 @@ export class SessionManager { } } + if (this.deps.recoverManagedMutationBeforeRunClosure) { + let managedMutationGate: ManagedMutationRecoveryGate; + try { + managedMutationGate = await this.deps.recoverManagedMutationBeforeRunClosure(session); + } catch (error) { + if (policy.kind === 'strict') throw error; + // An unreadable configured mutation authority is not evidence that + // no T1 exists. Keep the Run open for a later authoritative pass. + continue; + } + if (managedMutationGate.kind === 'parked') continue; + } + let continuationClaimRecovered = false; const continuationAuthority = runtimeContinuationAuthority(this.deps.runtimeEventStore); if (this.deps.runStore && continuationAuthority) { diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 0b8fd99475..e4ee9e596c 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -94,6 +94,11 @@ import type { SubagentExecutionRef } from './subagent-execution.js'; import { sandboxErrorMetadata, serializeSandboxError } from './sandbox/errors.js'; import { normalizeSandboxBoundaryExpansion } from './sandbox-boundary-path.js'; import { SANDBOX_BOUNDARY_UNAVAILABLE } from './sandbox-boundary-tool.js'; +import { + GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST, + MANAGED_MUTATION_CANDIDATE_REJECTED_MESSAGE, + transformManagedMutation, +} from './managed-mutation-transform.js'; import { RuntimeInteractionAdmissionRejectedError, RuntimeInteractionClosedError, @@ -160,7 +165,7 @@ export interface MakaTool

{ /** Crash-recovery contract used by the durable tool boundary. */ recoveryMode?: ToolRecoveryMode; /** Durable execution profile selected by the Host before T1. */ - durableExecutionProfile?: 'managed_mutation_v1'; + durableExecutionProfile?: 'managed_mutation_v1' | 'gitoxide_managed_mutation_v1'; /** Step-level admission contract. Exclusive tools cannot share an assistant step. */ executionSemantics?: 'parallel' | 'exclusive_step'; /** Nested CodeMode admission. Ordinary tools are nestable by default. */ @@ -479,6 +484,11 @@ interface RuntimeManagedMutationOperationValue { readonly isError: boolean; readonly durationMs: number; }; + readonly managedMutationResult?: { + readonly canonicalPath: string; + readonly content: string; + readonly changed: boolean; + }; } /** @@ -490,6 +500,20 @@ export interface RuntimeManagedMutationOperationProof { readonly content: ToolResultContent; readonly isError: boolean; readonly durationMs: number; + /** Runtime-owned exact response envelope consumed by the SQLite settlement owner. */ + readonly durableOutcome: RuntimeEvent; + readonly managedMutationResult?: { + readonly canonicalPath: string; + readonly content: string; + readonly changed: boolean; + }; +} + +export interface RuntimeManagedMutationOperationCapability { + (): Promise; + readonly rejectNoEffect?: ( + reason: 'candidate_rejected_before_publication', + ) => Promise; } export type RuntimeManagedMutationSettlement = @@ -498,17 +522,23 @@ export type RuntimeManagedMutationSettlement = readonly durableOutcome: RuntimeEvent; } | { - readonly kind: 'safely_discarded'; - /** Exact value returned to the provider and canonicalized for durable replay. */ - readonly providerResult: unknown; + readonly kind: 'no_workspace_change_committed'; + readonly durableOutcome: RuntimeEvent; + } + | { + readonly kind: 'operation_failed_no_effect_committed'; readonly durableOutcome: RuntimeEvent; } | { readonly kind: 'unsettled'; readonly error: unknown }; export interface RuntimeManagedMutationAdmission { readonly durableDispatch: Readonly; + readonly gitoxideTransform?: { + readonly canonicalPath: string; + readonly baseContent: string | null; + }; execute( - operation: () => Promise, + operation: RuntimeManagedMutationOperationCapability, ): Promise; /** Idempotent for an unused, failed-T1, or already executed admission. */ dispose(): Promise; @@ -517,6 +547,7 @@ export interface RuntimeManagedMutationAdmission { interface DurableToolAttempt { operationId: string; responseEventId: string; + buildOutcome(result: ToolResultContent, isError: boolean, durationMs: number): RuntimeEvent; commitOutcome( result: unknown, isError: boolean, @@ -1342,7 +1373,10 @@ export class ToolRuntime { } let managedMutationAdmission: RuntimeManagedMutationAdmission | undefined; - if (tool.durableExecutionProfile === 'managed_mutation_v1') { + if ( + tool.durableExecutionProfile === 'managed_mutation_v1' || + tool.durableExecutionProfile === 'gitoxide_managed_mutation_v1' + ) { if ( (tool.name !== 'Write' && tool.name !== 'Edit') || tool.recoveryMode !== 'reconcile' || @@ -1362,6 +1396,18 @@ export class ToolRuntime { persistedArgs: structuredClone(persistedArgs), abortSignal: ctx.abortSignal, }); + if (tool.durableExecutionProfile === 'gitoxide_managed_mutation_v1') { + const transform = managedMutationAdmission.gitoxideTransform; + if ( + !transform || + managedMutationAdmission.durableDispatch.expectedPaths.length !== 1 || + transform.canonicalPath !== managedMutationAdmission.durableDispatch.expectedPaths[0] || + managedMutationAdmission.durableDispatch.executionProfileDigest !== + GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST + ) { + throw new Error('Gitoxide managed mutation transform admission is invalid'); + } + } } catch (error) { const reason = `Managed workspace mutation admission failed: ${formatSyntheticToolErrorText(error)}`; await refuseBeforeDispatch(reason); @@ -1513,7 +1559,48 @@ export class ToolRuntime { const prepareOperationValue = async ( immutableSnapshot = false, ): Promise> => { - const rawResult = await invokeTool(); + let managedMutationResult: + | { + readonly canonicalPath: string; + readonly content: string; + readonly changed: boolean; + } + | undefined; + let rawResult: unknown; + try { + if ( + immutableSnapshot && + tool.durableExecutionProfile === 'gitoxide_managed_mutation_v1' + ) { + const transformAdmission = managedMutationAdmission?.gitoxideTransform; + if (!transformAdmission || (tool.name !== 'Write' && tool.name !== 'Edit')) { + throw new Error('Gitoxide managed mutation transform is unavailable'); + } + const transformed = transformManagedMutation({ + toolName: tool.name, + canonicalPath: transformAdmission.canonicalPath, + baseContent: transformAdmission.baseContent, + args: executionArgs, + }); + rawResult = transformed.providerResult; + managedMutationResult = Object.freeze({ + canonicalPath: transformAdmission.canonicalPath, + content: transformed.content, + changed: transformed.changed, + }); + } else { + rawResult = await invokeTool(); + } + } catch (error) { + if ( + !immutableSnapshot || + tool.durableExecutionProfile !== 'gitoxide_managed_mutation_v1' + ) { + throw error; + } + const message = formatSyntheticToolErrorText(error); + rawResult = this.errorReturn(message); + } const result = immutableSnapshot ? snapshotManagedToolResult(rawResult, ctx.maxResultBytes) : rawResult; @@ -1535,6 +1622,7 @@ export class ToolRuntime { const value = { result, outcome: immutableSnapshot ? Object.freeze(outcome) : outcome, + ...(managedMutationResult ? { managedMutationResult } : {}), }; return immutableSnapshot ? Object.freeze(value) : value; }; @@ -1551,7 +1639,24 @@ export class ToolRuntime { } = { state: 'open' }; let operationPromise: Promise | undefined; let runtimeOwnedValue: RuntimeManagedMutationOperationValue | undefined; - const executeManagedOperation = (): Promise => { + const proofForValue = ( + value: RuntimeManagedMutationOperationValue, + ): RuntimeManagedMutationOperationProof => ({ + // The canonical content is already recursively immutable, so the + // owner can read it without receiving a mutable alias. + content: value.outcome.content, + isError: value.outcome.isError, + durationMs: value.outcome.durationMs, + durableOutcome: durableAttempt!.buildOutcome( + value.outcome.content, + value.outcome.isError, + value.outcome.durationMs, + ), + ...(value.managedMutationResult + ? { managedMutationResult: value.managedMutationResult } + : {}), + }); + const executeManagedOperationBase = (): Promise => { if (operationLifecycle.state !== 'open') { if (operationLifecycle.state === 'closed') { return Promise.reject(new Error('Managed mutation operation capability is closed')); @@ -1565,13 +1670,7 @@ export class ToolRuntime { try { const value = await prepareOperationValue(true); runtimeOwnedValue = value; - return { - // The canonical content is already recursively immutable, so - // the owner can read it without receiving a mutable alias. - content: value.outcome.content, - isError: value.outcome.isError, - durationMs: value.outcome.durationMs, - }; + return proofForValue(value); } finally { if (operationLifecycle.state === 'running') { operationLifecycle.state = 'settled'; @@ -1583,6 +1682,37 @@ export class ToolRuntime { void operationPromise.catch(() => undefined); return operationPromise; }; + let noEffectRejectionIssued = false; + const executeManagedOperation = Object.assign(executeManagedOperationBase, { + rejectNoEffect: async ( + reason: 'candidate_rejected_before_publication', + ): Promise => { + if ( + reason !== 'candidate_rejected_before_publication' || + operationLifecycle.state !== 'settled' || + !runtimeOwnedValue || + noEffectRejectionIssued + ) { + throw new Error('Managed mutation no-effect rejection capability is unavailable'); + } + noEffectRejectionIssued = true; + const result = snapshotManagedToolResult( + this.errorReturn(MANAGED_MUTATION_CANDIDATE_REJECTED_MESSAGE), + ctx.maxResultBytes, + ); + const content = Object.freeze(coerceResultContent(result)); + const value = Object.freeze({ + result, + outcome: Object.freeze({ + content, + isError: true, + durationMs: this.input.now() - startedAt, + }), + }); + runtimeOwnedValue = value; + return proofForValue(value); + }, + }); let settlement: unknown; let ownerFailed = false; let ownerError: unknown; @@ -1616,7 +1746,7 @@ export class ToolRuntime { // discarded; every other failure remains unsettled for recovery. throw new RuntimeManagedMutationUnsettledError(ownerError); } - const normalized = normalizeManagedMutationSettlement(settlement, ctx.maxResultBytes); + const normalized = normalizeManagedMutationSettlement(settlement); if (normalized.kind === 'workspace_successor_committed') { if (!runtimeOwnedValue) { throw new RuntimeManagedMutationUnsettledError( @@ -1631,9 +1761,14 @@ export class ToolRuntime { durableOutcome: normalized.durableOutcome, }; } else { + if (!runtimeOwnedValue) { + throw new RuntimeManagedMutationUnsettledError( + new Error('Managed mutation owner settled without executing the operation'), + ); + } settledExecution = { kind: 'managed', - value: normalized.value, + value: runtimeOwnedValue, durableOutcome: normalized.durableOutcome, }; } @@ -2087,6 +2222,11 @@ export class ToolRuntime { return { operationId, responseEventId: `${operationId}_response`, + buildOutcome: (result, isError, durationMs) => + snapshotManagedToolResult( + buildResponseEvent(result, isError, durationMs, this.input.now()), + undefined, + ) as RuntimeEvent, commitOutcome: async (result, isError, durationMs) => { if (committedOutcome) return committedOutcome; const responseEvent = buildResponseEvent(result, isError, durationMs, this.input.now()); @@ -3128,17 +3268,17 @@ function uncertainOutcomeSignalFromError(error: unknown): ToolUncertainOutcomeSi }; } -function normalizeManagedMutationSettlement( - settlement: unknown, - maxResultBytes: number | undefined, -): +function normalizeManagedMutationSettlement(settlement: unknown): | { kind: 'workspace_successor_committed'; durableOutcome: RuntimeEvent; } | { - kind: 'safely_discarded'; - value: RuntimeManagedMutationOperationValue; + kind: 'no_workspace_change_committed'; + durableOutcome: RuntimeEvent; + } + | { + kind: 'operation_failed_no_effect_committed'; durableOutcome: RuntimeEvent; } { if (!settlement || typeof settlement !== 'object' || Array.isArray(settlement)) { @@ -3149,7 +3289,11 @@ function normalizeManagedMutationSettlement( if (kind === 'unsettled') { throw new RuntimeManagedMutationUnsettledError(record.error); } - if (kind !== 'workspace_successor_committed' && kind !== 'safely_discarded') { + if ( + kind !== 'workspace_successor_committed' && + kind !== 'no_workspace_change_committed' && + kind !== 'operation_failed_no_effect_committed' + ) { throw new Error('Managed mutation owner returned an unknown settlement kind'); } const durableOutcomeValue = Object.hasOwn(record, 'durableOutcome') @@ -3164,36 +3308,20 @@ function normalizeManagedMutationSettlement( } const durableOutcome = durableOutcomeValue as RuntimeEvent; - if (kind === 'workspace_successor_committed') { - return { - kind, - durableOutcome, - }; - } - - if (!Object.hasOwn(record, 'providerResult')) { - throw new Error('Managed safely-discarded settlement has no provider result'); - } - const providerResult = snapshotManagedToolResult(record.providerResult, maxResultBytes); const response = durableOutcome.content; - if (response?.kind !== 'function_response' || response.isError !== true) { - throw new Error('Managed safely-discarded settlement has no durable error outcome'); - } - const content = Object.freeze(coerceResultContent(providerResult)); - const outcome = Object.freeze({ - content, - isError: true, - durationMs: - typeof durableOutcome.actions?.stateDelta?.durationMs === 'number' - ? durableOutcome.actions.stateDelta.durationMs - : 0, - }); + if (response?.kind !== 'function_response') { + throw new Error('Managed mutation settlement has no durable function response'); + } + const expectsError = kind === 'operation_failed_no_effect_committed'; + if ((response.isError === true) !== expectsError) { + throw new Error( + expectsError + ? 'Managed no-effect failure settlement has no durable error outcome' + : 'Managed successful settlement has no durable success outcome', + ); + } return { kind, - value: Object.freeze({ - result: providerResult, - outcome, - }), durableOutcome, }; } diff --git a/packages/storage/package.json b/packages/storage/package.json index b45746f046..ef8829205f 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -23,6 +23,7 @@ "./interaction-store": "./dist/interaction-store-public.js", "./git-worktree-child-executor": "./dist/git-worktree-child-executor.js", "./memory-bundle-store": "./dist/memory-bundle-store.js", + "./managed-dependency-environment": "./dist/managed-dependency-environment.js", "./managed-workspace-owner": "./dist/managed-workspace-owner.js", "./managed-secret-store": "./dist/managed-secret-store.js", "./activation-secret-injector": "./dist/activation-secret-injector.js", @@ -41,6 +42,8 @@ "./model-call-ledger": "./dist/model-call-ledger.js", "./usage-stores": "./dist/usage-stores.js", "./workspace-identity": "./dist/workspace-identity.js", + "./execution-stores-workspace-authority-internal": "./dist/execution-stores-workspace-authority-internal.js", + "./workspace-version-authority-internal": "./dist/workspace-version-authority-internal.js", "./write-queue": "./dist/write-queue.js" }, "scripts": { diff --git a/packages/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts b/packages/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts new file mode 100644 index 0000000000..9ba7188d03 --- /dev/null +++ b/packages/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import { openInteractiveExecutionStoresForWrite } from '../execution-stores.js'; +import { requireExecutionStoresWorkspaceMutationAuthorityInternal } from '../execution-stores-workspace-authority-internal.js'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +after(removeTrackedControlDirectories); + +test('binds the private workspace mutation authority to authentic execution stores', async () => { + assert.throws( + () => requireExecutionStoresWorkspaceMutationAuthorityInternal({}), + /workspace mutation authority is unavailable/u, + ); + const base = await mkdtemp(join(tmpdir(), 'maka-execution-workspace-authority-')); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: join(base, 'interactive'), kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + try { + const authority = requireExecutionStoresWorkspaceMutationAuthorityInternal(stores); + assert.equal(await authority.readHead('workspace-missing', 'epoch-missing'), undefined); + assert.equal(await authority.readVersion('version-missing'), undefined); + authority.adoptRootForManagedExecution(); + const accepted = await authority.commitBaseline({ + epochOpenedEventId: 'gitoxide-epoch-opened-1', + baselineAcceptedEventId: 'gitoxide-baseline-accepted-1', + committedAt: 0, + epoch: { + repositoryId: 'repository_11111111111111111111111111111111', + workspaceId: 'workspace_22222222222222222222222222222222', + workspaceEpochId: 'epoch_33333333333333333333333333333333', + workspaceInstanceId: 'instance_44444444444444444444444444444444', + mode: 'managed_worktree', + objectFormat: 'sha1', + sourceCommitOid: '1'.repeat(40), + sourceTreeOid: '2'.repeat(40), + materializationProfileDigest: `sha256:${'3'.repeat(64)}`, + materializationSemantics: 'git_tree_materialized_with_fixed_config_v1', + policyHash: `sha256:${'4'.repeat(64)}`, + }, + baseline: { + workspaceVersionId: 'version_55555555555555555555555555555555', + commitOid: '5'.repeat(40), + treeOid: '2'.repeat(40), + treeDeltaDigest: `sha256:${'6'.repeat(64)}`, + changedFileCount: 1, + deletedFileCount: 0, + }, + }); + assert.equal(accepted.created, true); + assert.equal( + (await authority.readHead(accepted.head.workspaceId, accepted.head.workspaceEpochId)) + ?.workspaceVersionId, + accepted.head.workspaceVersionId, + ); + } finally { + await stores.sessionStore.close?.(); + await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts index aae35699b0..85b1ff504c 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -38,6 +38,7 @@ import { } from '../sqlite-runtime-store.js'; import { bindWorkspaceBaselineAuthorityStoreRootInternal, + commitManagedMutationTerminalInternal, commitWorkspaceBaselineInternal, commitWorkspaceSuccessorInternal, readActiveManagedMutationInternal, @@ -214,12 +215,82 @@ describe('workspace version persistence authority', () => { }, }, }), - /managed mutation outcome requires the workspace successor writer/i, + /managed mutation outcome requires a workspace settlement writer/i, ); assert.equal((await store.readToolOperation(prepared.operationId))?.currentState, 'prepared'); }); }); + it('atomically commits a no-effect outcome and releases its durable reservation', async () => { + await withDatabase(async ({ dbPath, store }) => { + const baseline = baselineInput(); + const opened = await commitWorkspaceBaselineInternal(store, baseline); + const prepared = managedPreparedCommit(baseline, opened.head, 'operation-no-change'); + await store.commitToolPrepared(prepared); + const runtimeEvent: RuntimeEvent = { + id: `${prepared.operationId}-outcome-event`, + sessionId: prepared.runtimeEvent.sessionId, + invocationId: prepared.runtimeEvent.invocationId, + runId: prepared.runtimeEvent.runId, + turnId: prepared.runtimeEvent.turnId, + ts: baseline.committedAt + 2, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: prepared.providerToolCallId, + name: prepared.toolName, + result: { kind: 'text', text: 'No workspace change' }, + }, + refs: { + operationId: prepared.operationId, + toolCallId: prepared.providerToolCallId, + }, + }; + const input = { + disposition: 'no_workspace_change_committed' as const, + toolOutcome: { + operationId: prepared.operationId, + journalEventId: `${prepared.operationId}_outcome`, + committedAt: baseline.committedAt + 2, + runtimeEvent, + }, + }; + + const committed = await commitManagedMutationTerminalInternal(store, input); + assert.equal(committed.created, true); + assert.equal( + (await store.readToolOperation(prepared.operationId))?.currentState, + 'outcome_committed', + ); + assert.equal( + await readActiveManagedMutationInternal(store, baseline.epoch.workspaceInstanceId), + undefined, + ); + assert.deepEqual( + await store.readWorkspaceHead(baseline.epoch.workspaceId, baseline.epoch.workspaceEpochId), + opened.head, + ); + assert.equal((await commitManagedMutationTerminalInternal(store, input)).created, false); + + const raw = new DatabaseSync(dbPath); + try { + assert.equal( + countWhere( + raw, + 'runtime_events', + "json_extract(payload_json, '$.actions.managedMutationTerminal.protocol') = ?", + 'managed_mutation_terminal_v1', + ), + 1, + ); + } finally { + raw.close(); + } + }); + }); + it('atomically commits one tool outcome with its successor workspace head', async () => { await withDatabase(async ({ dbPath, store }) => { const { baseline, input } = await prepareSuccessorCommit(store); diff --git a/packages/storage/src/execution-stores-workspace-authority-internal.ts b/packages/storage/src/execution-stores-workspace-authority-internal.ts new file mode 100644 index 0000000000..9261c08f78 --- /dev/null +++ b/packages/storage/src/execution-stores-workspace-authority-internal.ts @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { RuntimeWorkspaceVersionAuthorityStore } from '@maka/core/runtime-event-store'; +import type { + WorkspaceBaselineAuthorityInput, + WorkspaceBaselineCommitResult, + WorkspaceHeadRecordV1, + WorkspaceVersionRecordV1, +} from '@maka/core/workspace-version-authority'; +import { + adoptWorkspaceBaselineAuthorityStoreRootInternal, + commitManagedMutationTerminalInternal, + commitWorkspaceBaselineInternal, + commitWorkspaceSuccessorInternal, + readActiveManagedMutationInternal, + type ManagedMutationReservationRecordV1, + type ManagedMutationTerminalCommitInput, + type ManagedMutationTerminalCommitResult, + type WorkspaceSuccessorCommitInput, + type WorkspaceSuccessorCommitResult, +} from './workspace-version-authority-internal.js'; +import type { ToolOperationRecord } from './sqlite-runtime-store.js'; + +interface WorkspaceMutationRecoveryStore extends RuntimeWorkspaceVersionAuthorityStore { + readToolOperation(operationId: string): Promise; +} + +export interface ExecutionStoresWorkspaceMutationAuthorityInternal { + adoptRootForManagedExecution(): void; + readHead( + workspaceId: string, + workspaceEpochId: string, + ): Promise; + readVersion(workspaceVersionId: string): Promise; + readActiveManagedMutation( + workspaceInstanceId: string, + ): Promise; + readToolOperation(operationId: string): Promise; + readRuntimeEvents(sessionId: string, runId: string): Promise; + commitBaseline(input: WorkspaceBaselineAuthorityInput): Promise; + commitSuccessor(input: WorkspaceSuccessorCommitInput): Promise; + commitTerminal( + input: ManagedMutationTerminalCommitInput, + ): Promise; +} + +interface RegisteredWorkspaceAuthority { + readonly authority: WorkspaceMutationRecoveryStore; + readonly rootId: string; +} + +const workspaceAuthorities = new WeakMap(); + +export function registerExecutionStoresWorkspaceMutationAuthorityInternal( + stores: object, + authority: WorkspaceMutationRecoveryStore, + rootId: string, +): void { + if (workspaceAuthorities.has(stores)) { + throw new Error('Execution stores workspace mutation authority is already registered'); + } + workspaceAuthorities.set(stores, Object.freeze({ authority, rootId })); +} + +export function requireExecutionStoresWorkspaceMutationAuthorityInternal( + stores: object, +): ExecutionStoresWorkspaceMutationAuthorityInternal { + const registered = workspaceAuthorities.get(stores); + if (!registered) throw new Error('Execution stores workspace mutation authority is unavailable'); + const { authority, rootId } = registered; + return Object.freeze({ + adoptRootForManagedExecution: () => + adoptWorkspaceBaselineAuthorityStoreRootInternal(authority, rootId), + readHead: (workspaceId: string, workspaceEpochId: string) => + authority.readWorkspaceHead(workspaceId, workspaceEpochId), + readVersion: (workspaceVersionId: string) => authority.readWorkspaceVersion(workspaceVersionId), + readActiveManagedMutation: (workspaceInstanceId: string) => + readActiveManagedMutationInternal(authority, workspaceInstanceId), + readToolOperation: (operationId: string) => authority.readToolOperation(operationId), + readRuntimeEvents: (sessionId: string, runId: string) => + authority.readRuntimeEvents(sessionId, runId), + commitBaseline: (input: WorkspaceBaselineAuthorityInput) => + commitWorkspaceBaselineInternal(authority, input), + commitSuccessor: (input: WorkspaceSuccessorCommitInput) => + commitWorkspaceSuccessorInternal(authority, input), + commitTerminal: (input: ManagedMutationTerminalCommitInput) => + commitManagedMutationTerminalInternal(authority, input), + }); +} diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index ea8e49bcda..3c9e2a493e 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -76,6 +76,7 @@ import type { ToolCommitResult, ToolOperationRecord, } from './sqlite-runtime-store.js'; +import { registerExecutionStoresWorkspaceMutationAuthorityInternal } from './execution-stores-workspace-authority-internal.js'; const executionStoresWriterBrand: unique symbol = Symbol('ExecutionStoresWriter'); const executionStoresReaderBrand: unique symbol = Symbol('ExecutionStoresReader'); @@ -558,6 +559,11 @@ async function createExecutionStoresForWrite { + const toolOutcome: CommitToolOutcomeInput = { + ...input.toolOutcome, + runtimeEvent: canonicalizeRuntimeEventForStorage(input.toolOutcome.runtimeEvent), + }; + assertNoReservedWorkspaceAuthorityAppend(toolOutcome.runtimeEvent); + assertOutcomeInput(toolOutcome); + + return this.transaction(() => { + this.#assertWorkspaceStorageRootBinding(rootId); + const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.assertWorkspaceProjectionsMatchSync(authority); + const operation = this.readToolOperationSync(toolOutcome.operationId); + if ( + !operation || + operation.dispatchEventId === undefined || + operation.recoveryMode !== 'reconcile' || + (operation.toolName !== 'Write' && operation.toolName !== 'Edit') + ) { + throw new Error('Managed mutation terminal requires one prepared Write/Edit operation'); + } + const dispatchJson = this.readRuntimeEventJson(operation.dispatchEventId); + const dispatchEvent = dispatchJson + ? decodeRuntimeEvent(JSON.parse(dispatchJson) as unknown) + : undefined; + const mutation = dispatchEvent?.actions?.toolDispatch?.managedMutation; + if (!dispatchEvent || !mutation) { + throw new Error('Managed mutation terminal is missing its exact durable T1'); + } + const terminalEvent = buildManagedMutationTerminalEvent({ + dispatchEvent, + mutation, + outcomeEvent: toolOutcome.runtimeEvent, + disposition: input.disposition, + }); + const existingTerminalJson = this.readRuntimeEventJson(terminalEvent.id); + if (operation.resultEventId !== undefined || existingTerminalJson !== undefined) { + if (operation.resultEventId !== toolOutcome.runtimeEvent.id || !existingTerminalJson) { + throw new Error('Managed mutation terminal retry conflicts with its committed outcome'); + } + assertStoredRuntimeEventEquals( + toolOutcome.runtimeEvent, + this.readRuntimeEventJson(operation.resultEventId), + ); + assertStoredRuntimeEventEquals(terminalEvent, existingTerminalJson); + return { + created: false, + outcomeRuntimeEventSeq: this.runtimeEventSeq(operation.resultEventId), + }; + } + if (operation.currentState !== 'prepared') { + throw new Error('Managed mutation terminal requires one prepared operation'); + } + const currentHead = authority.heads.find( + (candidate) => + candidate.workspaceId === mutation.workspaceId && + candidate.workspaceEpochId === mutation.workspaceEpochId, + ); + const reservation = this.db + .prepare(` + SELECT + workspace_instance_id, operation_id, dispatch_event_id, + base_workspace_version_id, base_accepted_event_id, base_head_revision, + base_commit_oid, base_tree_oid, expected_paths_json, execution_profile_digest + FROM runtime_managed_mutation_reservations + WHERE operation_id = ? + `) + .get(operation.operationId) as + | Pick< + ManagedMutationReservationProjectionRow, + | 'workspace_instance_id' + | 'operation_id' + | 'dispatch_event_id' + | 'base_workspace_version_id' + | 'base_accepted_event_id' + | 'base_head_revision' + | 'base_commit_oid' + | 'base_tree_oid' + | 'expected_paths_json' + | 'execution_profile_digest' + > + | undefined; + if ( + !currentHead || + !reservation || + currentHead.workspaceVersionId !== mutation.baseWorkspaceVersionId || + currentHead.acceptedEventId !== mutation.baseAcceptedEventId || + currentHead.revision !== mutation.baseHeadRevision || + currentHead.commitOid !== mutation.baseCommitOid || + currentHead.treeOid !== mutation.baseTreeOid || + reservation.workspace_instance_id !== mutation.workspaceInstanceId || + reservation.operation_id !== operation.operationId || + reservation.dispatch_event_id !== operation.dispatchEventId || + reservation.base_workspace_version_id !== mutation.baseWorkspaceVersionId || + reservation.base_accepted_event_id !== mutation.baseAcceptedEventId || + reservation.base_head_revision !== mutation.baseHeadRevision || + reservation.base_commit_oid !== mutation.baseCommitOid || + reservation.base_tree_oid !== mutation.baseTreeOid || + reservation.execution_profile_digest !== mutation.executionProfileDigest || + !isDeepStrictEqual(JSON.parse(reservation.expected_paths_json), mutation.expectedPaths) + ) { + throw new Error('Managed mutation terminal requires its exact active reservation and head'); + } + const response = toolOutcome.runtimeEvent.content; + if ( + response?.kind !== 'function_response' || + (input.disposition === 'operation_failed_no_effect_committed') !== + (response.isError === true) + ) { + throw new Error( + 'Managed mutation terminal disposition conflicts with its exact tool outcome', + ); + } + + const outcomeResult = this.commitToolOutcomeSync(toolOutcome, 'workspace_terminal'); + this.insertRuntimeEvent(terminalEvent, toolOutcome.committedAt, false); + const released = this.db + .prepare(` + DELETE FROM runtime_managed_mutation_reservations + WHERE workspace_instance_id = ? AND operation_id = ? AND dispatch_event_id = ? + `) + .run(mutation.workspaceInstanceId, operation.operationId, operation.dispatchEventId); + if (released.changes !== 1) { + throw new Error('Managed mutation terminal reservation release compare-and-set failed'); + } + this.assertWorkspaceProjectionsMatchSync(this.readCanonicalWorkspaceAuthoritySync()); + return { + created: true, + outcomeRuntimeEventSeq: outcomeResult.runtimeEventSeq, + }; + }); + } + private registerWorkspaceBaselineAuthorityWriter(databasePath: string): void { const readWorkspaceHead = this.readWorkspaceHead.bind(this); registerWorkspaceBaselineAuthorityWriterInternal( @@ -1472,7 +1612,9 @@ export class SqliteRuntimeStore databasePath, (input, rootId) => this.#commitWorkspaceBaseline(input, rootId), (input, rootId) => this.#commitWorkspaceSuccessor(input, rootId), + (input, rootId) => this.#commitManagedMutationTerminal(input, rootId), (rootId) => this.#bindWorkspaceStorageRoot(rootId), + (rootId) => this.#adoptWorkspaceStorageRoot(rootId), readWorkspaceHead, (workspaceInstanceId) => this.#readActiveManagedMutation(workspaceInstanceId), ); @@ -1537,6 +1679,34 @@ export class SqliteRuntimeStore }); } + #adoptWorkspaceStorageRoot(rootId: string): void { + this.transaction(() => { + const existing = this.#readWorkspaceStorageRootBinding(); + if (existing) { + if (existing.root_id !== rootId || existing.protocol_version !== 1) { + throw new Error( + 'Workspace authority database belongs to a different durable storage root', + ); + } + return; + } + const authority = this.readCanonicalWorkspaceAuthoritySync(); + if ( + authority.baselines.length > 0 || + authority.successors.length > 0 || + authority.activeManagedMutations.length > 0 + ) { + throw new Error('Existing workspace authority data cannot be adopted implicitly'); + } + this.db + .prepare(` + INSERT INTO runtime_storage_root_binding(singleton, root_id, protocol_version) + VALUES (1, ?, 1) + `) + .run(rootId); + }); + } + #assertWorkspaceStorageRootBinding(rootId: string): void { const existing = this.#readWorkspaceStorageRootBinding(); if (!existing || existing.root_id !== rootId || existing.protocol_version !== 1) { @@ -1729,9 +1899,11 @@ export class SqliteRuntimeStore ); } } + const terminalOperations = scanManagedMutationTerminalFacts(events, toolScan); const activeManagedMutations = this.scanCanonicalManagedMutationReservationsSync( toolScan, scan, + terminalOperations, ); this.options.failpoint?.('after_workspace_canonical_scan'); return { ...scan, activeManagedMutations }; @@ -1740,6 +1912,7 @@ export class SqliteRuntimeStore private scanCanonicalManagedMutationReservationsSync( toolScan: ReturnType, authority: ReturnType, + terminalOperations: ReadonlySet, ): ManagedMutationReservationProjectionRow[] { const acceptedOperations = new Set( authority.successors.map((candidate) => candidate.successor.origin.operationId), @@ -1762,7 +1935,12 @@ export class SqliteRuntimeStore `Corrupt managed mutation reservation: identity_conflict at ${dispatchEvent?.id ?? operation.operationId}`, ); } - if (acceptedOperations.has(operation.operationId)) continue; + if ( + acceptedOperations.has(operation.operationId) || + terminalOperations.has(operation.operationId) + ) { + continue; + } if (operation.responseEvent) { throw new Error( `Corrupt managed mutation reservation: generic_outcome at ${operation.responseEvent.id}`, @@ -2670,7 +2848,7 @@ export class SqliteRuntimeStore private commitToolOutcomeSync( input: CommitToolOutcomeInput, - settlementOwner: 'generic' | 'workspace_successor' = 'generic', + settlementOwner: 'generic' | 'workspace_successor' | 'workspace_terminal' = 'generic', ): ToolCommitResult { const operation = this.readToolOperationSync(input.operationId); if (!operation) throw new Error(`Unknown tool operation ${input.operationId}`); @@ -2703,8 +2881,8 @@ export class SqliteRuntimeStore if (!reservation) { throw new Error('Managed mutation T1 is missing its durable reservation'); } - if (settlementOwner !== 'workspace_successor') { - throw new Error('Managed mutation outcome requires the workspace successor writer'); + if (settlementOwner === 'generic') { + throw new Error('Managed mutation outcome requires a workspace settlement writer'); } } const runtimeEventSeq = this.insertRuntimeEvent(input.runtimeEvent, input.committedAt, false); @@ -4132,6 +4310,106 @@ function managedMutationMatchesAcceptedSuccessor( ); } +function buildManagedMutationTerminalEvent(input: { + readonly dispatchEvent: RuntimeEvent; + readonly mutation: RuntimeEventManagedWorkspaceMutationV1; + readonly outcomeEvent: RuntimeEvent; + readonly disposition: RuntimeEventManagedWorkspaceMutationTerminalV1['disposition']; +}): RuntimeEvent { + const dispatch = input.dispatchEvent.actions?.toolDispatch; + if (!dispatch) throw new Error('Managed mutation terminal requires a dispatch event'); + const terminal: RuntimeEventManagedWorkspaceMutationTerminalV1 = { + protocol: 'managed_mutation_terminal_v1', + disposition: input.disposition, + operationId: dispatch.operationId, + dispatchEventId: input.dispatchEvent.id, + outcomeEventId: input.outcomeEvent.id, + mutation: structuredClone(input.mutation), + }; + if (!isRuntimeManagedWorkspaceMutationTerminal(terminal)) { + throw new Error('Invalid managed mutation terminal fact'); + } + const digest = createHash('sha256') + .update(`${dispatch.operationId}\0${input.dispatchEvent.id}\0${input.outcomeEvent.id}`) + .digest('hex') + .slice(0, 32); + return { + id: `managed_terminal_${digest}`, + sessionId: input.dispatchEvent.sessionId, + invocationId: input.dispatchEvent.invocationId, + runId: input.dispatchEvent.runId, + turnId: input.dispatchEvent.turnId, + ts: input.outcomeEvent.ts, + partial: false, + role: 'system', + author: 'system', + modelVisibility: 'hidden', + actions: { managedMutationTerminal: terminal }, + }; +} + +function scanManagedMutationTerminalFacts( + events: readonly RuntimeEvent[], + toolScan: ReturnType, +): ReadonlySet { + const terminalOperations = new Set(); + const eventOrder = new Map(events.map((event, index) => [event.id, index])); + for (const event of events) { + const terminal = event.actions?.managedMutationTerminal; + if (!terminal) continue; + const operation = toolScan.operations.find( + (candidate) => candidate.operationId === terminal.operationId, + ); + const dispatchEvent = operation?.dispatchEvent; + const dispatch = dispatchEvent?.actions?.toolDispatch; + const response = operation?.responseEvent; + const expectedTerminalEvent = + dispatchEvent && dispatch?.managedMutation && response + ? buildManagedMutationTerminalEvent({ + dispatchEvent, + mutation: dispatch.managedMutation, + outcomeEvent: response, + disposition: terminal.disposition, + }) + : undefined; + const actionKeys = event.actions ? Object.keys(event.actions) : []; + if ( + !isRuntimeManagedWorkspaceMutationTerminal(terminal) || + event.partial || + event.role !== 'system' || + event.author !== 'system' || + event.modelVisibility !== 'hidden' || + event.content !== undefined || + event.status !== undefined || + event.refs !== undefined || + actionKeys.length !== 1 || + actionKeys[0] !== 'managedMutationTerminal' || + !operation || + operation.issues.length > 0 || + !dispatchEvent || + !dispatch || + !response || + !expectedTerminalEvent || + !isDeepStrictEqual(event, expectedTerminalEvent) || + terminal.dispatchEventId !== dispatchEvent.id || + terminal.outcomeEventId !== response.id || + !isDeepStrictEqual(terminal.mutation, dispatch.managedMutation) || + event.sessionId !== dispatchEvent.sessionId || + event.invocationId !== dispatchEvent.invocationId || + event.runId !== dispatchEvent.runId || + event.turnId !== dispatchEvent.turnId || + (eventOrder.get(event.id) ?? -1) <= (eventOrder.get(response.id) ?? -1) || + (terminal.disposition === 'operation_failed_no_effect_committed') !== + (response.content?.kind === 'function_response' && response.content.isError === true) || + terminalOperations.has(terminal.operationId) + ) { + throw new Error(`Corrupt managed mutation terminal fact: identity_conflict at ${event.id}`); + } + terminalOperations.add(terminal.operationId); + } + return terminalOperations; +} + function workspaceEpochProjectionRow( authority: ScannedWorkspaceBaselineAuthority, ): WorkspaceEpochProjectionRow { diff --git a/packages/storage/src/workspace-version-authority-internal.ts b/packages/storage/src/workspace-version-authority-internal.ts index 5774e154d6..8369ce4713 100644 --- a/packages/storage/src/workspace-version-authority-internal.ts +++ b/packages/storage/src/workspace-version-authority-internal.ts @@ -34,6 +34,7 @@ type WorkspaceBaselineAuthorityWriter = ( rootId: string, ) => Promise; type WorkspaceStorageRootBinder = (rootId: string) => void; +type WorkspaceStorageRootAdopter = (rootId: string) => void; export interface WorkspaceSuccessorCommitInput { successor: WorkspaceSuccessorAuthorityInput; toolOutcome: { @@ -48,10 +49,22 @@ export interface WorkspaceSuccessorCommitResult { head: WorkspaceHeadRecordV1; outcomeRuntimeEventSeq: number; } +export interface ManagedMutationTerminalCommitInput { + readonly disposition: 'operation_failed_no_effect_committed' | 'no_workspace_change_committed'; + readonly toolOutcome: WorkspaceSuccessorCommitInput['toolOutcome']; +} +export interface ManagedMutationTerminalCommitResult { + readonly created: boolean; + readonly outcomeRuntimeEventSeq: number; +} type WorkspaceSuccessorAuthorityWriter = ( input: WorkspaceSuccessorCommitInput, rootId: string, ) => Promise; +type ManagedMutationTerminalWriter = ( + input: ManagedMutationTerminalCommitInput, + rootId: string, +) => Promise; type WorkspaceHeadReader = ( workspaceId: string, workspaceEpochId: string, @@ -79,9 +92,11 @@ type ManagedMutationReservationReader = ( interface WorkspaceBaselineAuthorityRegistration { readonly writer: WorkspaceBaselineAuthorityWriter; readonly successorWriter: WorkspaceSuccessorAuthorityWriter; + readonly terminalWriter: ManagedMutationTerminalWriter; readonly readHead: WorkspaceHeadReader; readonly readActiveManagedMutation: ManagedMutationReservationReader; readonly bindStorageRoot: WorkspaceStorageRootBinder; + readonly adoptStorageRoot: WorkspaceStorageRootAdopter; readonly databasePath: string; readonly databaseFileIdentity?: string; boundRootId?: string; @@ -97,7 +112,9 @@ export function registerWorkspaceBaselineAuthorityWriterInternal( databasePath: string, writer: WorkspaceBaselineAuthorityWriter, successorWriter: WorkspaceSuccessorAuthorityWriter, + terminalWriter: ManagedMutationTerminalWriter, bindStorageRoot: WorkspaceStorageRootBinder, + adoptStorageRoot: WorkspaceStorageRootAdopter, readHead: WorkspaceHeadReader, readActiveManagedMutation: ManagedMutationReservationReader, ): void { @@ -108,9 +125,11 @@ export function registerWorkspaceBaselineAuthorityWriterInternal( workspaceBaselineAuthorityWriters.set(store, { writer, successorWriter, + terminalWriter, readHead, readActiveManagedMutation, bindStorageRoot, + adoptStorageRoot, databasePath: resolvedDatabasePath, databaseFileIdentity: captureRegularFileIdentity(resolvedDatabasePath), }); @@ -165,6 +184,18 @@ export function commitWorkspaceSuccessorInternal( return registration.successorWriter(input, registration.boundRootId); } +export function commitManagedMutationTerminalInternal( + store: object, + input: ManagedMutationTerminalCommitInput, +): Promise { + const registration = workspaceBaselineAuthorityWriters.get(store); + if (!registration) throw new Error('Managed mutation terminal authority writer is unavailable'); + if (!registration.boundRootId) { + throw new Error('Workspace settlement authority store has no durable storage-root binding'); + } + return registration.terminalWriter(input, registration.boundRootId); +} + export function bindWorkspaceBaselineAuthorityStoreRootInternal( store: object, rootId: string, @@ -178,6 +209,19 @@ export function bindWorkspaceBaselineAuthorityStoreRootInternal( registration.boundRootId = rootId; } +export function adoptWorkspaceBaselineAuthorityStoreRootInternal( + store: object, + rootId: string, +): void { + const registration = workspaceBaselineAuthorityWriters.get(store); + if (!registration) throw new Error('Workspace baseline authority writer is unavailable'); + if (!/^[a-f0-9]{64}$/u.test(rootId)) { + throw new Error('Invalid durable storage-root identity'); + } + registration.adoptStorageRoot(rootId); + registration.boundRootId = rootId; +} + export async function assertWorkspaceBaselineAuthorityStoreRootInternal( store: object, storageRoot: string, diff --git a/scripts/asf-license-headers.mjs b/scripts/asf-license-headers.mjs index 5ce7e6b81d..7ccbc0dbbf 100644 --- a/scripts/asf-license-headers.mjs +++ b/scripts/asf-license-headers.mjs @@ -219,6 +219,7 @@ export const exclusionRules = [ 'docs/astryx-surface-file-inventory.md', 'docs/astryx-surface-file-inventory.paths', 'docs/windows-test-inventory.md', + 'native/gitoxide-helper/Cargo.lock', 'packages/core/src/model-metadata.generated.ts', 'packages/runtime/src/bundled-skill-catalog.generated.ts', 'packages/runtime/src/telemetry/model-pricing.generated.ts', diff --git a/scripts/generate-gitoxide-cargo-notices.mjs b/scripts/generate-gitoxide-cargo-notices.mjs new file mode 100644 index 0000000000..c838a953eb --- /dev/null +++ b/scripts/generate-gitoxide-cargo-notices.mjs @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const manifestPath = join(repoRoot, 'native', 'gitoxide-helper', 'Cargo.toml'); +const lockPath = join(repoRoot, 'native', 'gitoxide-helper', 'Cargo.lock'); +const apacheLicensePath = join(repoRoot, 'LICENSE'); +const outputPath = join( + repoRoot, + 'apps', + 'desktop', + '.generated', + 'gitoxide-helper', + 'THIRD_PARTY_NOTICES.txt', +); + +const metadata = JSON.parse( + execFileSync( + process.env.CARGO ?? 'cargo', + ['metadata', '--manifest-path', manifestPath, '--locked', '--format-version', '1'], + { cwd: repoRoot, encoding: 'utf8' }, + ), +); +const packages = metadata.packages + .filter((pkg) => pkg.name !== 'maka-gitoxide-helper') + .sort((left, right) => + Buffer.compare( + Buffer.from(`${left.name}@${left.version}`, 'utf8'), + Buffer.from(`${right.name}@${right.version}`, 'utf8'), + ), + ); +const sections = packages.map((pkg) => { + if (!pkg.license) throw new Error(`${pkg.name}@${pkg.version}: missing SPDX license metadata`); + const directory = dirname(pkg.manifest_path); + const licenseFiles = readdirSync(directory, { withFileTypes: true }) + .filter( + (entry) => entry.isFile() && /^(licen[cs]e|copying|notice)(?:[._-].*)?$/iu.test(entry.name), + ) + .map((entry) => entry.name) + .sort(); + const selectedLicense = selectPackagedLicense(pkg.license, directory, licenseFiles); + if (!selectedLicense) { + throw new Error(`${pkg.name}@${pkg.version}: packaged crate has no license or notice text`); + } + const source = pkg.repository ?? pkg.homepage ?? pkg.source ?? 'unknown'; + const heading = `${pkg.name} ${pkg.version}`; + return [ + heading, + '-'.repeat(heading.length), + `SPDX license: ${pkg.license}`, + ...(selectedLicense.note ? [selectedLicense.note] : []), + `Source: ${source}`, + ...selectedLicense.files.flatMap(({ name, path }) => [ + '', + `--- ${name} ---`, + readFileSync(path, 'utf8').replace(/\r\n?/gu, '\n').trimEnd(), + ]), + ].join('\n'); +}); + +function selectPackagedLicense(spdxLicense, directory, licenseFiles) { + if (licenseFiles.length > 0) { + return { + files: licenseFiles.map((name) => ({ name, path: join(directory, name) })), + }; + } + if (/(^|\s|\()Apache-2\.0($|\s|\))/u.test(spdxLicense)) { + return { + note: 'Selected license: Apache-2.0 (the crate archive contains no license text)', + files: [{ name: 'LICENSE-APACHE-2.0', path: apacheLicensePath }], + }; + } + return undefined; +} +const lockDigest = createHash('sha256').update(readFileSync(lockPath)).digest('hex'); +const output = `Maka Gitoxide helper Cargo dependency notices +================================================ + +Generated by scripts/generate-gitoxide-cargo-notices.mjs from the exact +Cargo.lock dependency graph. Do not edit this file by hand. + +Manifest: ${relative(repoRoot, manifestPath).replaceAll('\\', '/')} +Cargo.lock SHA-256: ${lockDigest} + +${sections.join('\n\n')} +`; +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, output); +console.log(`[gitoxide-cargo-notices] wrote ${outputPath}`); diff --git a/scripts/package-macos-arm64.mjs b/scripts/package-macos-arm64.mjs index 34779367bc..66c4163483 100644 --- a/scripts/package-macos-arm64.mjs +++ b/scripts/package-macos-arm64.mjs @@ -91,6 +91,12 @@ export async function packageMacosArm64({ await run('npm', ['run', 'clean']); await run('npm', ['run', 'build']); + await run('npm', ['run', 'prepare:bundled-npm']); + await run('npm', ['run', 'verify:bundled-npm']); + await run('npm', ['run', 'audit:bundled-npm']); + await run('npm', ['run', 'build:gitoxide-helper']); + await run('npm', ['run', 'prepare:gitoxide-helper']); + await run('npm', ['run', 'generate:gitoxide-cargo-notices']); await run('npm', ['run', 'check:release']); await remove(releaseDirectory, { recursive: true, force: true }); await run('npm', ['--workspace', '@maka/desktop', 'run', 'package:macos-arm64']); diff --git a/scripts/package-windows-x64.mjs b/scripts/package-windows-x64.mjs index 46cb20441d..9a98ce9996 100644 --- a/scripts/package-windows-x64.mjs +++ b/scripts/package-windows-x64.mjs @@ -107,6 +107,12 @@ export async function packageWindowsX64({ await run('npm', ['run', 'check:windows-cargo-notices']); await mkdir(sandboxResourceDirectory, { recursive: true }); await copyFile(sandboxBinaryPath, sandboxResourcePath); + await run('npm', ['run', 'prepare:bundled-npm']); + await run('npm', ['run', 'verify:bundled-npm']); + await run('npm', ['run', 'audit:bundled-npm']); + await run('npm', ['run', 'build:gitoxide-helper']); + await run('npm', ['run', 'prepare:gitoxide-helper']); + await run('npm', ['run', 'generate:gitoxide-cargo-notices']); await run('npm', ['run', 'check:release']); await remove(releaseDirectory, { recursive: true, force: true }); await run('npm', ['--workspace', '@maka/desktop', 'run', 'package:windows-x64']); diff --git a/scripts/prepare-bundled-npm.mjs b/scripts/prepare-bundled-npm.mjs new file mode 100644 index 0000000000..ddd25e9837 --- /dev/null +++ b/scripts/prepare-bundled-npm.mjs @@ -0,0 +1,262 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { cp, lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { basename, dirname, join, relative } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const EXPECTED_NPM_VERSION = '12.0.2'; +export const BUNDLED_NPM_SECURITY_PATCHES = Object.freeze([ + Object.freeze({ + packageName: 'tar', + fromVersion: '7.5.19', + toVersion: '7.5.22', + advisories: Object.freeze(['GHSA-r292-9mhp-454m']), + }), + Object.freeze({ + packageName: 'brace-expansion', + fromVersion: '5.0.7', + toVersion: '5.0.9', + advisories: Object.freeze(['GHSA-mh99-v99m-4gvg', 'GHSA-rgw5-rvv9-x895']), + }), + Object.freeze({ + packageName: 'ip-address', + fromVersion: '10.2.0', + toVersion: '10.4.0', + advisories: Object.freeze([ + 'GHSA-mwp4-54f8-5fhr', + 'GHSA-4xrf-jv44-h6hh', + 'GHSA-22jq-vg5j-6vgg', + ]), + }), + Object.freeze({ + packageName: 'undici', + fromVersion: '6.27.0', + toVersion: '6.28.0', + advisories: Object.freeze([ + 'GHSA-8xcm-r25x-g524', + 'GHSA-m8rv-5g2x-5cg5', + 'GHSA-v3r7-h72x-cjcm', + ]), + }), +]); + +export async function prepareBundledNpm({ + sourceNpmRoot = join(repoRoot, 'node_modules', 'npm'), + patchedPackagesRoot = join(repoRoot, 'node_modules'), + runtimeOutputRoot = join(repoRoot, 'apps', 'desktop', '.generated', 'bundled-npm', 'npm'), + outputPath = join(repoRoot, 'apps', 'desktop', '.generated', 'bundled-npm', 'bundled-npm.json'), + auditRoot = join(dirname(outputPath), 'audit'), + sourceLockPath = join(repoRoot, 'package-lock.json'), + platform = process.platform, + arch = process.arch, +} = {}) { + const packageManifest = JSON.parse(await readFile(join(sourceNpmRoot, 'package.json'), 'utf8')); + if ( + packageManifest.name !== 'npm' || + packageManifest.version !== EXPECTED_NPM_VERSION || + packageManifest.license !== 'Artistic-2.0' + ) { + throw new Error( + `Bundled npm preparation requires npm ${EXPECTED_NPM_VERSION} under Artistic-2.0.`, + ); + } + for (const patch of BUNDLED_NPM_SECURITY_PATCHES) { + await requirePackageVersion( + join(sourceNpmRoot, 'node_modules', patch.packageName, 'package.json'), + patch.packageName, + patch.fromVersion, + `npm source ${patch.packageName}`, + ); + await requirePackageVersion( + join(patchedPackagesRoot, patch.packageName, 'package.json'), + patch.packageName, + patch.toVersion, + `patched ${patch.packageName}`, + ); + } + + await inventoryFiles(sourceNpmRoot, { ignoreGeneratedBinDirectories: true }); + for (const patch of BUNDLED_NPM_SECURITY_PATCHES) { + await inventoryFiles(join(patchedPackagesRoot, patch.packageName)); + } + await rm(runtimeOutputRoot, { recursive: true, force: true }); + await mkdir(dirname(runtimeOutputRoot), { recursive: true }); + await cp(sourceNpmRoot, runtimeOutputRoot, { + recursive: true, + force: false, + errorOnExist: true, + verbatimSymlinks: true, + }); + for (const patch of BUNDLED_NPM_SECURITY_PATCHES) { + const destination = join(runtimeOutputRoot, 'node_modules', patch.packageName); + await rm(destination, { recursive: true, force: true }); + await cp(join(patchedPackagesRoot, patch.packageName), destination, { + recursive: true, + force: false, + errorOnExist: true, + verbatimSymlinks: true, + }); + } + await removeGeneratedBinDirectories(runtimeOutputRoot); + await requireRegularFile(join(runtimeOutputRoot, 'LICENSE'), 'npm license'); + await requireRegularFile(join(runtimeOutputRoot, 'bin', 'npm-cli.js'), 'npm CLI'); + const files = await inventoryFiles(runtimeOutputRoot); + const manifest = { + schemaVersion: 1, + protocol: 'maka_bundled_npm_runtime_v1', + provider: 'desktop/npm-cli', + npmVersion: EXPECTED_NPM_VERSION, + platform, + arch, + runtimeRootRelativePath: 'npm', + cliRelativePath: 'npm/bin/npm-cli.js', + securityPatches: BUNDLED_NPM_SECURITY_PATCHES, + files, + distributionReady: true, + }; + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + await writeBundledRuntimeAuditLock({ auditRoot, sourceLockPath }); + return manifest; +} + +async function writeBundledRuntimeAuditLock({ auditRoot, sourceLockPath }) { + const sourceLock = JSON.parse(await readFile(sourceLockPath, 'utf8')); + const packages = Object.fromEntries( + Object.entries(sourceLock.packages ?? {}) + .filter(([path]) => path === 'node_modules/npm' || path.startsWith('node_modules/npm/')) + .map(([path, value]) => [path, { ...value, dev: false }]), + ); + for (const patch of BUNDLED_NPM_SECURITY_PATCHES) { + const patchedEntry = sourceLock.packages?.[`node_modules/${patch.packageName}`]; + if (!patchedEntry || patchedEntry.version !== patch.toVersion) { + throw new Error( + `Bundled npm audit requires ${patch.packageName} ${patch.toVersion} in the root lockfile.`, + ); + } + packages[`node_modules/npm/node_modules/${patch.packageName}`] = { + ...patchedEntry, + dev: false, + }; + } + packages[''] = { + name: 'maka-bundled-npm-audit', + version: '1.0.0', + dependencies: { npm: EXPECTED_NPM_VERSION }, + }; + const auditPackage = { + name: 'maka-bundled-npm-audit', + version: '1.0.0', + private: true, + dependencies: { npm: EXPECTED_NPM_VERSION }, + }; + const auditLock = { + name: auditPackage.name, + version: auditPackage.version, + lockfileVersion: 3, + requires: true, + packages, + }; + await rm(auditRoot, { recursive: true, force: true }); + await mkdir(auditRoot, { recursive: true }); + await Promise.all([ + writeFile(join(auditRoot, 'package.json'), `${JSON.stringify(auditPackage, null, 2)}\n`), + writeFile(join(auditRoot, 'package-lock.json'), `${JSON.stringify(auditLock, null, 2)}\n`), + ]); +} + +async function requirePackageVersion(path, name, version, label) { + const manifest = JSON.parse(await readFile(path, 'utf8')); + if (manifest.name !== name || manifest.version !== version) { + throw new Error(`${label} must be ${name}@${version}.`); + } +} + +async function inventoryFiles(root, { ignoreGeneratedBinDirectories = false } = {}) { + const files = []; + await walk(root, root, files, { ignoreGeneratedBinDirectories }); + files.sort((left, right) => Buffer.from(left.path).compare(Buffer.from(right.path))); + return files; +} + +async function walk(root, directory, files, options) { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const absolutePath = join(directory, entry.name); + if ( + options.ignoreGeneratedBinDirectories && + entry.name === '.bin' && + basename(directory) === 'node_modules' + ) { + continue; + } + const info = await lstat(absolutePath); + if (entry.isDirectory() && !info.isSymbolicLink()) { + await walk(root, absolutePath, files, options); + continue; + } + if (!entry.isFile() || info.isSymbolicLink()) { + throw new Error('Bundled npm runtime may contain only regular files and directories.'); + } + files.push({ + path: relative(root, absolutePath).replaceAll('\\', '/'), + bytes: info.size, + sha256: await sha256File(absolutePath), + }); + } +} + +async function removeGeneratedBinDirectories(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const absolutePath = join(directory, entry.name); + if (entry.name === '.bin' && basename(directory) === 'node_modules') { + await rm(absolutePath, { recursive: true, force: true }); + continue; + } + const info = await lstat(absolutePath); + if (entry.isDirectory() && !info.isSymbolicLink()) { + await removeGeneratedBinDirectories(absolutePath); + } + } +} + +async function requireRegularFile(path, label) { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error(`${label} must be a regular non-symlink file: ${path}`); + } +} + +async function sha256File(path) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return `sha256:${hash.digest('hex')}`; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const manifest = await prepareBundledNpm(); + console.log( + `Prepared bundled npm ${manifest.npmVersion} for ${manifest.platform}-${manifest.arch}.`, + ); +} diff --git a/scripts/prepare-bundled-npm.test.mjs b/scripts/prepare-bundled-npm.test.mjs new file mode 100644 index 0000000000..a8aa6cb7de --- /dev/null +++ b/scripts/prepare-bundled-npm.test.mjs @@ -0,0 +1,166 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { access, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { prepareBundledNpm } from './prepare-bundled-npm.mjs'; + +const patches = [ + ['tar', '7.5.19', '7.5.22'], + ['brace-expansion', '5.0.7', '5.0.9'], + ['ip-address', '10.2.0', '10.4.0'], + ['undici', '6.27.0', '6.28.0'], +]; + +test('prepares an exact patched manifest and audit lock for bundled npm', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + const runtimeOutputRoot = join(fixture.root, 'runtime', 'npm'); + const outputPath = join(fixture.root, 'runtime', 'bundled-npm.json'); + const auditRoot = join(fixture.root, 'runtime', 'audit'); + + const manifest = await prepareBundledNpm({ + ...fixture.inputs, + runtimeOutputRoot, + outputPath, + auditRoot, + platform: 'linux', + arch: 'x64', + }); + + assert.equal(manifest.npmVersion, '12.0.2'); + assert.deepEqual( + manifest.securityPatches.map(({ packageName, fromVersion, toVersion }) => [ + packageName, + fromVersion, + toVersion, + ]), + patches, + ); + assert.deepEqual(JSON.parse(await readFile(outputPath, 'utf8')), manifest); + for (const [name, , version] of patches) { + const packageManifest = JSON.parse( + await readFile(join(runtimeOutputRoot, 'node_modules', name, 'package.json'), 'utf8'), + ); + assert.equal(packageManifest.version, version); + } + const auditLock = JSON.parse(await readFile(join(auditRoot, 'package-lock.json'), 'utf8')); + for (const [name, , version] of patches) { + assert.equal(auditLock.packages[`node_modules/npm/node_modules/${name}`].version, version); + } +}); + +test('rejects symlink or junction input before publication', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + await symlink( + process.platform === 'win32' + ? fixture.inputs.sourceNpmRoot + : join(fixture.inputs.sourceNpmRoot, 'LICENSE'), + join(fixture.inputs.sourceNpmRoot, 'redirect'), + process.platform === 'win32' ? 'junction' : undefined, + ); + + await assert.rejects( + prepareBundledNpm({ + ...fixture.inputs, + runtimeOutputRoot: join(fixture.root, 'runtime', 'npm'), + outputPath: join(fixture.root, 'runtime', 'bundled-npm.json'), + }), + /regular files and directories/u, + ); +}); + +test('excludes install-generated internal bin links from the published runtime', { + skip: process.platform === 'win32', +}, async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + const generatedBinRoot = join(fixture.inputs.sourceNpmRoot, 'node_modules', '.bin'); + await mkdir(generatedBinRoot, { recursive: true }); + await symlink( + join('..', '..', 'bin', 'npm-cli.js'), + join(generatedBinRoot, 'npm-internal'), + 'file', + ); + const runtimeOutputRoot = join(fixture.root, 'runtime', 'npm'); + + await prepareBundledNpm({ + ...fixture.inputs, + runtimeOutputRoot, + outputPath: join(fixture.root, 'runtime', 'bundled-npm.json'), + }); + + await assert.rejects(access(join(runtimeOutputRoot, 'node_modules', '.bin'))); +}); + +async function createFixture() { + const root = await mkdtemp(join(tmpdir(), 'maka-prepare-bundled-npm-')); + const sourceNpmRoot = join(root, 'npm'); + const patchedPackagesRoot = join(root, 'patched'); + const sourceLockPath = join(root, 'package-lock.json'); + await mkdir(join(sourceNpmRoot, 'bin'), { recursive: true }); + await Promise.all([ + writeFile( + join(sourceNpmRoot, 'package.json'), + '{"name":"npm","version":"12.0.2","license":"Artistic-2.0"}\n', + ), + writeFile(join(sourceNpmRoot, 'LICENSE'), 'fixture license\n'), + writeFile(join(sourceNpmRoot, 'bin', 'npm-cli.js'), 'console.log("npm");\n'), + ]); + const lockPackages = { 'node_modules/npm': { version: '12.0.2' } }; + for (const [name, fromVersion, toVersion] of patches) { + const sourceRoot = join(sourceNpmRoot, 'node_modules', name); + const patchedRoot = join(patchedPackagesRoot, name); + await Promise.all([ + mkdir(sourceRoot, { recursive: true }), + mkdir(patchedRoot, { recursive: true }), + ]); + await Promise.all([ + writeFile( + join(sourceRoot, 'package.json'), + `${JSON.stringify({ name, version: fromVersion })}\n`, + ), + writeFile( + join(patchedRoot, 'package.json'), + `${JSON.stringify({ name, version: toVersion })}\n`, + ), + writeFile(join(patchedRoot, 'index.js'), 'export const patched = true;\n'), + ]); + lockPackages[`node_modules/npm/node_modules/${name}`] = { version: fromVersion }; + lockPackages[`node_modules/${name}`] = { + version: toVersion, + resolved: `https://registry.npmjs.org/${name}/-/${name}-${toVersion}.tgz`, + integrity: 'sha512-Zml4dHVyZQ==', + }; + } + await writeFile( + sourceLockPath, + `${JSON.stringify({ lockfileVersion: 3, packages: lockPackages })}\n`, + ); + return { + root, + inputs: { sourceNpmRoot, patchedPackagesRoot, sourceLockPath }, + remove: () => rm(root, { recursive: true, force: true }), + }; +} diff --git a/scripts/prepare-gitoxide-helper.mjs b/scripts/prepare-gitoxide-helper.mjs new file mode 100644 index 0000000000..7a193220fb --- /dev/null +++ b/scripts/prepare-gitoxide-helper.mjs @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { chmod, copyFile, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const MAX_HELPER_BYTES = 256 * 1024 * 1024; + +export async function prepareGitoxideHelper({ sourceExecutablePath, outputRoot, platform, arch }) { + if ( + typeof sourceExecutablePath !== 'string' || + typeof outputRoot !== 'string' || + !['win32', 'darwin', 'linux'].includes(platform) || + typeof arch !== 'string' || + !/^[a-z0-9_]+$/u.test(arch) + ) { + throw new Error('Gitoxide helper preparation input is invalid'); + } + const sourceInfo = await lstat(sourceExecutablePath); + if ( + !sourceInfo.isFile() || + sourceInfo.isSymbolicLink() || + sourceInfo.size < 1 || + sourceInfo.size > MAX_HELPER_BYTES + ) { + throw new Error('Gitoxide helper build output must be a bounded regular file'); + } + + const executableName = platform === 'win32' ? 'maka-gitoxide-helper.exe' : 'maka-gitoxide-helper'; + const runtimeRoot = join(outputRoot, 'gitoxide'); + const executablePath = join(runtimeRoot, executableName); + const manifestPath = join(outputRoot, 'gitoxide-helper.json'); + const manifestTempPath = `${manifestPath}.tmp`; + await rm(runtimeRoot, { recursive: true, force: true }); + await rm(manifestTempPath, { force: true }); + await mkdir(runtimeRoot, { recursive: true }); + await copyFile(sourceExecutablePath, executablePath); + if (platform !== 'win32') await chmod(executablePath, 0o755); + + const copiedInfo = await lstat(executablePath); + if (!copiedInfo.isFile() || copiedInfo.isSymbolicLink() || copiedInfo.size !== sourceInfo.size) { + throw new Error('Prepared Gitoxide helper does not match its build output'); + } + const sha256 = await sha256File(executablePath); + const manifest = { + schemaVersion: 1, + protocol: 'maka_gitoxide_helper_release_v1', + provider: 'maka/gitoxide-helper', + platform, + arch, + protocolVersion: 1, + executableRelativePath: `gitoxide/${executableName}`, + bytes: copiedInfo.size, + sha256, + distributionReady: true, + }; + await mkdir(outputRoot, { recursive: true }); + await writeFile(manifestTempPath, `${JSON.stringify(manifest, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + await rename(manifestTempPath, manifestPath); + return { executablePath, manifestPath, sha256 }; +} + +async function sha256File(path) { + const digest = createHash('sha256'); + for await (const chunk of createReadStream(path)) digest.update(chunk); + return `sha256:${digest.digest('hex')}`; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const platform = process.platform; + const executableName = platform === 'win32' ? 'maka-gitoxide-helper.exe' : 'maka-gitoxide-helper'; + const sourceExecutablePath = join( + repoRoot, + 'native', + 'gitoxide-helper', + 'target', + 'release', + executableName, + ); + const outputRoot = join(repoRoot, 'apps', 'desktop', '.generated', 'gitoxide-helper'); + const result = await prepareGitoxideHelper({ + sourceExecutablePath, + outputRoot, + platform, + arch: process.arch, + }); + const manifest = JSON.parse(await readFile(result.manifestPath, 'utf8')); + console.log( + `[gitoxide-helper] prepared ${manifest.executableRelativePath} (${manifest.bytes} bytes, ${manifest.sha256})`, + ); +} diff --git a/scripts/prepare-gitoxide-helper.test.mjs b/scripts/prepare-gitoxide-helper.test.mjs new file mode 100644 index 0000000000..b9bc6396ce --- /dev/null +++ b/scripts/prepare-gitoxide-helper.test.mjs @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { mkdtemp } from 'node:fs/promises'; + +import { prepareGitoxideHelper } from './prepare-gitoxide-helper.mjs'; + +test('prepares one exact helper artifact and a strict release manifest', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-gitoxide-package-')); + try { + const source = join(root, process.platform === 'win32' ? 'helper.exe' : 'helper'); + const outputRoot = join(root, 'resources'); + await writeFile(source, 'exact-helper-bytes'); + + const result = await prepareGitoxideHelper({ + sourceExecutablePath: source, + outputRoot, + platform: process.platform, + arch: process.arch, + }); + + assert.equal(await readFile(result.executablePath, 'utf8'), 'exact-helper-bytes'); + assert.deepEqual(JSON.parse(await readFile(result.manifestPath, 'utf8')), { + schemaVersion: 1, + protocol: 'maka_gitoxide_helper_release_v1', + provider: 'maka/gitoxide-helper', + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + executableRelativePath: + process.platform === 'win32' + ? 'gitoxide/maka-gitoxide-helper.exe' + : 'gitoxide/maka-gitoxide-helper', + bytes: 18, + sha256: result.sha256, + distributionReady: true, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('refuses to prepare a missing helper artifact', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-gitoxide-package-missing-')); + try { + await assert.rejects( + prepareGitoxideHelper({ + sourceExecutablePath: join(root, 'missing'), + outputRoot: join(root, 'resources'), + platform: process.platform, + arch: process.arch, + }), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/prepare-windows-upgrade-baseline.mjs b/scripts/prepare-windows-upgrade-baseline.mjs index 87c388c045..b8c64860ee 100644 --- a/scripts/prepare-windows-upgrade-baseline.mjs +++ b/scripts/prepare-windows-upgrade-baseline.mjs @@ -30,6 +30,9 @@ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const defaultManifestPath = join(repoRoot, 'scripts', 'windows-upgrade-baseline.json'); export function validateWindowsUpgradeBaseline(manifest, candidateVersion) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(manifest.repository)) { + throw new Error('Baseline repository must be an explicit GitHub owner/name authority.'); + } if (manifest.tag !== `v${manifest.version}`) throw new Error('Baseline tag must match its version.'); if (manifest.assetName !== `Maka-${manifest.version}-win-x64.exe`) { @@ -47,12 +50,7 @@ export function validateWindowsUpgradeBaseline(manifest, candidateVersion) { export async function prepareWindowsUpgradeBaseline( candidateVersion, outputDirectory, - { - manifestPath = defaultManifestPath, - repository = process.env.GITHUB_REPOSITORY ?? 'apache/maka', - run = runFile, - checksum = sha256File, - } = {}, + { manifestPath = defaultManifestPath, run = runFile, checksum = sha256File } = {}, ) { const manifest = validateWindowsUpgradeBaseline( JSON.parse(await readFile(manifestPath, 'utf8')), @@ -66,7 +64,7 @@ export async function prepareWindowsUpgradeBaseline( 'download', manifest.tag, '--repo', - repository, + manifest.repository, '--pattern', manifest.assetName, '--dir', diff --git a/scripts/prepare-windows-upgrade-baseline.test.mjs b/scripts/prepare-windows-upgrade-baseline.test.mjs new file mode 100644 index 0000000000..c56a03a423 --- /dev/null +++ b/scripts/prepare-windows-upgrade-baseline.test.mjs @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { prepareWindowsUpgradeBaseline } from './prepare-windows-upgrade-baseline.mjs'; + +test('downloads the pinned upgrade artifact from the manifest authority, not the workflow fork', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-windows-upgrade-authority-')); + const manifestPath = join(root, 'baseline.json'); + const outputDirectory = join(root, 'download'); + const digest = 'a'.repeat(64); + const calls = []; + const previousRepository = process.env.GITHUB_REPOSITORY; + await writeFile( + manifestPath, + `${JSON.stringify({ + repository: 'apache/maka', + version: '1.2.2', + tag: 'v1.2.2', + assetName: 'Maka-1.2.2-win-x64.exe', + sha256: digest, + })}\n`, + 'utf8', + ); + + try { + process.env.GITHUB_REPOSITORY = 'zhiiw/maka-agent'; + await prepareWindowsUpgradeBaseline('1.2.3', outputDirectory, { + manifestPath, + run: async (command, args) => { + calls.push({ command, args }); + await mkdir(outputDirectory, { recursive: true }); + await writeFile(join(outputDirectory, 'Maka-1.2.2-win-x64.exe'), 'fixture'); + }, + checksum: async () => digest, + }); + assert.deepEqual(calls, [ + { + command: 'gh', + args: [ + 'release', + 'download', + 'v1.2.2', + '--repo', + 'apache/maka', + '--pattern', + 'Maka-1.2.2-win-x64.exe', + '--dir', + outputDirectory, + ], + }, + ]); + } finally { + if (previousRepository === undefined) delete process.env.GITHUB_REPOSITORY; + else process.env.GITHUB_REPOSITORY = previousRepository; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index e2f16e814e..0ec8f0bb16 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -181,7 +181,9 @@ test('Desktop packaging does not distribute the retired bundled Git runtime', () false, ); assert.equal( - resources.some(({ to }) => to === 'git' || to.startsWith('licenses/git')), + resources.some( + ({ to }) => to === 'git' || to === 'licenses/git' || to.startsWith('licenses/git/'), + ), false, ); assert.equal( @@ -194,6 +196,30 @@ test('Desktop packaging does not distribute the retired bundled Git runtime', () ); }); +test('Desktop packaging carries the Gitoxide helper, release manifest, and Cargo notices', () => { + const resources = desktopBuilderConfig.extraResources.map(({ from, to }) => ({ from, to })); + assert.deepEqual( + resources.filter(({ to }) => + [ + 'gitoxide', + 'gitoxide-helper.json', + 'licenses/gitoxide-helper/THIRD_PARTY_NOTICES.txt', + ].includes(to), + ), + [ + { from: '.generated/gitoxide-helper/gitoxide', to: 'gitoxide' }, + { + from: '.generated/gitoxide-helper/gitoxide-helper.json', + to: 'gitoxide-helper.json', + }, + { + from: '.generated/gitoxide-helper/THIRD_PARTY_NOTICES.txt', + to: 'licenses/gitoxide-helper/THIRD_PARTY_NOTICES.txt', + }, + ], + ); +}); + test('a successful Windows upgrade invalidates stale backup authority before best-effort cleanup', async () => { const source = await readFile( join(repoRoot, 'apps', 'desktop', 'build', 'installer.nsh'), diff --git a/scripts/verify-bundled-npm-runtime.mjs b/scripts/verify-bundled-npm-runtime.mjs new file mode 100644 index 0000000000..44ba6f961d --- /dev/null +++ b/scripts/verify-bundled-npm-runtime.mjs @@ -0,0 +1,290 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { chmod, lstat, mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { create as createTar } from 'tar'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +export async function verifyPreparedBundledNpm({ + resourcesRoot = join(repoRoot, 'apps', 'desktop', '.generated', 'bundled-npm'), +} = {}) { + const { resolveBundledNpmRuntime } = await import( + new URL('../packages/runtime-host/dist/server/bundled-npm-runtime.js', import.meta.url) + ); + const { runManagedDependencyProducerProcessInternal, runManagedNpmDependencyProvision } = + await import( + new URL( + '../packages/runtime-host/dist/server/managed-dependency-producer-process.js', + import.meta.url, + ) + ); + const capability = await resolveBundledNpmRuntime({ resourcesRoot }); + if (capability.npmVersion !== '12.0.2') { + throw new Error(`Expected bundled npm 12.0.2, found ${capability.npmVersion}.`); + } + const scratchProject = await mkdtemp(join(tmpdir(), 'maka-bundled-npm-smoke-')); + try { + const outputRoot = join(scratchProject, 'node_modules'); + const scratchRoot = join(scratchProject, '.maka-runtime'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + ]); + const manifestBytes = Buffer.from( + '{"name":"maka-bundled-npm-smoke","private":true,"packageManager":"npm@12.0.2"}\n', + ); + const lockfileBytes = Buffer.from( + '{"name":"maka-bundled-npm-smoke","lockfileVersion":3,"requires":true,"packages":{"":{"name":"maka-bundled-npm-smoke"}}}\n', + ); + await runManagedNpmDependencyProvision({ + runtime: capability, + producerInput: { + identity: { + protocolVersion: 1, + environmentId: digest(Buffer.concat([manifestBytes, lockfileBytes])), + manifestPath: 'package.json', + manifestSha256: digest(manifestBytes), + lockfilePath: 'package-lock.json', + lockfileSha256: digest(lockfileBytes), + packageManagerName: 'npm', + packageManagerVersion: capability.npmVersion, + nodeVersion: capability.nodeVersion, + nodeAbi: capability.nodeAbi, + platform: capability.platform, + arch: capability.arch, + producerRuntimeIdentitySha256: capability.runtimeIdentitySha256, + producerPolicyIdentitySha256: digest(Buffer.from('hermetic_dependency_builder_v1')), + policyVersion: 'managed_dependency_environment_v1', + }, + outputRoot, + scratchRoot, + manifestBytes, + lockfileBytes, + }, + }); + await verifyRealDependencyInstall({ + capability, + scratchProject: join(scratchProject, 'real-install'), + runManagedDependencyProducerProcessInternal, + }); + } finally { + await rm(scratchProject, { recursive: true, force: true }); + } + return capability; +} + +async function verifyRealDependencyInstall({ + capability, + scratchProject, + runManagedDependencyProducerProcessInternal, +}) { + const fixtureRoot = join(scratchProject, 'registry-fixture'); + const packageRoot = join(fixtureRoot, 'package'); + const tarballPath = join(fixtureRoot, 'maka-fixture-bin-1.0.0.tgz'); + const requestedProjectRoot = join(scratchProject, 'project'); + await Promise.all([ + mkdir(join(packageRoot, 'bin'), { recursive: true }), + mkdir(join(requestedProjectRoot, 'node_modules'), { recursive: true }), + mkdir(join(requestedProjectRoot, '.maka-runtime', 'home'), { recursive: true }), + mkdir(join(requestedProjectRoot, '.maka-runtime', 'cache'), { recursive: true }), + mkdir(join(requestedProjectRoot, '.maka-runtime', 'temp'), { recursive: true }), + ]); + // Windows hosted runners commonly expose TEMP through an 8.3 alias such as + // RUNNER~1. The producer owner canonicalizes cwd before spawn, so the + // verifier must build its Permission Model allowlist from the same identity. + const projectRoot = await realpath(requestedProjectRoot); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const homeRoot = join(scratchRoot, 'home'); + const cacheRoot = join(scratchRoot, 'cache'); + const tempRoot = join(scratchRoot, 'temp'); + await Promise.all([ + writeFile( + join(packageRoot, 'package.json'), + `${JSON.stringify({ + name: 'maka-fixture-bin', + version: '1.0.0', + bin: { 'maka-fixture': 'bin/cli.js' }, + })}\n`, + ), + writeFile(join(packageRoot, 'index.js'), 'export const installed = true;\n'), + writeFile(join(packageRoot, 'bin', 'cli.js'), '#!/usr/bin/env node\nconsole.log("fixture");\n'), + ]); + if (process.platform !== 'win32') await chmod(join(packageRoot, 'bin', 'cli.js'), 0o755); + await createTar({ cwd: fixtureRoot, file: tarballPath, gzip: true }, ['package']); + const tarball = await readFile(tarballPath); + const integrity = `sha512-${createHash('sha512').update(tarball).digest('base64')}`; + const registry = await startFixtureRegistry(tarball, integrity); + try { + const manifest = { + name: 'maka-bundled-npm-real-smoke', + private: true, + packageManager: 'npm@12.0.2', + dependencies: { 'maka-fixture-bin': '1.0.0' }, + }; + const resolved = `${registry.origin}maka-fixture-bin/-/maka-fixture-bin-1.0.0.tgz`; + const lockfile = { + name: manifest.name, + lockfileVersion: 3, + requires: true, + packages: { + '': { name: manifest.name, dependencies: manifest.dependencies }, + 'node_modules/maka-fixture-bin': { + version: '1.0.0', + resolved, + integrity, + bin: { 'maka-fixture': 'bin/cli.js' }, + }, + }, + }; + const userConfig = join(homeRoot, 'npmrc'); + const globalConfig = join(homeRoot, 'global-npmrc'); + const config = `registry=${registry.origin}\n`; + await Promise.all([ + writeFile(join(projectRoot, 'package.json'), `${JSON.stringify(manifest)}\n`), + writeFile(join(projectRoot, 'package-lock.json'), `${JSON.stringify(lockfile)}\n`), + writeFile(userConfig, config), + writeFile(globalConfig, config), + ]); + await runManagedDependencyProducerProcessInternal({ + argv: [ + capability.nodeExecutablePath, + '--permission', + ...(Number.parseInt(capability.nodeVersion.split('.')[0] ?? '', 10) >= 26 + ? ['--allow-net'] + : []), + `--allow-fs-read=${capability.npmRuntimeRoot}`, + `--allow-fs-read=${projectRoot}`, + `--allow-fs-write=${projectRoot}`, + capability.npmCliPath, + 'ci', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=true', + `--registry=${registry.origin}`, + `--cache=${cacheRoot}`, + `--userconfig=${userConfig}`, + `--globalconfig=${globalConfig}`, + ], + cwd: projectRoot, + monitorRoot: projectRoot, + env: { + HOME: homeRoot, + USERPROFILE: homeRoot, + npm_config_registry: registry.origin, + npm_config_audit: 'false', + npm_config_fund: 'false', + npm_config_ignore_scripts: 'true', + npm_config_update_notifier: 'false', + TEMP: tempRoot, + TMP: tempRoot, + TMPDIR: tempRoot, + ...(process.platform === 'win32' + ? { SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR } + : {}), + }, + timeoutMs: 60_000, + maxObservedBytes: 64 * 1024 * 1024, + maxObservedEntries: 10_000, + }); + const installed = JSON.parse( + await readFile(join(outputRoot, 'maka-fixture-bin', 'package.json'), 'utf8'), + ); + if (installed.name !== 'maka-fixture-bin' || installed.version !== '1.0.0') { + throw new Error('Bundled npm did not install the hermetic fixture package'); + } + const binPath = join( + outputRoot, + '.bin', + process.platform === 'win32' ? 'maka-fixture.cmd' : 'maka-fixture', + ); + const binInfo = await lstat(binPath); + if (process.platform === 'win32' ? !binInfo.isFile() : !binInfo.isSymbolicLink()) { + throw new Error('Bundled npm did not create the expected platform .bin entry'); + } + } finally { + await registry.close(); + } +} + +async function startFixtureRegistry(tarball, integrity) { + let origin = ''; + const server = createServer((request, response) => { + if (request.url === '/maka-fixture-bin') { + response.setHeader('content-type', 'application/json'); + response.end( + JSON.stringify({ + name: 'maka-fixture-bin', + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + name: 'maka-fixture-bin', + version: '1.0.0', + bin: { 'maka-fixture': 'bin/cli.js' }, + dist: { + integrity, + tarball: `${origin}maka-fixture-bin/-/maka-fixture-bin-1.0.0.tgz`, + }, + }, + }, + }), + ); + return; + } + if (request.url === '/maka-fixture-bin/-/maka-fixture-bin-1.0.0.tgz') { + response.setHeader('content-type', 'application/octet-stream'); + response.end(tarball); + return; + } + response.statusCode = 404; + response.end('not found'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Fixture registry did not bind TCP'); + origin = `http://127.0.0.1:${address.port}/`; + return { + origin, + close: () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + }; +} + +function digest(value) { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const capability = await verifyPreparedBundledNpm(); + console.log( + `Verified bundled npm ${capability.npmVersion} for ${capability.platform}-${capability.arch}.`, + ); +} diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index 43c51b85c0..e09b76f59e 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -1022,6 +1022,10 @@ export async function assertPackagedResources( // artifacts that were correct when they shipped. The canonical icon itself // is `requireCanonicalIcon` above, not this. requireAppIconCatalog = true, + // Current artifacts ship the attested npm runtime. Historical Windows + // upgrade fixtures predate it and are checked against their own contract. + requireBundledNpm = true, + requireGitoxideHelper = true, } = {}, ) { if (bundledGitContract !== 'forbidden' && bundledGitContract !== 'legacy-required') { @@ -1031,6 +1035,23 @@ export async function assertPackagedResources( const required = [ 'app.asar', 'bundled-tools.json', + ...(requireBundledNpm + ? [ + 'bundled-npm.json', + join('npm', 'bin', 'npm-cli.js'), + join('licenses', 'npm-cli', 'LICENSE'), + ] + : []), + ...(requireGitoxideHelper + ? [ + 'gitoxide-helper.json', + join( + 'gitoxide', + process.platform === 'win32' ? 'maka-gitoxide-helper.exe' : 'maka-gitoxide-helper', + ), + join('licenses', 'gitoxide-helper', 'THIRD_PARTY_NOTICES.txt'), + ] + : []), ...(requiresLegacyBundledGit ? [ 'bundled-git.json', diff --git a/scripts/verify-packaged-app.test.mjs b/scripts/verify-packaged-app.test.mjs index 1f7596d19d..3e129241d8 100644 --- a/scripts/verify-packaged-app.test.mjs +++ b/scripts/verify-packaged-app.test.mjs @@ -49,6 +49,29 @@ test('packaged resources forbid the retired bundled Git distribution', async () } }); +test('requires bundled npm only for the current release contract', async () => { + const currentPaths = []; + await assertPackagedResources('resources', { + requirePath: async (path) => currentPaths.push(path), + forbidPath: async () => {}, + requireWindowsSandbox: false, + }); + assert.ok(currentPaths.includes(join('resources', 'bundled-npm.json'))); + assert.ok(currentPaths.includes(join('resources', 'npm', 'bin', 'npm-cli.js'))); + assert.ok(currentPaths.includes(join('resources', 'licenses', 'npm-cli', 'LICENSE'))); + + const legacyPaths = []; + await assertPackagedResources('resources', { + requirePath: async (path) => legacyPaths.push(path), + forbidPath: async () => {}, + requireWindowsSandbox: false, + requireBundledNpm: false, + }); + assert.equal(legacyPaths.includes(join('resources', 'bundled-npm.json')), false); + assert.equal(legacyPaths.includes(join('resources', 'npm', 'bin', 'npm-cli.js')), false); + assert.equal(legacyPaths.includes(join('resources', 'licenses', 'npm-cli', 'LICENSE')), false); +}); + test('legacy packaged resources require the historical bundled Git contract', async () => { const required = []; const forbidden = []; @@ -58,6 +81,7 @@ test('legacy packaged resources require the historical bundled Git contract', as requireWindowsSandbox: false, bundledGitContract: 'legacy-required', requireCanonicalIcon: false, + requireGitoxideHelper: false, }); for (const path of [ diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index 6e94fa9926..912f03ace7 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -155,6 +155,7 @@ it('uses the product SemVer contract throughout Windows release verification', ( assert.equal(bumpedAutoupdateVersion('1.2.3'), '1.2.4'); const baseline = { + repository: 'apache/maka', version: '1.2.3-beta.1', tag: 'v1.2.3-beta.1', assetName: 'Maka-1.2.3-beta.1-win-x64.exe', diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index 6b2bf36265..94a1a227bf 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -131,6 +131,8 @@ export async function verifyPackagedWindowsApp( bundledGitContract: requiresCurrentContract ? 'forbidden' : 'legacy-required', requireCanonicalIcon: requiresCurrentContract, requireAppIconCatalog: requiresCurrentContract, + requireBundledNpm: requiresCurrentContract, + requireGitoxideHelper: requiresCurrentContract, }); if (requiresCurrentContract) await assertPackagedDependencyClosure(resources); else await requirePath(join(resources, 'git', 'cmd', 'git.exe')); diff --git a/scripts/windows-upgrade-baseline.json b/scripts/windows-upgrade-baseline.json index c3f19aed4e..4b390928c8 100644 --- a/scripts/windows-upgrade-baseline.json +++ b/scripts/windows-upgrade-baseline.json @@ -1,4 +1,5 @@ { + "repository": "apache/maka", "version": "0.1.9", "tag": "v0.1.9", "assetName": "Maka-0.1.9-win-x64.exe",