From 97752e1a4c69706d1fc8077912b5fc0569b078c7 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 14:50:54 +0800 Subject: [PATCH 01/86] feat(git): add short-lived Gitoxide admission helper --- .../workflows/gitoxide-helper-admission.yml | 57 + .gitignore | 3 + ...e-short-lived-helper-admission-v1.zh-CN.md | 100 ++ native/gitoxide-helper/Cargo.lock | 1431 +++++++++++++++++ native/gitoxide-helper/Cargo.toml | 33 + native/gitoxide-helper/rust-toolchain.toml | 21 + native/gitoxide-helper/src/main.rs | 146 ++ .../tests/repository_admission.rs | 175 ++ package.json | 1 + scripts/asf-license-headers.mjs | 1 + 10 files changed, 1968 insertions(+) create mode 100644 .github/workflows/gitoxide-helper-admission.yml create mode 100644 docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md create mode 100644 native/gitoxide-helper/Cargo.lock create mode 100644 native/gitoxide-helper/Cargo.toml create mode 100644 native/gitoxide-helper/rust-toolchain.toml create mode 100644 native/gitoxide-helper/src/main.rs create mode 100644 native/gitoxide-helper/tests/repository_admission.rs diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml new file mode 100644 index 0000000000..d3f8ac570f --- /dev/null +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -0,0 +1,57 @@ +# 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/**' + push: + branches: + - main + paths: + - '.github/workflows/gitoxide-helper-admission.yml' + - 'native/gitoxide-helper/**' + +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 + - 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 diff --git a/.gitignore b/.gitignore index 0f6f738912..664f0303c1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ 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. 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..fe3f23f83c --- /dev/null +++ b/docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md @@ -0,0 +1,100 @@ + + +# 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. 下一切片 + +下一 PR 只建立一个 owner 边界:由 Host/Storage 验证 helper artifact identity,并将一次 +repository observation 转换成 T1 前可消费的 opaque admission capability。source import、fresh +projection 与 candidate ref CAS 继续分别验证,不能在 admission PR 中顺手恢复旧 Git CLI adapter。 diff --git a/native/gitoxide-helper/Cargo.lock b/native/gitoxide-helper/Cargo.lock new file mode 100644 index 0000000000..b37203abb7 --- /dev/null +++ b/native/gitoxide-helper/Cargo.lock @@ -0,0 +1,1431 @@ +# 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", + "serde", + "serde_json", +] + +[[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..3c1bc18359 --- /dev/null +++ b/native/gitoxide-helper/Cargo.toml @@ -0,0 +1,33 @@ +# 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"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" 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..a762bcd5bd --- /dev/null +++ b/native/gitoxide-helper/src/main.rs @@ -0,0 +1,146 @@ +/* + * 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::{ + io::{self, Read}, + path::PathBuf, + process::ExitCode, +}; + +use serde::{Deserialize, Serialize}; + +const PROTOCOL_VERSION: u8 = 1; +const MAX_REQUEST_BYTES: u64 = 64 * 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct InspectRepositoryRequest { + protocol_version: u8, + operation: String, + repository_path: PathBuf, +} + +#[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")] + 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()?; + if request.protocol_version != PROTOCOL_VERSION { + return Err("unsupported_protocol_version"); + } + if request.operation != "inspect_repository" { + return Err("unsupported_operation"); + } + + let repository = gix::open::Options::isolated() + .strict_config(true) + .open(request.repository_path) + .map_err(|_| "repository_open_failed")? + .to_thread_local(); + + 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 => { + write_response(&Response::RepositoryRejected { + protocol_version: PROTOCOL_VERSION, + reason: "unsupported_object_format", + object_format: "sha256".to_owned(), + supported_object_formats: ["sha1"], + }); + Ok(ExitCode::from(2)) + } + _ => { + write_response(&Response::RepositoryRejected { + protocol_version: PROTOCOL_VERSION, + reason: "unsupported_object_format", + object_format: "unknown".to_owned(), + supported_object_formats: ["sha1"], + }); + Ok(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..bc5152c8c3 --- /dev/null +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -0,0 +1,175 @@ +/* + * 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}, + time::{SystemTime, UNIX_EPOCH}, +}; + +const HELPER: &str = env!("CARGO_BIN_EXE_maka-gitoxide-helper"); + +#[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"], + }) + ); +} + +fn invoke_helper(repository_path: &Path) -> 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(); + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "inspect_repository", + "repositoryPath": repository_path, + }); + child + .stdin + .take() + .unwrap() + .write_all(serde_json::to_string(&request).unwrap().as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +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 init(object_format: &str) -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "maka-gitoxide-helper-admission-{}-{nonce}", + 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.json b/package.json index 7c5c6c330e..42ce3e8613 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", 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', From 3453011d94bba819d94b709fdb2c5302cf7c5cb4 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 15:58:00 +0800 Subject: [PATCH 02/86] fix(git): classify unsupported repository formats --- native/gitoxide-helper/src/main.rs | 52 +++++++++------- .../tests/repository_admission.rs | 62 +++++++++++++++++++ 2 files changed, 93 insertions(+), 21 deletions(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index a762bcd5bd..33d99bb6de 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -82,11 +82,27 @@ fn run() -> Result { return Err("unsupported_operation"); } - let repository = gix::open::Options::isolated() + let repository = match gix::open::Options::isolated() .strict_config(true) .open(request.repository_path) - .map_err(|_| "repository_open_failed")? - .to_thread_local(); + { + 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 => { @@ -107,27 +123,21 @@ fn run() -> Result { }); Ok(ExitCode::SUCCESS) } - gix::hash::Kind::Sha256 => { - write_response(&Response::RepositoryRejected { - protocol_version: PROTOCOL_VERSION, - reason: "unsupported_object_format", - object_format: "sha256".to_owned(), - supported_object_formats: ["sha1"], - }); - Ok(ExitCode::from(2)) - } - _ => { - write_response(&Response::RepositoryRejected { - protocol_version: PROTOCOL_VERSION, - reason: "unsupported_object_format", - object_format: "unknown".to_owned(), - supported_object_formats: ["sha1"], - }); - Ok(ExitCode::from(2)) - } + gix::hash::Kind::Sha256 => Ok(reject_unsupported_object_format("sha256".to_owned())), + _ => Ok(reject_unsupported_object_format("unknown".to_owned())), } } +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() diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index bc5152c8c3..a5064a8153 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -73,6 +73,38 @@ fn rejects_sha256_before_returning_repository_identity() { ); } +#[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); +} + fn invoke_helper(repository_path: &Path) -> Output { let mut child = Command::new(HELPER) .env("PATH", "") @@ -123,6 +155,36 @@ impl RepositoryFixture { 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) From f37f7613728f6696c54302f7f373475178d33d27 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 22:14:03 +0800 Subject: [PATCH 03/86] feat(git): bind helper artifacts to opaque capabilities --- ...xide-helper-artifact-authority-v1.zh-CN.md | 115 ++++++++ ...e-short-lived-helper-admission-v1.zh-CN.md | 8 +- ...helper-artifact-authority-internal.test.ts | 164 +++++++++++ ...xide-helper-artifact-authority-internal.ts | 269 ++++++++++++++++++ 4 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/gitoxide-helper-artifact-authority-internal.test.ts create mode 100644 packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts 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..49bc1045e5 --- /dev/null +++ b/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md @@ -0,0 +1,115 @@ + + +# 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; +3. repository observation 再转换为 T1 前的 opaque admission capability。 + +在第 1 项完成以前,不接 Desktop/CLI,也不恢复旧 Git CLI adapter。 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 index fe3f23f83c..9af0a37afa 100644 --- a/docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md +++ b/docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md @@ -95,6 +95,8 @@ v1 的 `supportedObjectFormats` 固定为 `["sha1"]`。未来支持必须显式 ## 7. 下一切片 -下一 PR 只建立一个 owner 边界:由 Host/Storage 验证 helper artifact identity,并将一次 -repository observation 转换成 T1 前可消费的 opaque admission capability。source import、fresh -projection 与 candidate ref CAS 继续分别验证,不能在 admission PR 中顺手恢复旧 Git CLI adapter。 +后续 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/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/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..882da884b0 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts @@ -0,0 +1,269 @@ +/* + * 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 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, + 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', + ); + } + } +} From 39b5ebf5d09d557937217cddd6a65c6549e0d2d2 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 22:29:20 +0800 Subject: [PATCH 04/86] feat(git): own short-lived helper invocations --- .../workflows/gitoxide-helper-admission.yml | 22 + ...xide-helper-artifact-authority-v1.zh-CN.md | 3 +- ...toxide-helper-invocation-owner-v1.zh-CN.md | 93 +++++ ...itoxide-helper-invocation-internal.test.ts | 155 +++++++ .../gitoxide-helper-invocation-internal.ts | 393 ++++++++++++++++++ 5 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts create mode 100644 packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index d3f8ac570f..35e3b9e72c 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -22,12 +22,18 @@ on: 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' + - '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' + - 'docs/architecture/gitoxide-*.md' permissions: contents: read @@ -49,9 +55,25 @@ jobs: - 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: Install JavaScript dependencies without packaging hooks + run: npm ci --ignore-scripts + - 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-invocation-internal.test.js diff --git a/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md b/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md index 49bc1045e5..2e21c7c47b 100644 --- a/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md +++ b/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md @@ -109,7 +109,8 @@ admission 与每次 invocation resolve 都执行这套校验。它可以识别 后续只能按下面顺序推进: 1. 发布/安装 owner 把受信 helper identity 绑定到 signed product artifact; -2. 短生命周期 invocation owner 消费 opaque capability 并运行 strict helper protocol; +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..11657fe0f9 --- /dev/null +++ b/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md @@ -0,0 +1,93 @@ + + +# 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。 +不在该切片中实现 source import、worktree projection、candidate 或 ref CAS。 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/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts new file mode 100644 index 0000000000..34a2902a99 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -0,0 +1,393 @@ +/* + * 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_REQUEST_BYTES = 64 * 1024; +const MAX_STDOUT_BYTES = 64 * 1024; +const MAX_STDERR_BYTES = 16 * 1024; +const INVOCATION_TIMEOUT_MS = 5_000; +const SHA1_OID_PATTERN = /^[0-9a-f]{40}$/; +const OBJECT_FORMAT_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; +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', +]); + +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 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); +} + +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; +}): 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'), + 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 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 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); +} From 514c9d2de2600a4ed0483d558364a959d1e61e61 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 22:35:05 +0800 Subject: [PATCH 05/86] feat(git): issue repository admission capabilities --- .../workflows/gitoxide-helper-admission.yml | 6 +- ...toxide-helper-invocation-owner-v1.zh-CN.md | 5 +- ...epository-admission-capability-v1.zh-CN.md | 92 +++++++++++ ...me-workspace-version-authority-v1.zh-CN.md | 16 +- ...itory-admission-authority-internal.test.ts | 156 ++++++++++++++++++ ...repository-admission-authority-internal.ts | 106 ++++++++++++ 6 files changed, 376 insertions(+), 5 deletions(-) create mode 100644 docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts create mode 100644 packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index 35e3b9e72c..deb4861724 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -76,4 +76,8 @@ jobs: - 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-invocation-internal.test.js + 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-repository-admission-authority-internal.test.js diff --git a/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md b/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md index 11657fe0f9..f68359a811 100644 --- a/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md +++ b/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md @@ -89,5 +89,6 @@ packaged-release trust root。 ## 6. 下一切片 下一步只把 exact repository observation 转换成 T1 前可消费的 owner-bound opaque admission -capability,并绑定 canonical repository path、object format、HEAD commit/tree 与 observation protocol。 -不在该切片中实现 source import、worktree projection、candidate 或 ref CAS。 +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-repository-admission-capability-v1.zh-CN.md b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md new file mode 100644 index 0000000000..1c75cdc171 --- /dev/null +++ b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md @@ -0,0 +1,92 @@ + + +# Gitoxide repository admission capability v1 + +状态:Gitoxide 验证栈的最后一个 API-only Draft;尚未接 T1、source import 或 managed workspace。 + +## 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、source import、projection、candidate 与 ref CAS。 + +因此这些 PR 可以作为 Gitoxide backend 的验证栈审查,但在正式 release owner 和生产消费者接入前 +继续保持 Draft。 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/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..9797f96d53 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts @@ -0,0 +1,156 @@ +/* + * 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, + GitoxideRepositoryAdmissionAuthorityError, + requireGitoxideRepositoryAdmissionInternal, +} 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'], + }, + ); +}); + +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(); +} 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..bbc1f238bd --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -0,0 +1,106 @@ +/* + * 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 { realpath } from 'node:fs/promises'; +import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artifact-authority-internal.js'; +import { + inspectRepositoryWithGitoxideHelperInternal, + type GitoxideRepositoryRejectionV1, +} from './gitoxide-helper-invocation-internal.js'; + +export interface GitoxideRepositoryAdmissionCapability { + readonly kind: 'gitoxide_repository_admission_capability_v1'; +} + +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') { + super('Gitoxide repository admission capability is invalid'); + this.name = 'GitoxideRepositoryAdmissionAuthorityError'; + } +} + +interface AdmissionCapabilityRecord { + readonly admissionOwnerToken: object; + readonly state: GitoxideRepositoryAdmissionStateInternal; +} + +const admissions = 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; +} From 728a95008b9a128b9ffcbf3a0dcce64af66853fb Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 23:21:29 +0800 Subject: [PATCH 06/86] test(git): isolate concurrent helper fixtures --- native/gitoxide-helper/tests/repository_admission.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index a5064a8153..06891dbdba 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -22,10 +22,12 @@ use std::{ 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() { @@ -190,9 +192,10 @@ impl RepositoryFixture { .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}", - std::process::id() + "maka-gitoxide-helper-admission-{}-{nonce}-{sequence}", + std::process::id(), )); fs::create_dir_all(&root).unwrap(); let fixture = Self { root }; From f421c33015fa1554193edfee50ea4d6ab1877247 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 23:29:08 +0800 Subject: [PATCH 07/86] feat(git): import admitted source heads --- ...epository-admission-capability-v1.zh-CN.md | 4 +- ...oxide-source-import-data-plane-v1.zh-CN.md | 66 +++++ native/gitoxide-helper/Cargo.toml | 1 + native/gitoxide-helper/src/main.rs | 273 +++++++++++++++++- .../tests/repository_admission.rs | 95 +++++- ...itory-admission-authority-internal.test.ts | 67 +++++ .../gitoxide-helper-invocation-internal.ts | 158 ++++++++++ ...repository-admission-authority-internal.ts | 35 +++ 8 files changed, 681 insertions(+), 18 deletions(-) create mode 100644 docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md diff --git a/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md index 1c75cdc171..1aa81dd329 100644 --- a/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md +++ b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md @@ -19,7 +19,7 @@ # Gitoxide repository admission capability v1 -状态:Gitoxide 验证栈的最后一个 API-only Draft;尚未接 T1、source import 或 managed workspace。 +状态:Gitoxide control-plane admission Draft;source import data plane 作为下一独立切片消费该 capability。 ## 1. 主要不变量 @@ -86,7 +86,7 @@ capability 表示一次明确线性化点上的 immutable Git commit/tree snapsh - signed packaged-release trust root 与受保护安装路径; - Desktop/CLI 消费者; -- T1 durable admission、source import、projection、candidate 与 ref CAS。 +- T1 durable admission、projection、candidate 与 ref CAS;source import 由后续独立 Draft 实现。 因此这些 PR 可以作为 Gitoxide backend 的验证栈审查,但在正式 release owner 和生产消费者接入前 继续保持 Draft。 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..c58eee81d4 --- /dev/null +++ b/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md @@ -0,0 +1,66 @@ + + +# 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。 + +## 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/native/gitoxide-helper/Cargo.toml b/native/gitoxide-helper/Cargo.toml index 3c1bc18359..66a0f19dc0 100644 --- a/native/gitoxide-helper/Cargo.toml +++ b/native/gitoxide-helper/Cargo.toml @@ -31,3 +31,4 @@ path = "src/main.rs" gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha256"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +unicode-normalization = "0.1" diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 33d99bb6de..2ffe301fc1 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -18,22 +18,41 @@ */ use std::{ + collections::HashSet, + fs, io::{self, Read}, path::PathBuf, process::ExitCode, }; use serde::{Deserialize, Serialize}; +use unicode_normalization::UnicodeNormalization; const PROTOCOL_VERSION: u8 = 1; const MAX_REQUEST_BYTES: u64 = 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; #[derive(Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct InspectRepositoryRequest { - protocol_version: u8, - operation: String, - repository_path: PathBuf, +#[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, + }, } #[derive(Serialize)] @@ -54,6 +73,18 @@ enum Response<'a> { 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")] HelperError { protocol_version: u8, reason: &'a str, @@ -75,16 +106,43 @@ fn main() -> ExitCode { fn run() -> Result { let request = read_request()?; - if request.protocol_version != PROTOCOL_VERSION { - return Err("unsupported_protocol_version"); + 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, + ) + } } - if request.operation != "inspect_repository" { - return Err("unsupported_operation"); +} + +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(request.repository_path) + .open(repository_path) { Ok(repository) => repository.to_thread_local(), Err(gix::open::Error::Config(gix::config::Error::ConfigTypedString(error))) @@ -128,6 +186,199 @@ fn run() -> Result { } } +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(); + + match fs::symlink_metadata(&destination_repository_path) { + Ok(_) => return Err("import_destination_not_fresh"), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(_) => return Err("import_destination_unreadable"), + } + let destination = gix::init_bare(&destination_repository_path) + .map_err(|_| "import_destination_create_failed")?; + 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 = ImportStats::default(); + copy_source_tree(&source, &destination, source_tree, "", &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(); + 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, + stats: &mut ImportStats, +) -> Result<(), &'static str> { + 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) { + return Err("unsupported_source_path"); + } + let relative_path = if prefix.is_empty() { + component.to_owned() + } else { + format!("{prefix}/{component}") + }; + let folded_path: String = relative_path.nfc().flat_map(char::to_lowercase).collect(); + if !stats.folded_paths.insert(folded_path) { + return Err("source_path_collision"); + } + match entry.mode().kind() { + gix::objs::tree::EntryKind::Tree => { + copy_source_tree( + source, + destination, + entry.object_id(), + &relative_path, + stats, + )?; + } + gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable => { + stats.files = stats + .files + .checked_add(1) + .filter(|count| *count <= MAX_IMPORT_FILES) + .ok_or("source_file_limit_exceeded")?; + let header = entry.id().header().map_err(|_| "source_blob_unavailable")?; + if header.kind() != gix::objs::Kind::Blob || header.size() > MAX_IMPORT_FILE_BYTES { + return Err("source_file_limit_exceeded"); + } + stats.bytes = stats + .bytes + .checked_add(header.size()) + .filter(|bytes| *bytes <= MAX_IMPORT_BYTES) + .ok_or("source_byte_limit_exceeded")?; + 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 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(Default)] +struct ImportStats { + files: u64, + bytes: u64, + folded_paths: HashSet, +} + fn reject_unsupported_object_format(object_format: String) -> ExitCode { write_response(&Response::RepositoryRejected { protocol_version: PROTOCOL_VERSION, @@ -138,7 +389,7 @@ fn reject_unsupported_object_format(object_format: String) -> ExitCode { ExitCode::from(2) } -fn read_request() -> Result { +fn read_request() -> Result { let mut bytes = Vec::new(); io::stdin() .take(MAX_REQUEST_BYTES + 1) diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index 06891dbdba..9b5499e1ac 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -107,7 +107,76 @@ fn observes_raw_head_identity_instead_of_replacement_ref_semantics() { 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()); +} + 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") @@ -118,11 +187,6 @@ fn invoke_helper(repository_path: &Path) -> Output { .stderr(Stdio::piped()) .spawn() .unwrap(); - let request = serde_json::json!({ - "protocolVersion": 1, - "operation": "inspect_repository", - "repositoryPath": repository_path, - }); child .stdin .take() @@ -132,6 +196,27 @@ fn invoke_helper(repository_path: &Path) -> Output { 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() +} + struct RepositoryFixture { root: PathBuf, } 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 index 9797f96d53..70843bb65d 100644 --- 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 @@ -32,6 +32,7 @@ import { import { admitGitoxideRepositoryInternal, GitoxideRepositoryAdmissionAuthorityError, + importAdmittedGitoxideRepositoryInternal, requireGitoxideRepositoryAdmissionInternal, } from '../server/gitoxide-repository-admission-authority-internal.js'; @@ -116,6 +117,66 @@ test('returns a policy rejection without issuing an admission capability', async ); }); +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 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, + 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, + destinationRepositoryPath: join(repositoryPath, 'forged.git'), + baselineRef: 'refs/maka/forged', + }), + (error) => + error instanceof GitoxideRepositoryAdmissionAuthorityError && + error.code === 'gitoxide_repository_admission_capability_invalid', + ); +}); + async function admittedHelper(): Promise { if (admittedHelperPromise) return admittedHelperPromise; admittedHelperPromise = (async () => { @@ -154,3 +215,9 @@ async function createRepository(t: TestContext, objectFormat: 'sha1' | 'sha256') 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/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts index 34a2902a99..9f34d435c4 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -32,6 +32,7 @@ const MAX_STDERR_BYTES = 16 * 1024; const INVOCATION_TIMEOUT_MS = 5_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', @@ -41,6 +42,31 @@ const HELPER_ERROR_REASONS = new Set([ 'repository_open_failed', 'head_commit_unavailable', 'head_tree_unavailable', + 'baseline_commit_write_failed', + 'baseline_publish_failed', + 'baseline_ref_outside_maka_namespace', + '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', + '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_tree_copy_failed', + 'source_tree_identity_mismatch', + 'source_tree_invalid', + 'source_tree_unavailable', + 'unsupported_source_entry_kind', + 'unsupported_source_path', ]); export interface GitoxideRepositoryObservationV1 { @@ -63,6 +89,19 @@ 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 type GitoxideHelperInvocationErrorCode = | 'gitoxide_helper_invocation_invalid' | 'gitoxide_helper_invocation_spawn_failed' @@ -129,6 +168,61 @@ export async function inspectRepositoryWithGitoxideHelperInternal(input: { 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); +} + interface HelperProcessOutcome { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null; @@ -293,6 +387,70 @@ function decodeOutcome(outcome: HelperProcessOutcome): GitoxideRepositoryInspect ); } +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 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, [ 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 index bbc1f238bd..115128ed20 100644 --- a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -20,7 +20,9 @@ import { realpath } from 'node:fs/promises'; import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artifact-authority-internal.js'; import { + importSourceHeadWithGitoxideHelperInternal, inspectRepositoryWithGitoxideHelperInternal, + type GitoxideSourceImportObservationV1, type GitoxideRepositoryRejectionV1, } from './gitoxide-helper-invocation-internal.js'; @@ -104,3 +106,36 @@ export function requireGitoxideRepositoryAdmissionInternal( } return state.state; } + +export async function importAdmittedGitoxideRepositoryInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly admissionOwnerToken: object; + readonly repositoryCapability: GitoxideRepositoryAdmissionCapability; + 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', + ); + } + return result; +} From 8b555883d0b68933257c9b9927e427dd90f8ca36 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 23:44:39 +0800 Subject: [PATCH 08/86] build(git): lock source import dependency --- native/gitoxide-helper/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/native/gitoxide-helper/Cargo.lock b/native/gitoxide-helper/Cargo.lock index b37203abb7..c71ae8d448 100644 --- a/native/gitoxide-helper/Cargo.lock +++ b/native/gitoxide-helper/Cargo.lock @@ -1033,6 +1033,7 @@ dependencies = [ "gix", "serde", "serde_json", + "unicode-normalization", ] [[package]] From 2348d69dfdb5abeb7bc2da659e1993917c6412ff Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:05:16 +0800 Subject: [PATCH 09/86] fix(git): bound managed tree traversal --- native/gitoxide-helper/src/main.rs | 206 ++++++++++++++++++++++++++--- 1 file changed, 185 insertions(+), 21 deletions(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 2ffe301fc1..4f3fe662f0 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -33,6 +33,17 @@ const MAX_REQUEST_BYTES: u64 = 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, +}; #[derive(Deserialize)] #[serde( @@ -242,8 +253,16 @@ fn import_source_head( fs::create_dir(destination_repository_path.join("hooks")) .map_err(|_| "import_hooks_cleanup_failed")?; - let mut stats = ImportStats::default(); - copy_source_tree(&source, &destination, source_tree, "", &mut stats)?; + 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(), @@ -289,8 +308,11 @@ fn copy_source_tree( destination: &gix::Repository, tree_oid: gix::hash::ObjectId, prefix: &str, - stats: &mut ImportStats, + 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")?; @@ -298,7 +320,9 @@ fn copy_source_tree( 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) { + 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() { @@ -306,10 +330,7 @@ fn copy_source_tree( } else { format!("{prefix}/{component}") }; - let folded_path: String = relative_path.nfc().flat_map(char::to_lowercase).collect(); - if !stats.folded_paths.insert(folded_path) { - return Err("source_path_collision"); - } + stats.observe_entry(&relative_path, policy)?; match entry.mode().kind() { gix::objs::tree::EntryKind::Tree => { copy_source_tree( @@ -317,24 +338,17 @@ fn copy_source_tree( 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 => { - stats.files = stats - .files - .checked_add(1) - .filter(|count| *count <= MAX_IMPORT_FILES) - .ok_or("source_file_limit_exceeded")?; let header = entry.id().header().map_err(|_| "source_blob_unavailable")?; - if header.kind() != gix::objs::Kind::Blob || header.size() > MAX_IMPORT_FILE_BYTES { - return Err("source_file_limit_exceeded"); + if header.kind() != gix::objs::Kind::Blob { + return Err("source_blob_invalid"); } - stats.bytes = stats - .bytes - .checked_add(header.size()) - .filter(|bytes| *bytes <= MAX_IMPORT_BYTES) - .ok_or("source_byte_limit_exceeded")?; + stats.observe_blob(header.size(), policy)?; let blob = entry .object() .map_err(|_| "source_blob_unavailable")? @@ -372,13 +386,163 @@ fn is_supported_source_component(component: &str) -> bool { && !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 ImportStats { +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") + ); + } +} + fn reject_unsupported_object_format(object_format: String) -> ExitCode { write_response(&Response::RepositoryRejected { protocol_version: PROTOCOL_VERSION, From 59d62ce85838cc203bfea7e4f6786c5ce23f6300 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:06:30 +0800 Subject: [PATCH 10/86] fix(runtime-host): preserve tree policy failures --- .../src/server/gitoxide-helper-invocation-internal.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts index 9f34d435c4..416c9ef773 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -61,10 +61,15 @@ const HELPER_ERROR_REASONS = new Set([ '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', 'unsupported_source_entry_kind', 'unsupported_source_path', ]); From 5bf0ef487733299224de86d8082f0530997f4415 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:48:20 +0800 Subject: [PATCH 11/86] style(gitoxide): match pinned Rust formatting --- native/gitoxide-helper/src/main.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 4f3fe662f0..a9baee94c2 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -410,11 +410,7 @@ struct ManagedTreeStats { } impl ManagedTreeStats { - fn enter_tree( - &mut self, - depth: u64, - policy: ManagedTreePolicy, - ) -> Result<(), &'static str> { + fn enter_tree(&mut self, depth: u64, policy: ManagedTreePolicy) -> Result<(), &'static str> { if depth > policy.max_depth { return Err("source_tree_depth_exceeded"); } @@ -452,11 +448,7 @@ impl ManagedTreeStats { Ok(()) } - fn observe_blob( - &mut self, - size: u64, - policy: ManagedTreePolicy, - ) -> Result<(), &'static str> { + fn observe_blob(&mut self, size: u64, policy: ManagedTreePolicy) -> Result<(), &'static str> { if size > policy.max_file_bytes { return Err("source_file_limit_exceeded"); } From dd5430f2362a5848ae7c26b619369213e396189a Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:55:08 +0800 Subject: [PATCH 12/86] fix(gitoxide): make source import restartable --- native/gitoxide-helper/src/main.rs | 46 +++++++++++++------ .../tests/repository_admission.rs | 43 +++++++++++++++++ 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index a9baee94c2..d8a68a0965 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -237,13 +237,17 @@ fn import_source_head( .map_err(|_| "source_head_tree_unavailable")? .detach(); - match fs::symlink_metadata(&destination_repository_path) { + 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 => {} + 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"), - } - let destination = gix::init_bare(&destination_repository_path) - .map_err(|_| "import_destination_create_failed")?; + }; if destination.object_hash() != gix::hash::Kind::Sha1 { return Err("import_destination_object_format_mismatch"); } @@ -280,14 +284,30 @@ fn import_source_head( .map_err(|_| "baseline_commit_write_failed")? .id() .detach(); - destination - .reference( - baseline_ref.as_str(), - baseline_commit, - gix::refs::transaction::PreviousValue::MustNotExist, - "maka managed workspace baseline", - ) - .map_err(|_| "baseline_publish_failed")?; + 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, diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index 9b5499e1ac..e2f03a305d 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -166,6 +166,49 @@ fn imports_an_exact_source_head_into_a_fresh_managed_repository() { ["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() + ); } fn invoke_helper(repository_path: &Path) -> Output { From 66e534125eedd3a45a1ba8d2a681f2763fb56108 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 23:54:20 +0800 Subject: [PATCH 13/86] feat(git): publish exact-base successors --- ...oxide-source-import-data-plane-v1.zh-CN.md | 3 + ...e-successor-ref-cas-data-plane-v1.zh-CN.md | 59 ++++++ native/gitoxide-helper/src/main.rs | 179 +++++++++++++++- .../tests/repository_admission.rs | 113 ++++++++++ ...itory-admission-authority-internal.test.ts | 88 ++++++++ .../gitoxide-helper-invocation-internal.ts | 198 +++++++++++++++++- ...repository-admission-authority-internal.ts | 117 ++++++++++- 7 files changed, 751 insertions(+), 6 deletions(-) create mode 100644 docs/architecture/gitoxide-successor-ref-cas-data-plane-v1.zh-CN.md 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 index c58eee81d4..176ee4a09a 100644 --- a/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md +++ b/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md @@ -29,6 +29,9 @@ > 只把该 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; 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..da606c4d54 --- /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; +2. M1.3 product composition 消费 admission/import/candidate/projection capabilities; +3. 数据面完成后,从最新 `main` 重建 M2.2 candidate durable owner 与 M2.4 Write/Edit 生产闭环。 diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index d8a68a0965..efb666393f 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -29,7 +29,7 @@ use serde::{Deserialize, Serialize}; use unicode_normalization::UnicodeNormalization; const PROTOCOL_VERSION: u8 = 1; -const MAX_REQUEST_BYTES: u64 = 64 * 1024; +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; @@ -64,6 +64,14 @@ enum Request { 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, + }, } #[derive(Serialize)] @@ -96,6 +104,26 @@ enum Response<'a> { 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")] HelperError { protocol_version: u8, reason: &'a str, @@ -140,6 +168,23 @@ fn run() -> Result { 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, + ) + } } } @@ -395,6 +440,138 @@ fn copy_source_tree( Ok(()) } +fn create_successor( + repository_path: PathBuf, + expected_base_commit_oid: String, + target_ref: String, + path: String, + content: String, +) -> 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"); + } + + 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(); + 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 + .find_reference(target_ref.as_str()) + .map_err(|_| "target_ref_unavailable")? + .into_fully_peeled_id() + .map_err(|_| "target_ref_unavailable")? + .detach(); + if current != expected_base && current != 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.to_string(), + target_ref, + }); + return Ok(ExitCode::from(3)); + } + if current == 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 is_canonical_successor_path(path: &str) -> bool { + path.len() <= 4096 + && !path.is_empty() + && !path.starts_with('/') + && !path.contains('\\') + && !path.contains('\0') + && path.split('/').all(|component| { + !component.is_empty() + && component != "." + && component != ".." + && !component.eq_ignore_ascii_case(".git") + }) +} + fn is_supported_source_component(component: &str) -> bool { !component.is_empty() && component != "." diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index e2f03a305d..260571ef45 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -211,6 +211,108 @@ fn repairs_an_initialized_import_destination_without_a_published_baseline() { ); } +#[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 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"] + ); +} + fn invoke_helper(repository_path: &Path) -> Output { invoke_request(serde_json::json!({ "protocolVersion": 1, @@ -260,6 +362,17 @@ fn git_bare_succeeds(repository: &Path, args: [&str; N]) -> bool .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, } 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 index 70843bb65d..69b8fa98a7 100644 --- 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 @@ -31,6 +31,7 @@ import { } from '../server/gitoxide-helper-artifact-authority-internal.js'; import { admitGitoxideRepositoryInternal, + createGitoxideSuccessorInternal, GitoxideRepositoryAdmissionAuthorityError, importAdmittedGitoxideRepositoryInternal, requireGitoxideRepositoryAdmissionInternal, @@ -139,6 +140,7 @@ test('imports only the exact repository identity bound to the admission capabili 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, @@ -152,6 +154,7 @@ test('imports only the exact repository identity bound to the admission capabili ...helper, admissionOwnerToken, repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, destinationRepositoryPath, baselineRef: 'refs/maka/baseline', }); @@ -168,6 +171,7 @@ test('imports only the exact repository identity bound to the admission capabili ...helper, admissionOwnerToken: {}, repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, destinationRepositoryPath: join(repositoryPath, 'forged.git'), baselineRef: 'refs/maka/forged', }), @@ -177,6 +181,90 @@ test('imports only the exact repository identity bound to the admission capabili ); }); +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', + ); +}); + async function admittedHelper(): Promise { if (admittedHelperPromise) return admittedHelperPromise; admittedHelperPromise = (async () => { diff --git a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts index 416c9ef773..b9b6b6aa19 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -26,7 +26,8 @@ import { verifyGitoxideHelperArtifactForInvocationInternal, } from './gitoxide-helper-artifact-authority-internal.js'; -const MAX_REQUEST_BYTES = 64 * 1024; +const MAX_SUCCESSOR_CONTENT_BYTES = 64 * 1024 * 1024; +const MAX_REQUEST_BYTES = MAX_SUCCESSOR_CONTENT_BYTES + 64 * 1024; const MAX_STDOUT_BYTES = 64 * 1024; const MAX_STDERR_BYTES = 16 * 1024; const INVOCATION_TIMEOUT_MS = 5_000; @@ -45,12 +46,19 @@ const HELPER_ERROR_REASONS = new Set([ 'baseline_commit_write_failed', 'baseline_publish_failed', 'baseline_ref_outside_maka_namespace', + 'base_commit_unavailable', + 'base_path_lookup_failed', + 'base_tree_unavailable', + 'blob_write_failed', + 'commit_write_failed', '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_successor_path', 'source_blob_copy_failed', 'source_blob_identity_mismatch', 'source_blob_invalid', @@ -70,6 +78,13 @@ const HELPER_ERROR_REASONS = new Set([ 'source_tree_invalid', 'source_tree_unavailable', 'source_tree_visit_limit_exceeded', + '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_source_entry_kind', 'unsupported_source_path', ]); @@ -107,6 +122,30 @@ export interface GitoxideSourceImportObservationV1 { 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 type GitoxideHelperInvocationErrorCode = | 'gitoxide_helper_invocation_invalid' | 'gitoxide_helper_invocation_spawn_failed' @@ -228,6 +267,64 @@ export async function importSourceHeadWithGitoxideHelperInternal(input: { 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); +} + interface HelperProcessOutcome { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null; @@ -421,6 +518,86 @@ function decodeSourceImportOutcome( ); } +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)) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + `Gitoxide helper could not publish the successor: ${value.reason}`, + value.reason, + ); + } + const stderr = outcome.stderr.toString('utf8').trim(); + throw protocolInvalid( + `Gitoxide helper exit code and response disagree${stderr ? `: ${stderr}` : ''}`, + ); +} + +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 isSourceImportObservation(value: unknown): value is GitoxideSourceImportObservationV1 { return ( hasExactKeys(value, [ @@ -519,6 +696,25 @@ function hasExactKeys( return keys.length === expected.length && keys.every((key, index) => key === expected[index]); } +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 helperEnvironment(): NodeJS.ProcessEnv { return { PATH: '', 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 index 115128ed20..f57bb1c536 100644 --- a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -22,6 +22,8 @@ import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artif import { importSourceHeadWithGitoxideHelperInternal, inspectRepositoryWithGitoxideHelperInternal, + createSuccessorWithGitoxideHelperInternal, + type GitoxideSuccessorPublishedV1, type GitoxideSourceImportObservationV1, type GitoxideRepositoryRejectionV1, } from './gitoxide-helper-invocation-internal.js'; @@ -30,6 +32,18 @@ export interface GitoxideRepositoryAdmissionCapability { readonly kind: 'gitoxide_repository_admission_capability_v1'; } +export interface GitoxideManagedRepositoryCapability { + readonly kind: 'gitoxide_managed_repository_capability_v1'; +} + +export interface GitoxideManagedRepositoryImportResultV1 extends GitoxideSourceImportObservationV1 { + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; +} + +export interface GitoxideManagedRepositorySuccessorResultV1 extends GitoxideSuccessorPublishedV1 { + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; +} + export interface GitoxideRepositoryAdmissionStateInternal { readonly protocolVersion: 1; readonly repositoryPath: string; @@ -46,8 +60,16 @@ export type GitoxideRepositoryAdmissionResultV1 = | GitoxideRepositoryRejectionV1; export class GitoxideRepositoryAdmissionAuthorityError extends Error { - constructor(readonly code: 'gitoxide_repository_admission_capability_invalid') { - super('Gitoxide repository admission capability is invalid'); + constructor( + readonly code: + | 'gitoxide_repository_admission_capability_invalid' + | 'gitoxide_managed_repository_base_mismatch', + ) { + super( + code === 'gitoxide_managed_repository_base_mismatch' + ? 'Gitoxide managed repository base no longer matches' + : 'Gitoxide repository admission capability is invalid', + ); this.name = 'GitoxideRepositoryAdmissionAuthorityError'; } } @@ -59,6 +81,16 @@ interface AdmissionCapabilityRecord { 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(); + export async function admitGitoxideRepositoryInternal(input: { readonly invocationOwnerToken: object; readonly helperCapability: GitoxideHelperInvocationCapability; @@ -112,10 +144,11 @@ export async function importAdmittedGitoxideRepositoryInternal(input: { readonly helperCapability: GitoxideHelperInvocationCapability; readonly admissionOwnerToken: object; readonly repositoryCapability: GitoxideRepositoryAdmissionCapability; + readonly managedRepositoryOwnerToken: object; readonly destinationRepositoryPath: string; readonly baselineRef: string; readonly abortSignal?: AbortSignal; -}): Promise { +}): Promise { const source = requireGitoxideRepositoryAdmissionInternal( input.admissionOwnerToken, input.repositoryCapability, @@ -137,5 +170,81 @@ export async function importAdmittedGitoxideRepositoryInternal(input: { 'gitoxide_repository_admission_capability_invalid', ); } - return result; + 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 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 }); +} + +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; } From 25f56f6157a3dcd02829d115927099411a720ed2 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 23:56:52 +0800 Subject: [PATCH 14/86] build(git): enable tree editing --- native/gitoxide-helper/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/gitoxide-helper/Cargo.toml b/native/gitoxide-helper/Cargo.toml index 66a0f19dc0..4e0affcca9 100644 --- a/native/gitoxide-helper/Cargo.toml +++ b/native/gitoxide-helper/Cargo.toml @@ -28,7 +28,7 @@ name = "maka-gitoxide-helper" path = "src/main.rs" [dependencies] -gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha256"] } +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" From c739ed98548a23134761064bf573ae8e2794321a Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:09:06 +0800 Subject: [PATCH 15/86] fix(git): validate complete successor trees --- native/gitoxide-helper/src/main.rs | 60 +++++++++++++++++++ .../tests/repository_admission.rs | 41 +++++++++++++ 2 files changed, 101 insertions(+) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index efb666393f..ce910cdce7 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -498,6 +498,7 @@ fn create_successor( .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(), @@ -558,6 +559,65 @@ fn create_successor( 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() <= 4096 && !path.is_empty() diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index 260571ef45..b1a6ad8d76 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -313,6 +313,47 @@ fn rejects_a_successor_when_the_target_ref_no_longer_matches_the_base() { ); } +#[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, From 24830e573238f35ffdefc97c770784c5d143978a Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 00:05:12 +0800 Subject: [PATCH 16/86] feat(git): materialize exact projections --- ...de-fresh-projection-data-plane-v1.zh-CN.md | 54 ++ ...e-successor-ref-cas-data-plane-v1.zh-CN.md | 2 +- native/gitoxide-helper/Cargo.lock | 1 + native/gitoxide-helper/Cargo.toml | 3 + native/gitoxide-helper/src/main.rs | 575 +++++++++++++++++- .../tests/repository_admission.rs | 127 ++++ ...itory-admission-authority-internal.test.ts | 83 +++ .../gitoxide-helper-invocation-internal.ts | 294 ++++++++- ...repository-admission-authority-internal.ts | 101 +++ 9 files changed, 1235 insertions(+), 5 deletions(-) create mode 100644 docs/architecture/gitoxide-fresh-projection-data-plane-v1.zh-CN.md 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-successor-ref-cas-data-plane-v1.zh-CN.md b/docs/architecture/gitoxide-successor-ref-cas-data-plane-v1.zh-CN.md index da606c4d54..1276a9c66b 100644 --- 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 @@ -54,6 +54,6 @@ ## 后续依赖 -1. Gitoxide fresh projection materialization/observation; +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/native/gitoxide-helper/Cargo.lock b/native/gitoxide-helper/Cargo.lock index c71ae8d448..8d00ea3da4 100644 --- a/native/gitoxide-helper/Cargo.lock +++ b/native/gitoxide-helper/Cargo.lock @@ -1031,6 +1031,7 @@ name = "maka-gitoxide-helper" version = "0.0.0" dependencies = [ "gix", + "libc", "serde", "serde_json", "unicode-normalization", diff --git a/native/gitoxide-helper/Cargo.toml b/native/gitoxide-helper/Cargo.toml index 4e0affcca9..2d0f8c547c 100644 --- a/native/gitoxide-helper/Cargo.toml +++ b/native/gitoxide-helper/Cargo.toml @@ -32,3 +32,6 @@ gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha2 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/src/main.rs b/native/gitoxide-helper/src/main.rs index ce910cdce7..1672f96951 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -19,9 +19,9 @@ use std::{ collections::HashSet, - fs, - io::{self, Read}, - path::PathBuf, + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + path::{Path, PathBuf}, process::ExitCode, }; @@ -44,6 +44,9 @@ const MANAGED_TREE_POLICY_V1: ManagedTreePolicy = ManagedTreePolicy { max_file_bytes: MAX_IMPORT_FILE_BYTES, max_bytes: MAX_IMPORT_BYTES, }; +const MAX_PROJECTION_FILE_BYTES: u64 = MAX_IMPORT_FILE_BYTES; +const MAX_PROJECTION_BYTES: u64 = MAX_IMPORT_BYTES; +const MAX_PROJECTION_FILES: u64 = MAX_IMPORT_FILES; #[derive(Deserialize)] #[serde( @@ -72,6 +75,18 @@ enum Request { path: String, content: 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, + }, } #[derive(Serialize)] @@ -124,6 +139,38 @@ enum Response<'a> { target_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")] HelperError { protocol_version: u8, reason: &'a str, @@ -185,6 +232,24 @@ fn run() -> Result { content, ) } + 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) + } } } @@ -632,6 +697,510 @@ fn is_canonical_successor_path(path: &str) -> bool { }) } +#[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)?; + + 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() > MAX_PROJECTION_FILE_BYTES + { + return Err("projection_file_limit_exceeded"); + } + stats.files = stats + .files + .checked_add(1) + .filter(|count| *count <= MAX_PROJECTION_FILES) + .ok_or("projection_file_limit_exceeded")?; + stats.bytes = stats + .bytes + .checked_add(header.size()) + .filter(|bytes| *bytes <= MAX_PROJECTION_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)?; + 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() > MAX_PROJECTION_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 <= MAX_PROJECTION_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 <= MAX_PROJECTION_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 != "." diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index b1a6ad8d76..22a051ab38 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -313,6 +313,133 @@ fn rejects_a_successor_when_the_target_ref_no_longer_matches_the_base() { ); } +#[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(); 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 index 69b8fa98a7..1f85f9eb6b 100644 --- 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 @@ -34,6 +34,8 @@ import { createGitoxideSuccessorInternal, GitoxideRepositoryAdmissionAuthorityError, importAdmittedGitoxideRepositoryInternal, + materializeGitoxideProjectionInternal, + observeGitoxideProjectionInternal, requireGitoxideRepositoryAdmissionInternal, } from '../server/gitoxide-repository-admission-authority-internal.js'; @@ -265,6 +267,87 @@ test('binds successor publication to the imported repository capability and exac ); }); +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 () => { diff --git a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts index b9b6b6aa19..523218555b 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -31,6 +31,7 @@ const MAX_REQUEST_BYTES = MAX_SUCCESSOR_CONTENT_BYTES + 64 * 1024; const MAX_STDOUT_BYTES = 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}$/; @@ -51,6 +52,8 @@ const HELPER_ERROR_REASONS = new Set([ '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', @@ -58,6 +61,7 @@ const HELPER_ERROR_REASONS = new Set([ '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', @@ -78,6 +82,22 @@ const HELPER_ERROR_REASONS = new Set([ 'source_tree_invalid', 'source_tree_unavailable', 'source_tree_visit_limit_exceeded', + 'projection_blob_invalid', + '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', @@ -85,6 +105,8 @@ const HELPER_ERROR_REASONS = new Set([ 'tree_edit_failed', 'tree_write_failed', 'unsupported_base_path_kind', + 'unsupported_projection_entry_kind', + 'unsupported_projection_path', 'unsupported_source_entry_kind', 'unsupported_source_path', ]); @@ -146,6 +168,45 @@ export interface GitoxideSuccessorRejectedV1 { export type GitoxideSuccessorResultV1 = GitoxideSuccessorPublishedV1 | 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 type GitoxideHelperInvocationErrorCode = | 'gitoxide_helper_invocation_invalid' | 'gitoxide_helper_invocation_spawn_failed' @@ -325,6 +386,102 @@ export async function createSuccessorWithGitoxideHelperInternal(input: { return decodeSuccessorOutcome(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); +} + +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; +} + interface HelperProcessOutcome { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null; @@ -336,6 +493,7 @@ function invokeHelper(input: { readonly executablePath: string; readonly request: Buffer; readonly abortSignal?: AbortSignal; + readonly timeoutMs?: number; }): Promise { return new Promise((resolve, reject) => { let child: ReturnType; @@ -371,7 +529,7 @@ function invokeHelper(input: { let processFailure: GitoxideHelperInvocationError | undefined; const timeout = setTimeout( () => terminate('gitoxide_helper_invocation_timed_out'), - INVOCATION_TIMEOUT_MS, + input.timeoutMs ?? INVOCATION_TIMEOUT_MS, ); const abort = () => terminate('gitoxide_helper_invocation_aborted'); input.abortSignal?.addEventListener('abort', abort, { once: true }); @@ -543,6 +701,136 @@ function decodeSuccessorOutcome(outcome: HelperProcessOutcome): GitoxideSuccesso ); } +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 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 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, [ @@ -696,6 +984,10 @@ function hasExactKeys( 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 && 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 index f57bb1c536..6501dffa4c 100644 --- a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -23,6 +23,10 @@ import { importSourceHeadWithGitoxideHelperInternal, inspectRepositoryWithGitoxideHelperInternal, createSuccessorWithGitoxideHelperInternal, + materializeProjectionWithGitoxideHelperInternal, + observeProjectionWithGitoxideHelperInternal, + type GitoxideProjectionMaterializedV1, + type GitoxideProjectionObservationV1, type GitoxideSuccessorPublishedV1, type GitoxideSourceImportObservationV1, type GitoxideRepositoryRejectionV1, @@ -36,6 +40,10 @@ export interface GitoxideManagedRepositoryCapability { readonly kind: 'gitoxide_managed_repository_capability_v1'; } +export interface GitoxideProjectionCapability { + readonly kind: 'gitoxide_projection_capability_v1'; +} + export interface GitoxideManagedRepositoryImportResultV1 extends GitoxideSourceImportObservationV1 { readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; } @@ -44,6 +52,11 @@ export interface GitoxideManagedRepositorySuccessorResultV1 extends GitoxideSucc readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; } +export interface GitoxideProjectionMaterializationResultV1 + extends GitoxideProjectionMaterializedV1 { + readonly projectionCapability: GitoxideProjectionCapability; +} + export interface GitoxideRepositoryAdmissionStateInternal { readonly protocolVersion: 1; readonly repositoryPath: string; @@ -91,6 +104,16 @@ interface ManagedRepositoryCapabilityRecord { const managedRepositories = 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; @@ -226,6 +249,84 @@ export async function createGitoxideSuccessorInternal(input: { return Object.freeze({ ...result, managedRepositoryCapability }); } +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; +} + function issueManagedRepositoryCapability( record: ManagedRepositoryCapabilityRecord, ): GitoxideManagedRepositoryCapability { From 2a24596d3d1a2244cb1154399bb41dec149697fa Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 00:08:34 +0800 Subject: [PATCH 17/86] style(git): format projection helper --- native/gitoxide-helper/src/main.rs | 42 ++++++++++--------- .../tests/repository_admission.rs | 31 +++++++++----- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 1672f96951..361cf596ad 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -716,7 +716,8 @@ fn materialize_projection( destination_path: PathBuf, ) -> Result { let repository = open_repository(repository_path)?; - let (accepted_commit, accepted_tree) = accepted_commit_identity(&repository, &accepted_commit_oid)?; + let (accepted_commit, accepted_tree) = + accepted_commit_identity(&repository, &accepted_commit_oid)?; let stats = match fs::create_dir(&destination_path) { Ok(()) => { @@ -788,8 +789,7 @@ fn materialize_tree( return Err("unsupported_projection_path"); } let relative_path = join_projection_path(prefix, component); - record_projection_path(stats, &relative_path) - .map_err(|_| "projection_path_collision")?; + 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 => { @@ -803,7 +803,10 @@ fn materialize_tree( )?; } gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable => { - let header = entry.id().header().map_err(|_| "projection_blob_unavailable")?; + let header = entry + .id() + .header() + .map_err(|_| "projection_blob_unavailable")?; if header.kind() != gix::objs::Kind::Blob || header.size() > MAX_PROJECTION_FILE_BYTES { @@ -857,7 +860,8 @@ fn observe_projection( projection_path: PathBuf, ) -> Result { let repository = open_repository(repository_path)?; - let (accepted_commit, accepted_tree) = accepted_commit_identity(&repository, &accepted_commit_oid)?; + let (accepted_commit, accepted_tree) = + accepted_commit_identity(&repository, &accepted_commit_oid)?; match inspect_projection(&repository, accepted_tree, &projection_path) { Ok(stats) => { write_response(&Response::ProjectionObserved { @@ -872,12 +876,7 @@ fn observe_projection( }); Ok(ExitCode::SUCCESS) } - Err(drift) => projection_drifted( - accepted_commit, - accepted_tree, - projection_path, - drift, - ), + Err(drift) => projection_drifted(accepted_commit, accepted_tree, projection_path, drift), } } @@ -909,10 +908,12 @@ fn observe_expected_tree( 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(), - })?; + 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", @@ -1075,10 +1076,13 @@ fn reject_extra_projection_paths( 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 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 { diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index 22a051ab38..b22a097305 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -356,11 +356,16 @@ fn materializes_and_observes_an_exact_commit_without_git_metadata() { String::from_utf8_lossy(&materialized.stdout), String::from_utf8_lossy(&materialized.stderr) ); - let materialized: serde_json::Value = - serde_json::from_slice(&materialized.stdout).unwrap(); + 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_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!({ @@ -405,13 +410,17 @@ fn reports_projection_content_and_extra_path_drift() { 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()); + 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!({ From 59fe107a108072161cd589c98451d9494adabd75 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:10:44 +0800 Subject: [PATCH 18/86] fix(git): enforce one managed tree policy --- native/gitoxide-helper/src/main.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 361cf596ad..772e5de976 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -44,9 +44,6 @@ const MANAGED_TREE_POLICY_V1: ManagedTreePolicy = ManagedTreePolicy { max_file_bytes: MAX_IMPORT_FILE_BYTES, max_bytes: MAX_IMPORT_BYTES, }; -const MAX_PROJECTION_FILE_BYTES: u64 = MAX_IMPORT_FILE_BYTES; -const MAX_PROJECTION_BYTES: u64 = MAX_IMPORT_BYTES; -const MAX_PROJECTION_FILES: u64 = MAX_IMPORT_FILES; #[derive(Deserialize)] #[serde( @@ -718,6 +715,7 @@ fn materialize_projection( 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(()) => { @@ -808,19 +806,19 @@ fn materialize_tree( .header() .map_err(|_| "projection_blob_unavailable")?; if header.kind() != gix::objs::Kind::Blob - || header.size() > MAX_PROJECTION_FILE_BYTES + || 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 <= MAX_PROJECTION_FILES) + .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 <= MAX_PROJECTION_BYTES) + .filter(|bytes| *bytes <= MANAGED_TREE_POLICY_V1.max_bytes) .ok_or("projection_byte_limit_exceeded")?; let blob = entry .object() @@ -862,6 +860,7 @@ fn observe_projection( 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 { @@ -987,7 +986,7 @@ fn observe_expected_blob( path: relative_path.to_owned(), })?; if header.kind() != gix::objs::Kind::Blob - || header.size() > MAX_PROJECTION_FILE_BYTES + || header.size() > MANAGED_TREE_POLICY_V1.max_file_bytes || metadata.len() != header.size() { return Err(ProjectionDrift { @@ -998,7 +997,7 @@ fn observe_expected_blob( stats.files = stats .files .checked_add(1) - .filter(|count| *count <= MAX_PROJECTION_FILES) + .filter(|count| *count <= MANAGED_TREE_POLICY_V1.max_files) .ok_or_else(|| ProjectionDrift { reason: "projection_file_limit_exceeded", path: relative_path.to_owned(), @@ -1006,7 +1005,7 @@ fn observe_expected_blob( stats.bytes = stats .bytes .checked_add(header.size()) - .filter(|bytes| *bytes <= MAX_PROJECTION_BYTES) + .filter(|bytes| *bytes <= MANAGED_TREE_POLICY_V1.max_bytes) .ok_or_else(|| ProjectionDrift { reason: "projection_byte_limit_exceeded", path: relative_path.to_owned(), From db9cd19fd6d7d77dc9cf2799fac93fee11885e02 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 00:47:36 +0800 Subject: [PATCH 19/86] feat(release): package the Gitoxide helper authority --- .../workflows/gitoxide-helper-admission.yml | 17 ++ apps/desktop/electron-builder.config.mjs | 12 ++ ...xide-packaged-helper-authority-v1.zh-CN.md | 80 ++++++++ package.json | 3 + .../packaged-gitoxide-helper.test.ts | 127 ++++++++++++ .../packaged-gitoxide-helper-internal.ts | 182 ++++++++++++++++++ scripts/generate-gitoxide-cargo-notices.mjs | 93 +++++++++ scripts/package-macos-arm64.mjs | 3 + scripts/package-windows-x64.mjs | 3 + scripts/prepare-gitoxide-helper.mjs | 114 +++++++++++ scripts/prepare-gitoxide-helper.test.mjs | 78 ++++++++ scripts/product-release.test.mjs | 28 ++- scripts/verify-packaged-app.mjs | 11 ++ scripts/verify-packaged-app.test.mjs | 1 + scripts/verify-windows-x64.mjs | 1 + 15 files changed, 752 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/gitoxide-packaged-helper-authority-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts create mode 100644 packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts create mode 100644 scripts/generate-gitoxide-cargo-notices.mjs create mode 100644 scripts/prepare-gitoxide-helper.mjs create mode 100644 scripts/prepare-gitoxide-helper.test.mjs diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index deb4861724..a41fa6a7ec 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -24,6 +24,11 @@ on: - '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/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: @@ -33,6 +38,11 @@ on: - '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/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: @@ -65,8 +75,15 @@ jobs: - 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 && diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 321216a77a..7f439ce9b9 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -66,6 +66,18 @@ export default { 'dist/renderer/computer-use-overlay/**', ], extraResources: [ + { + 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', 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/package.json b/package.json index 42ce3e8613..fb4264a867 100644 --- a/package.json +++ b/package.json @@ -63,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 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", 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..4268bd7388 --- /dev/null +++ b/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts @@ -0,0 +1,127 @@ +/* + * 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 releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const capability = await resolvePackagedGitoxideHelperInternal({ + resourcesRoot: fixture.root, + releaseOwnerToken, + invocationOwnerToken, + }); + const verified = await verifyGitoxideHelperArtifactForInvocationInternal( + invocationOwnerToken, + capability, + ); + assert.equal(verified.executablePath, fixture.executablePath); + 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( + resolvePackagedGitoxideHelperInternal({ + resourcesRoot: fixture.root, + releaseOwnerToken: {}, + 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( + resolvePackagedGitoxideHelperInternal({ + resourcesRoot: fixture.root, + releaseOwnerToken: {}, + invocationOwnerToken: {}, + }), + (error: unknown) => + error instanceof PackagedGitoxideHelperError && + error.code === 'packaged_gitoxide_helper_manifest_invalid', + ); + } finally { + await fixture.cleanup(); + } +}); + +async function createFixture(): Promise<{ + root: string; + executablePath: 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'); + 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: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + distributionReady: true, + })}\n`, + ); + return { + root, + executablePath, + cleanup: () => rm(root, { recursive: true, force: true }), + }; +} 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..594d50d0d1 --- /dev/null +++ b/packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts @@ -0,0 +1,182 @@ +/* + * 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; + +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 resourcesRoot: string; + readonly releaseOwnerToken: object; + readonly invocationOwnerToken: object; +}): Promise { + try { + const resourcesRoot = normalize(await realpath(input.resourcesRoot)); + 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(input.releaseOwnerToken, { + executablePath, + expectedSha256: manifest.sha256, + expectedBytes: manifest.bytes, + platform: manifest.platform, + arch: manifest.arch, + protocolVersion: manifest.protocolVersion, + }); + return await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken: input.releaseOwnerToken, + 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 }, + ); + } +} + +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/scripts/generate-gitoxide-cargo-notices.mjs b/scripts/generate-gitoxide-cargo-notices.mjs new file mode 100644 index 0000000000..595f1d429e --- /dev/null +++ b/scripts/generate-gitoxide-cargo-notices.mjs @@ -0,0 +1,93 @@ +/* + * 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 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(); + if (licenseFiles.length === 0) { + 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}`, + `Source: ${source}`, + ...licenseFiles.flatMap((name) => [ + '', + `--- ${name} ---`, + readFileSync(join(directory, name), 'utf8').replace(/\r\n?/gu, '\n').trimEnd(), + ]), + ].join('\n'); +}); +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..c9128d9d52 100644 --- a/scripts/package-macos-arm64.mjs +++ b/scripts/package-macos-arm64.mjs @@ -91,6 +91,9 @@ export async function packageMacosArm64({ await run('npm', ['run', 'clean']); await run('npm', ['run', 'build']); + 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..cce7adcabb 100644 --- a/scripts/package-windows-x64.mjs +++ b/scripts/package-windows-x64.mjs @@ -107,6 +107,9 @@ 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', '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-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/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-packaged-app.mjs b/scripts/verify-packaged-app.mjs index 43c51b85c0..9ca0e0eb06 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -1022,6 +1022,7 @@ export async function assertPackagedResources( // artifacts that were correct when they shipped. The canonical icon itself // is `requireCanonicalIcon` above, not this. requireAppIconCatalog = true, + requireGitoxideHelper = true, } = {}, ) { if (bundledGitContract !== 'forbidden' && bundledGitContract !== 'legacy-required') { @@ -1031,6 +1032,16 @@ export async function assertPackagedResources( const required = [ 'app.asar', 'bundled-tools.json', + ...(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..f44a8972f8 100644 --- a/scripts/verify-packaged-app.test.mjs +++ b/scripts/verify-packaged-app.test.mjs @@ -58,6 +58,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-x64.mjs b/scripts/verify-windows-x64.mjs index 6b2bf36265..3bf419eb3d 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -131,6 +131,7 @@ export async function verifyPackagedWindowsApp( bundledGitContract: requiresCurrentContract ? 'forbidden' : 'legacy-required', requireCanonicalIcon: requiresCurrentContract, requireAppIconCatalog: requiresCurrentContract, + requireGitoxideHelper: requiresCurrentContract, }); if (requiresCurrentContract) await assertPackagedDependencyClosure(resources); else await requirePath(join(resources, 'git', 'cmd', 'git.exe')); From 59d1ad7b2a6d317196a77a79df447d20851cb421 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 01:00:01 +0800 Subject: [PATCH 20/86] feat(git): read files from accepted trees --- ...xide-tree-file-read-data-plane-v1.zh-CN.md | 47 ++++++++++ native/gitoxide-helper/src/main.rs | 78 ++++++++++++++++ .../tests/repository_admission.rs | 66 ++++++++++++++ ...itory-admission-authority-internal.test.ts | 59 ++++++++++++ .../gitoxide-helper-invocation-internal.ts | 89 ++++++++++++++++++- ...repository-admission-authority-internal.ts | 33 +++++++ 6 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/gitoxide-tree-file-read-data-plane-v1.zh-CN.md 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/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 772e5de976..b9e022899c 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -44,6 +44,7 @@ const MANAGED_TREE_POLICY_V1: ManagedTreePolicy = ManagedTreePolicy { max_file_bytes: MAX_IMPORT_FILE_BYTES, max_bytes: MAX_IMPORT_BYTES, }; +const MAX_TREE_FILE_BYTES: u64 = 8 * 1024 * 1024; #[derive(Deserialize)] #[serde( @@ -84,6 +85,12 @@ enum Request { accepted_commit_oid: String, projection_path: PathBuf, }, + ReadTreeFile { + protocol_version: u8, + repository_path: PathBuf, + accepted_commit_oid: String, + path: String, + }, } #[derive(Serialize)] @@ -168,6 +175,17 @@ enum Response<'a> { 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, @@ -247,6 +265,15 @@ fn run() -> Result { 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) + } } } @@ -694,6 +721,57 @@ fn is_canonical_successor_path(path: &str) -> bool { }) } +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 { + 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 content = String::from_utf8(blob.data).map_err(|_| "tree_file_not_utf8")?; + 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, diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index b22a097305..59ba5c5aaa 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -266,6 +266,72 @@ fn publishes_and_exactly_retries_a_successor_from_the_current_ref() { assert_eq!(retry, first); } +#[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 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(); 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 index 1f85f9eb6b..b47e3f1824 100644 --- 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 @@ -36,6 +36,7 @@ import { importAdmittedGitoxideRepositoryInternal, materializeGitoxideProjectionInternal, observeGitoxideProjectionInternal, + readGitoxideTreeFileInternal, requireGitoxideRepositoryAdmissionInternal, } from '../server/gitoxide-repository-admission-authority-internal.js'; @@ -267,6 +268,64 @@ test('binds successor publication to the imported repository capability and exac ); }); +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) { diff --git a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts index 523218555b..8a56872fa4 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -27,8 +27,9 @@ import { } 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 = 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; @@ -83,6 +84,13 @@ const HELPER_ERROR_REASONS = new Set([ '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', @@ -207,6 +215,18 @@ 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' @@ -438,6 +458,36 @@ export async function observeProjectionWithGitoxideHelperInternal(input: { 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; @@ -726,6 +776,15 @@ function decodeProjectionObservationOutcome( 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}`); @@ -768,6 +827,34 @@ function isProjectionMaterialized(value: unknown): value is GitoxideProjectionMa ); } +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, [ 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 index 6501dffa4c..9826284b77 100644 --- a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -25,10 +25,12 @@ import { createSuccessorWithGitoxideHelperInternal, materializeProjectionWithGitoxideHelperInternal, observeProjectionWithGitoxideHelperInternal, + readTreeFileWithGitoxideHelperInternal, type GitoxideProjectionMaterializedV1, type GitoxideProjectionObservationV1, type GitoxideSuccessorPublishedV1, type GitoxideSourceImportObservationV1, + type GitoxideTreeFileReadV1, type GitoxideRepositoryRejectionV1, } from './gitoxide-helper-invocation-internal.js'; @@ -327,6 +329,37 @@ export async function observeGitoxideProjectionInternal(input: { 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 { From c274f5953e42c2971b9440d992dc594717b81b80 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 01:01:44 +0800 Subject: [PATCH 21/86] fix(ci): pin the Gitoxide release toolchain --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fb4264a867..637cc35989 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "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 build --manifest-path native/gitoxide-helper/Cargo.toml --release --locked", + "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", From 2155999879d0e936102c8af7c8f556852ba3086e Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 01:02:22 +0800 Subject: [PATCH 22/86] fix(git): retain helper-owned blob storage --- native/gitoxide-helper/src/main.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index b9e022899c..b69d686944 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -758,7 +758,9 @@ fn read_tree_file( if bytes_read != header.size() { return Err("tree_file_identity_mismatch"); } - let content = String::from_utf8(blob.data).map_err(|_| "tree_file_not_utf8")?; + 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", From 8bc26223374663dbf0444d75ebaf4f804bdfd1ff Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 01:17:14 +0800 Subject: [PATCH 23/86] fix(release): cover dual-licensed Cargo crates --- scripts/generate-gitoxide-cargo-notices.mjs | 24 ++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/generate-gitoxide-cargo-notices.mjs b/scripts/generate-gitoxide-cargo-notices.mjs index 595f1d429e..c838a953eb 100644 --- a/scripts/generate-gitoxide-cargo-notices.mjs +++ b/scripts/generate-gitoxide-cargo-notices.mjs @@ -26,6 +26,7 @@ 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', @@ -59,7 +60,8 @@ const sections = packages.map((pkg) => { ) .map((entry) => entry.name) .sort(); - if (licenseFiles.length === 0) { + 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'; @@ -68,14 +70,30 @@ const sections = packages.map((pkg) => { heading, '-'.repeat(heading.length), `SPDX license: ${pkg.license}`, + ...(selectedLicense.note ? [selectedLicense.note] : []), `Source: ${source}`, - ...licenseFiles.flatMap((name) => [ + ...selectedLicense.files.flatMap(({ name, path }) => [ '', `--- ${name} ---`, - readFileSync(join(directory, name), 'utf8').replace(/\r\n?/gu, '\n').trimEnd(), + 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 ================================================ From 99b0ad0ae4efaee2b0600dec3f1286e7b108ea0c Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 10:45:28 +0800 Subject: [PATCH 24/86] fix(git): verify accepted blob identity --- native/gitoxide-helper/src/main.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index b69d686944..bdced44974 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -758,6 +758,12 @@ fn read_tree_file( 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(); From 798dbf63ca11fe71b5497a51de05184393a72d85 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 10:55:03 +0800 Subject: [PATCH 25/86] fix(runtime-host): bind helper resolution to packaged resources --- .../packaged-gitoxide-helper.test.ts | 39 +++++++++++-------- .../packaged-gitoxide-helper-internal.ts | 23 ++++++++--- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts b/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts index 4268bd7388..f80fa62b36 100644 --- a/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts +++ b/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts @@ -32,13 +32,10 @@ import { test('turns an exact packaged helper manifest into an owner-bound invocation capability', async () => { const fixture = await createFixture(); try { - const releaseOwnerToken = {}; const invocationOwnerToken = {}; - const capability = await resolvePackagedGitoxideHelperInternal({ - resourcesRoot: fixture.root, - releaseOwnerToken, - invocationOwnerToken, - }); + const capability = await withPackagedResourcesRoot(fixture.root, () => + resolvePackagedGitoxideHelperInternal({ invocationOwnerToken }), + ); const verified = await verifyGitoxideHelperArtifactForInvocationInternal( invocationOwnerToken, capability, @@ -55,11 +52,9 @@ test('fails closed when the manifest and packaged helper no longer agree', async try { await writeFile(fixture.executablePath, 'tampered'); await assert.rejects( - resolvePackagedGitoxideHelperInternal({ - resourcesRoot: fixture.root, - releaseOwnerToken: {}, - invocationOwnerToken: {}, - }), + withPackagedResourcesRoot(fixture.root, () => + resolvePackagedGitoxideHelperInternal({ invocationOwnerToken: {} }), + ), (error: unknown) => error instanceof PackagedGitoxideHelperError && error.code === 'packaged_gitoxide_helper_integrity_mismatch', @@ -77,11 +72,9 @@ test('rejects an unknown or self-declared manifest shape', async () => { JSON.stringify({ schemaVersion: 999, executableRelativePath: 'gitoxide/helper' }), ); await assert.rejects( - resolvePackagedGitoxideHelperInternal({ - resourcesRoot: fixture.root, - releaseOwnerToken: {}, - invocationOwnerToken: {}, - }), + withPackagedResourcesRoot(fixture.root, () => + resolvePackagedGitoxideHelperInternal({ invocationOwnerToken: {} }), + ), (error: unknown) => error instanceof PackagedGitoxideHelperError && error.code === 'packaged_gitoxide_helper_manifest_invalid', @@ -91,6 +84,20 @@ test('rejects an unknown or self-declared manifest shape', async () => { } }); +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; diff --git a/packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts b/packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts index 594d50d0d1..027c57358d 100644 --- a/packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts +++ b/packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts @@ -41,6 +41,9 @@ const MANIFEST_KEYS = [ 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' @@ -60,12 +63,10 @@ export class PackagedGitoxideHelperError extends Error { } export async function resolvePackagedGitoxideHelperInternal(input: { - readonly resourcesRoot: string; - readonly releaseOwnerToken: object; readonly invocationOwnerToken: object; }): Promise { try { - const resourcesRoot = normalize(await realpath(input.resourcesRoot)); + 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); @@ -87,7 +88,7 @@ export async function resolvePackagedGitoxideHelperInternal(input: { await realpath(join(resourcesRoot, ...manifest.executableRelativePath.split('/'))), ); assertWithinRoot(resourcesRoot, executablePath, 'Gitoxide helper executable'); - const claim = issueGitoxideHelperReleaseArtifactClaimInternal(input.releaseOwnerToken, { + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(packagedReleaseOwnerToken, { executablePath, expectedSha256: manifest.sha256, expectedBytes: manifest.bytes, @@ -96,7 +97,7 @@ export async function resolvePackagedGitoxideHelperInternal(input: { protocolVersion: manifest.protocolVersion, }); return await admitGitoxideHelperArtifactInternal({ - releaseOwnerToken: input.releaseOwnerToken, + releaseOwnerToken: packagedReleaseOwnerToken, invocationOwnerToken: input.invocationOwnerToken, claim, }); @@ -119,6 +120,18 @@ export async function resolvePackagedGitoxideHelperInternal(input: { } } +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'; From 575de405548e4b05d69932b52ef315d1fd5abebc Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:12:32 +0800 Subject: [PATCH 26/86] fix(git): align direct reads with tree policy --- native/gitoxide-helper/src/main.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index bdced44974..14bb0e3750 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -708,16 +708,14 @@ fn validate_managed_tree_inner( } fn is_canonical_successor_path(path: &str) -> bool { - path.len() <= 4096 + 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.is_empty() - && component != "." - && component != ".." - && !component.eq_ignore_ascii_case(".git") + component.len() as u64 <= MANAGED_TREE_POLICY_V1.max_component_bytes + && is_supported_source_component(component) }) } @@ -745,7 +743,9 @@ fn read_tree_file( 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 { + 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(); @@ -1448,6 +1448,16 @@ mod tests { 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 { From d11045210e8f47e44a9ab916782eebc559b51324 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 00:17:33 +0800 Subject: [PATCH 27/86] feat(runtime-host): rebuild managed npm producer boundary --- ...d-dependency-producer-boundary-v1.zh-CN.md | 157 +++++ ...anaged-dependency-producer-process.test.ts | 530 +++++++++++++++++ .../managed-dependency-producer-process.ts | 557 ++++++++++++++++++ packages/runtime/package.json | 1 + .../__tests__/child-process-lifecycle.test.ts | 29 + .../runtime/src/child-process-lifecycle.ts | 4 + .../runtime/src/process-tree-terminator.ts | 8 +- packages/storage/package.json | 1 + 8 files changed, 1284 insertions(+), 3 deletions(-) create mode 100644 docs/architecture/managed-dependency-producer-boundary-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts create mode 100644 packages/runtime-host/src/server/managed-dependency-producer-process.ts 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..db062334e6 --- /dev/null +++ b/docs/architecture/managed-dependency-producer-boundary-v1.zh-CN.md @@ -0,0 +1,157 @@ +--- +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/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..4877e6dd2d --- /dev/null +++ b/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts @@ -0,0 +1,530 @@ +/* + * 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, 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 { + 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('does not expose an npm entry before runtime attestation is installed', () => { + assert.equal('runManagedNpmDependencyProvision' in runtimeHostServer, false); +}); + +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), + nodeExecutablePath: process.execPath, + npmRuntimeRoot: 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); + 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, + nodeExecutablePath: process.execPath, + npmRuntimeRoot: root, + npmCliPath, + }), + /unsafe dependency entry/u, + ); + 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), + nodeExecutablePath: process.execPath, + npmRuntimeRoot: 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), + nodeExecutablePath: process.execPath, + npmRuntimeRoot: 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 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/server/managed-dependency-producer-process.ts b/packages/runtime-host/src/server/managed-dependency-producer-process.ts new file mode 100644 index 0000000000..d92fb00b78 --- /dev/null +++ b/packages/runtime-host/src/server/managed-dependency-producer-process.ts @@ -0,0 +1,557 @@ +/* + * 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'; + +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; +export const MANAGED_NPM_PACKAGE_MANAGER_VERSION = '12.0.2'; +const MANAGED_NPM_MAX_OBSERVED_BYTES = 2 * 1024 * 1024 * 1024; +const MANAGED_NPM_MAX_OBSERVED_ENTRIES = 250_000; + +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 RunManagedNpmDependencyProvisionInput { + readonly producerInput: ManagedDependencyEnvironmentProducerInput; + readonly nodeExecutablePath: string; + readonly npmRuntimeRoot: string; + readonly npmCliPath: string; +} + +/** @internal PR3 must bind this candidate owner to an attested bundled runtime before export. */ +export async function runManagedNpmDependencyProvision( + input: RunManagedNpmDependencyProvisionInput, +): Promise { + 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( + input.nodeExecutablePath, + 'Managed npm Node runtime', + ); + const npmRuntimeRoot = await canonicalDirectory(input.npmRuntimeRoot, 'Managed npm runtime'); + const npmCliPath = await canonicalRegularFile(input.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', + `--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 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 ( + !packagePath.startsWith('node_modules/') || + 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 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 { + return { + HOME: homeRoot, + USERPROFILE: homeRoot, + 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' + ? { 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/package.json b/packages/runtime/package.json index 693b3d88f6..e333e5c45f 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", 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/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/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/storage/package.json b/packages/storage/package.json index b45746f046..1d2d11802d 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", From cb7210cd8be853186f1297bd3abd54489482f359 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 00:27:07 +0800 Subject: [PATCH 28/86] test(windows): inventory managed producer contracts --- docs/windows-test-inventory.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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'` | From 6c7159bf01287f4bd165be258877e14e2416266e Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 01:26:46 +0800 Subject: [PATCH 29/86] docs(runtime-host): license the managed producer contract --- ...d-dependency-producer-boundary-v1.zh-CN.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/architecture/managed-dependency-producer-boundary-v1.zh-CN.md b/docs/architecture/managed-dependency-producer-boundary-v1.zh-CN.md index db062334e6..dfe56902f2 100644 --- a/docs/architecture/managed-dependency-producer-boundary-v1.zh-CN.md +++ b/docs/architecture/managed-dependency-producer-boundary-v1.zh-CN.md @@ -1,3 +1,22 @@ + + --- document_status: implementation-contract status: draft-stacked-foundation From 4d2d798a49c057d8a6eb5f4c6a04d24584314e0a Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 10:32:49 +0800 Subject: [PATCH 30/86] fix(runtime-host): reject unsafe lockfile package paths --- ...anaged-dependency-producer-process.test.ts | 58 +++++++++++++++++++ .../managed-dependency-producer-process.ts | 43 +++++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) 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 index 4877e6dd2d..dfaae834fd 100644 --- a/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts +++ b/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts @@ -144,6 +144,64 @@ test('rejects lifecycle-script lock entries before starting npm', { 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, + nodeExecutablePath: process.execPath, + npmRuntimeRoot: 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) => { diff --git a/packages/runtime-host/src/server/managed-dependency-producer-process.ts b/packages/runtime-host/src/server/managed-dependency-producer-process.ts index d92fb00b78..4bcd923395 100644 --- a/packages/runtime-host/src/server/managed-dependency-producer-process.ts +++ b/packages/runtime-host/src/server/managed-dependency-producer-process.ts @@ -169,7 +169,7 @@ function assertSafeNpmInputs(input: ManagedDependencyEnvironmentProducerInput): const entry = value as Record; if (packagePath === '') continue; if ( - !packagePath.startsWith('node_modules/') || + !isSafeManagedNpmPackagePath(packagePath) || entry.link === true || entry.hasInstallScript === true || typeof entry.resolved !== 'string' || @@ -182,6 +182,47 @@ function assertSafeNpmInputs(input: ManagedDependencyEnvironmentProducerInput): } } +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 { From a2d933af0dff57a207d80e80fcee90b6c5f3d867 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 00:25:09 +0800 Subject: [PATCH 31/86] feat(runtime-host): attest managed npm runtime --- .gitignore | 1 + apps/desktop/electron-builder.config.mjs | 12 + ...undled-npm-runtime-attestation-v1.zh-CN.md | 129 ++ package-lock.json | 1987 ++++++++++++++++- package.json | 10 +- .../src/__tests__/bundled-npm-runtime.test.ts | 190 ++ ...anaged-dependency-producer-process.test.ts | 130 +- .../src/server/bundled-npm-runtime.ts | 444 ++++ .../managed-dependency-producer-process.ts | 64 +- scripts/package-macos-arm64.mjs | 3 + scripts/package-windows-x64.mjs | 3 + scripts/prepare-bundled-npm.mjs | 262 +++ scripts/prepare-bundled-npm.test.mjs | 166 ++ scripts/verify-bundled-npm-runtime.mjs | 290 +++ scripts/verify-packaged-app.mjs | 10 + scripts/verify-packaged-app.test.mjs | 23 + scripts/verify-windows-x64.mjs | 1 + 17 files changed, 3688 insertions(+), 37 deletions(-) create mode 100644 docs/architecture/bundled-npm-runtime-attestation-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/bundled-npm-runtime.test.ts create mode 100644 packages/runtime-host/src/server/bundled-npm-runtime.ts create mode 100644 scripts/prepare-bundled-npm.mjs create mode 100644 scripts/prepare-bundled-npm.test.mjs create mode 100644 scripts/verify-bundled-npm-runtime.mjs diff --git a/.gitignore b/.gitignore index 0f6f738912..1257eb342f 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ 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..78dc254e94 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -66,6 +66,14 @@ 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: 'bundled-tools.json', to: 'bundled-tools.json', @@ -105,6 +113,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..257739be97 --- /dev/null +++ b/docs/architecture/bundled-npm-runtime-attestation-v1.zh-CN.md @@ -0,0 +1,129 @@ +--- +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/package-lock.json b/package-lock.json index 963aae16b8..30307194db 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": [ @@ -5531,16 +5524,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": { @@ -10523,6 +10516,1968 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@gar/promise-retry": { + "version": "1.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "dev": true, + "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": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/agent/node_modules/agent-base": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/npm/node_modules/@npmcli/agent/node_modules/http-proxy-agent": { + "version": "9.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/npm/node_modules/@npmcli/agent/node_modules/https-proxy-agent": { + "version": "9.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "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" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/config": { + "version": "11.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@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": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/git": { + "version": "8.0.0", + "dev": true, + "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": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "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": "^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", diff --git a/package.json b/package.json index 7c5c6c330e..733dc7f0ae 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,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 +94,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/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__/managed-dependency-producer-process.test.ts b/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts index dfaae834fd..5176e8e496 100644 --- a/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts +++ b/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts @@ -18,12 +18,14 @@ */ 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, @@ -34,10 +36,41 @@ const productionProfileSkip = isManagedNpmNodeVersionSupported(process.versions. ? false : `Host Node ${process.versions.node} is outside the attested managed npm profile`; -test('does not expose an npm entry before runtime attestation is installed', () => { +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); @@ -85,9 +118,7 @@ test('runs the fixed npm install protocol with a hermetic environment', { await runManagedNpmDependencyProvision({ producerInput: fixtureProducerInput(outputRoot, scratchRoot), - nodeExecutablePath: process.execPath, - npmRuntimeRoot: root, - npmCliPath, + runtime: await attestFixtureRuntime(root, npmCliPath), }); const invocation = JSON.parse(await readFile(join(projectRoot, 'invocation.json'), 'utf8')) as { @@ -105,6 +136,13 @@ test('runs the fixed npm install protocol with a hermetic environment', { 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'); }); @@ -135,9 +173,7 @@ test('rejects lifecycle-script lock entries before starting npm', { await assert.rejects( runManagedNpmDependencyProvision({ producerInput, - nodeExecutablePath: process.execPath, - npmRuntimeRoot: root, - npmCliPath, + runtime: await attestFixtureRuntime(root, npmCliPath), }), /unsafe dependency entry/u, ); @@ -232,9 +268,7 @@ test('rejects a pre-positioned scratch redirect before starting npm', { await assert.rejects( runManagedNpmDependencyProvision({ producerInput: fixtureProducerInput(outputRoot, scratchRoot), - nodeExecutablePath: process.execPath, - npmRuntimeRoot: root, - npmCliPath, + runtime: await attestFixtureRuntime(root, npmCliPath), }), /scratch entry was not created/u, ); @@ -268,9 +302,7 @@ test('denies child-process creation inside the fixed npm execution profile', { await assert.rejects( runManagedNpmDependencyProvision({ producerInput: fixtureProducerInput(outputRoot, scratchRoot), - nodeExecutablePath: process.execPath, - npmRuntimeRoot: root, - npmCliPath, + runtime: await attestFixtureRuntime(root, npmCliPath), }), /child_process|permission|access denied/iu, ); @@ -546,6 +578,78 @@ async function waitForFile(path: string): Promise { 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) { 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..654214817a --- /dev/null +++ b/packages/runtime-host/src/server/bundled-npm-runtime.ts @@ -0,0 +1,444 @@ +/* + * 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, + issueManagedNpmRuntimeCapabilityInternal, + type ManagedNpmRuntimeCapability, +} from './managed-dependency-producer-process.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; +} + +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 = issueManagedNpmRuntimeCapabilityInternal( + { + npmVersion: manifest.npmVersion, + nodeVersion: process.versions.node, + nodeAbi: process.versions.modules ?? 'unknown', + platform, + arch, + nodeExecutablePath, + npmRuntimeRoot, + npmCliPath, + runtimeIdentitySha256: runtimeIdentity(manifest, nodeExecutableSha256), + }, + 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 }, + ); + } +} + +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/managed-dependency-producer-process.ts b/packages/runtime-host/src/server/managed-dependency-producer-process.ts index 4bcd923395..d21874bf23 100644 --- a/packages/runtime-host/src/server/managed-dependency-producer-process.ts +++ b/packages/runtime-host/src/server/managed-dependency-producer-process.ts @@ -49,15 +49,40 @@ export function isManagedNpmNodeVersionSupported(version: string): boolean { export interface RunManagedNpmDependencyProvisionInput { readonly producerInput: ManagedDependencyEnvironmentProducerInput; + readonly runtime: ManagedNpmRuntimeCapability; +} + +export interface ManagedNpmRuntimeCapability { + readonly npmVersion: typeof MANAGED_NPM_PACKAGE_MANAGER_VERSION; + readonly nodeVersion: string; + readonly nodeAbi: string; + readonly platform: NodeJS.Platform; + readonly arch: string; readonly nodeExecutablePath: string; readonly npmRuntimeRoot: string; readonly npmCliPath: string; + readonly runtimeIdentitySha256: `sha256:${string}`; +} + +const attestedNpmRuntimeCapabilities = new WeakMap< + ManagedNpmRuntimeCapability, + () => Promise +>(); + +/** @internal Only the bundled runtime attestation owner may issue this capability. */ +export function issueManagedNpmRuntimeCapabilityInternal( + input: ManagedNpmRuntimeCapability, + revalidate: () => Promise, +): ManagedNpmRuntimeCapability { + const capability = Object.freeze({ ...input }); + attestedNpmRuntimeCapabilities.set(capability, revalidate); + return capability; } -/** @internal PR3 must bind this candidate owner to an attested bundled runtime before export. */ export async function runManagedNpmDependencyProvision( input: RunManagedNpmDependencyProvisionInput, ): Promise { + const runtime = await requireAttestedNpmRuntimeCapability(input.runtime); assertSafeNpmInputs(input.producerInput); const outputRoot = normalize(await realpath(input.producerInput.outputRoot)); const scratchRoot = normalize(await realpath(input.producerInput.scratchRoot)); @@ -70,11 +95,11 @@ export async function runManagedNpmDependencyProvision( throw new TypeError('Managed npm producer requires one exact owned staging project'); } const nodeExecutablePath = await canonicalRegularFile( - input.nodeExecutablePath, + runtime.nodeExecutablePath, 'Managed npm Node runtime', ); - const npmRuntimeRoot = await canonicalDirectory(input.npmRuntimeRoot, 'Managed npm runtime'); - const npmCliPath = await canonicalRegularFile(input.npmCliPath, 'Managed npm CLI'); + 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'); } @@ -101,6 +126,7 @@ export async function runManagedNpmDependencyProvision( argv: [ nodeExecutablePath, '--permission', + ...(requiresExplicitNetworkPermission(runtime.nodeVersion) ? ['--allow-net'] : []), `--allow-fs-read=${npmRuntimeRoot}`, `--allow-fs-read=${projectRoot}`, `--allow-fs-write=${projectRoot}`, @@ -130,6 +156,20 @@ export async function runManagedNpmDependencyProvision( }); } +function requiresExplicitNetworkPermission(nodeVersion: string): boolean { + const major = Number.parseInt(nodeVersion.split('.')[0] ?? '', 10); + return Number.isSafeInteger(major) && major >= 26; +} + +async function requireAttestedNpmRuntimeCapability( + 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; +} + function assertSafeNpmInputs(input: ManagedDependencyEnvironmentProducerInput): void { if ( input.identity.packageManagerName !== 'npm' || @@ -286,9 +326,14 @@ function hermeticNpmEnvironment( 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: homeRoot, - USERPROFILE: homeRoot, + HOME: effectiveHomeRoot, + USERPROFILE: effectiveHomeRoot, npm_config_audit: 'false', npm_config_fund: 'false', npm_config_ignore_scripts: 'true', @@ -301,7 +346,12 @@ function hermeticNpmEnvironment( TMPDIR: temporaryRoot, NODE_COMPILE_CACHE: compileCacheRoot, ...(process.platform === 'win32' - ? { SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR } + ? { + 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' } : {}), }; diff --git a/scripts/package-macos-arm64.mjs b/scripts/package-macos-arm64.mjs index 34779367bc..23f2592abd 100644 --- a/scripts/package-macos-arm64.mjs +++ b/scripts/package-macos-arm64.mjs @@ -91,6 +91,9 @@ 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', '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..e7841e5611 100644 --- a/scripts/package-windows-x64.mjs +++ b/scripts/package-windows-x64.mjs @@ -107,6 +107,9 @@ 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', '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/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..f4d37b04ed 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -1022,6 +1022,9 @@ 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, } = {}, ) { if (bundledGitContract !== 'forbidden' && bundledGitContract !== 'legacy-required') { @@ -1031,6 +1034,13 @@ 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'), + ] + : []), ...(requiresLegacyBundledGit ? [ 'bundled-git.json', diff --git a/scripts/verify-packaged-app.test.mjs b/scripts/verify-packaged-app.test.mjs index 1f7596d19d..5d38e5c590 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 = []; diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index 6b2bf36265..d87ffeed13 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -131,6 +131,7 @@ export async function verifyPackagedWindowsApp( bundledGitContract: requiresCurrentContract ? 'forbidden' : 'legacy-required', requireCanonicalIcon: requiresCurrentContract, requireAppIconCatalog: requiresCurrentContract, + requireBundledNpm: requiresCurrentContract, }); if (requiresCurrentContract) await assertPackagedDependencyClosure(resources); else await requirePath(join(resources, 'git', 'cmd', 'git.exe')); From 84d0d7229d28c48a491913861774148ec99450b6 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 01:26:59 +0800 Subject: [PATCH 32/86] docs(runtime-host): license the npm attestation contract --- ...undled-npm-runtime-attestation-v1.zh-CN.md | 19 ++++ package-lock.json | 102 ------------------ 2 files changed, 19 insertions(+), 102 deletions(-) diff --git a/docs/architecture/bundled-npm-runtime-attestation-v1.zh-CN.md b/docs/architecture/bundled-npm-runtime-attestation-v1.zh-CN.md index 257739be97..a3f349fead 100644 --- a/docs/architecture/bundled-npm-runtime-attestation-v1.zh-CN.md +++ b/docs/architecture/bundled-npm-runtime-attestation-v1.zh-CN.md @@ -1,3 +1,22 @@ + + --- document_status: implementation-contract status: draft-stacked-foundation diff --git a/package-lock.json b/package-lock.json index 30307194db..1957e8b4f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3068,9 +3068,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3085,9 +3082,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3102,9 +3096,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3119,9 +3110,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3136,9 +3124,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3153,9 +3138,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3170,9 +3152,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3187,9 +3166,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3492,9 +3468,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3512,9 +3485,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3532,9 +3502,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3552,9 +3519,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3572,9 +3536,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3592,9 +3553,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9307,9 +9265,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9327,9 +9282,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9347,9 +9299,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9367,9 +9316,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9387,9 +9333,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9407,9 +9350,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9427,9 +9367,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9447,9 +9384,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9729,9 +9663,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9753,9 +9684,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9777,9 +9705,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9801,9 +9726,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -14634,9 +14556,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14651,9 +14570,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -14668,9 +14584,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14685,9 +14598,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14702,9 +14612,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -14719,9 +14626,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14736,9 +14640,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14753,9 +14654,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ From 74c10afb4c366b698c3fe70561d1ecd3146c6958 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 10:35:41 +0800 Subject: [PATCH 33/86] test(runtime-host): preserve producer path policy --- .../src/__tests__/managed-dependency-producer-process.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index 5176e8e496..2388f35306 100644 --- a/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts +++ b/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts @@ -227,9 +227,7 @@ test('rejects non-canonical lockfile package paths before starting npm', { await assert.rejects( runManagedNpmDependencyProvision({ producerInput, - nodeExecutablePath: process.execPath, - npmRuntimeRoot: root, - npmCliPath, + runtime: await attestFixtureRuntime(root, npmCliPath), }), /unsafe dependency entry/u, packagePath, From dd181773e74fa4863e4f5b2217b98c4c733daae9 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 10:53:26 +0800 Subject: [PATCH 34/86] fix(runtime-host): make npm attestation the sole issuer --- .../src/server/bundled-npm-runtime.ts | 29 ++++++--- .../managed-dependency-producer-process.ts | 63 ++++--------------- .../server/managed-npm-runtime-contract.ts | 46 ++++++++++++++ 3 files changed, 80 insertions(+), 58 deletions(-) create mode 100644 packages/runtime-host/src/server/managed-npm-runtime-contract.ts diff --git a/packages/runtime-host/src/server/bundled-npm-runtime.ts b/packages/runtime-host/src/server/bundled-npm-runtime.ts index 654214817a..09d340ae3e 100644 --- a/packages/runtime-host/src/server/bundled-npm-runtime.ts +++ b/packages/runtime-host/src/server/bundled-npm-runtime.ts @@ -24,9 +24,8 @@ import { isAbsolute, join, normalize, relative } from 'node:path'; import { isManagedNpmNodeVersionSupported, - issueManagedNpmRuntimeCapabilityInternal, type ManagedNpmRuntimeCapability, -} from './managed-dependency-producer-process.js'; +} from './managed-npm-runtime-contract.js'; const MANIFEST_KEYS = [ 'arch', @@ -70,6 +69,11 @@ export interface ResolveBundledNpmRuntimeInput { readonly resourcesRoot: string; } +const attestedNpmRuntimeCapabilities = new WeakMap< + ManagedNpmRuntimeCapability, + () => Promise +>(); + export async function resolveBundledNpmRuntime( input: ResolveBundledNpmRuntimeInput, ): Promise { @@ -118,19 +122,19 @@ export async function resolveBundledNpmRuntime( throw invalidManifest('Bundled npm CLI must be a regular non-symlink file'); } await assertRuntimeTreeMatchesManifest(manifest, npmRuntimeRoot); - const capability = issueManagedNpmRuntimeCapabilityInternal( - { + 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), - }, - async () => { + }); + attestedNpmRuntimeCapabilities.set(capability, async () => { const currentNodeExecutable = await canonicalRegularFile( process.execPath, 'Host Node executable', @@ -145,8 +149,7 @@ export async function resolveBundledNpmRuntime( ); } await assertRuntimeTreeMatchesManifest(manifest, npmRuntimeRoot); - }, - ); + }); return capability; } catch (error) { if (error instanceof BundledNpmRuntimeError) throw error; @@ -158,6 +161,16 @@ export async function resolveBundledNpmRuntime( } } +/** @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; diff --git a/packages/runtime-host/src/server/managed-dependency-producer-process.ts b/packages/runtime-host/src/server/managed-dependency-producer-process.ts index d21874bf23..70cb0f61cb 100644 --- a/packages/runtime-host/src/server/managed-dependency-producer-process.ts +++ b/packages/runtime-host/src/server/managed-dependency-producer-process.ts @@ -26,63 +26,35 @@ import { 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; -export const MANAGED_NPM_PACKAGE_MANAGER_VERSION = '12.0.2'; const MANAGED_NPM_MAX_OBSERVED_BYTES = 2 * 1024 * 1024 * 1024; const MANAGED_NPM_MAX_OBSERVED_ENTRIES = 250_000; -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 RunManagedNpmDependencyProvisionInput { readonly producerInput: ManagedDependencyEnvironmentProducerInput; readonly runtime: ManagedNpmRuntimeCapability; } -export interface ManagedNpmRuntimeCapability { - readonly npmVersion: typeof MANAGED_NPM_PACKAGE_MANAGER_VERSION; - readonly nodeVersion: string; - readonly nodeAbi: string; - readonly platform: NodeJS.Platform; - readonly arch: string; - readonly nodeExecutablePath: string; - readonly npmRuntimeRoot: string; - readonly npmCliPath: string; - readonly runtimeIdentitySha256: `sha256:${string}`; -} - -const attestedNpmRuntimeCapabilities = new WeakMap< - ManagedNpmRuntimeCapability, - () => Promise ->(); - -/** @internal Only the bundled runtime attestation owner may issue this capability. */ -export function issueManagedNpmRuntimeCapabilityInternal( - input: ManagedNpmRuntimeCapability, - revalidate: () => Promise, -): ManagedNpmRuntimeCapability { - const capability = Object.freeze({ ...input }); - attestedNpmRuntimeCapabilities.set(capability, revalidate); - return capability; -} - export async function runManagedNpmDependencyProvision( input: RunManagedNpmDependencyProvisionInput, ): Promise { - const runtime = await requireAttestedNpmRuntimeCapability(input.runtime); + 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)); @@ -161,15 +133,6 @@ function requiresExplicitNetworkPermission(nodeVersion: string): boolean { return Number.isSafeInteger(major) && major >= 26; } -async function requireAttestedNpmRuntimeCapability( - 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; -} - function assertSafeNpmInputs(input: ManagedDependencyEnvironmentProducerInput): void { if ( input.identity.packageManagerName !== 'npm' || 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}`; +} From 6e0429cd0757d288268094875c75a4e3d9db1412 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 01:27:43 +0800 Subject: [PATCH 35/86] feat(runtime-host): compose Gitoxide managed inspection --- .../workflows/gitoxide-helper-admission.yml | 5 + ...inspection-product-composition-v1.zh-CN.md | 88 ++++ .../gitoxide-managed-inspection.test.ts | 260 ++++++++++ .../src/server/execution-composition.ts | 22 + .../src/server/gitoxide-managed-inspection.ts | 456 ++++++++++++++++++ 5 files changed, 831 insertions(+) create mode 100644 docs/architecture/gitoxide-managed-inspection-product-composition-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts create mode 100644 packages/runtime-host/src/server/gitoxide-managed-inspection.ts diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index a41fa6a7ec..a0dde6bd87 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -24,6 +24,8 @@ on: - '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/packaged-gitoxide-helper-internal.ts' - 'packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts' - 'scripts/prepare-gitoxide-helper*' @@ -38,6 +40,8 @@ on: - '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/packaged-gitoxide-helper-internal.ts' - 'packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts' - 'scripts/prepare-gitoxide-helper*' @@ -98,3 +102,4 @@ jobs: 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-repository-admission-authority-internal.test.js + packages/runtime-host/dist/__tests__/gitoxide-managed-inspection.test.js 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/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..afb688a65f --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts @@ -0,0 +1,260 @@ +/* + * 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, + 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('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), + ); + const dependency = await composition.tool.impl( + { kind: 'read', path: 'node_modules/fixture-package/package.json' }, + toolContext(sourceRoot), + ); + assert.deepEqual(source.result, { kind: 'read', content: 'export const answer = 42;\n' }); + assert.deepEqual(dependency.result, { + kind: 'read', + content: '{"name":"fixture-package"}\n', + }); + assert.equal(identities.length, 2); + assert.notEqual(seenCwds[0], sourceRoot); + assert.equal(seenCwds[1], 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/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 0a5d199a9e..518cc524da 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -176,6 +176,10 @@ import { RuntimeHostWorkspaceExecutionError, type RuntimeHostWorkspaceExecutionComposition, } from './workspace-execution-composition.js'; +import { + tryOpenPackagedGitoxideManagedInspectionComposition, + type GitoxideManagedInspectionComposition, +} from './gitoxide-managed-inspection.js'; export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition { readonly workspaceExecution: RuntimeHostWorkspaceExecutionComposition; @@ -229,6 +233,7 @@ export async function createExecutionRuntimeHostComposition( let unsubscribeTranscriptChanges: (() => void) | undefined; let unsubscribeUsageChanges: (() => void) | undefined; let workspaceExecution: RuntimeHostWorkspaceExecutionComposition | undefined; + let gitoxideManagedInspection: GitoxideManagedInspectionComposition | undefined; let goalExecutions: HostGoalExecutionCoordinator | undefined; try { const openedProjectCatalog = storage.projectCatalog; @@ -311,6 +316,14 @@ 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)}`, + ), + }); workspaceExecution = createRuntimeHostWorkspaceExecutionComposition({ ...(managedFilesystemWorker ? { filesystemWorker: managedFilesystemWorker } : {}), }); @@ -361,6 +374,7 @@ export async function createExecutionRuntimeHostComposition( const hostTools = [ createHostWebSearchToolFromService(webSearchService), createHostWebFetchToolFromService(webFetchService), + ...(gitoxideManagedInspection ? [gitoxideManagedInspection.tool] : []), ...runtimePolicy.modelTools, ]; const childAgentTools = createHostChildAgentToolComposition({ @@ -1479,6 +1493,7 @@ export async function createExecutionRuntimeHostComposition( }, drain: [ () => rootCoordinator?.beginDrain(), + () => gitoxideManagedInspection?.beginDrain(), () => workspaceExecution?.beginDrain(), () => runtimeResources?.beginDrain(), () => messages.beginDrain(), @@ -1492,6 +1507,7 @@ export async function createExecutionRuntimeHostComposition( await rootCloseTask; }, () => runtimeResources?.close(), + () => gitoxideManagedInspection?.close(), () => workspaceExecution?.close(), () => sessionEffects?.close(), () => messages.close(), @@ -1591,6 +1607,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) { 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..45a80b4c3d --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-managed-inspection.ts @@ -0,0 +1,456 @@ +/* + * 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; + readonly dependencyEnvironmentId: `sha256:${string}`; + readonly result: ManagedWorkspaceReadOnlyResult; +} + +export interface GitoxideManagedInspectionComposition { + readonly state: 'ready' | 'draining' | 'closed'; + readonly tool: 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 execute = async ( + operation: GitoxideManagedInspectionInput, + sourceCwd: string, + abortSignal: AbortSignal, + ): 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; + 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 admitted = await admitGitoxideRepositoryInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + admissionOwnerToken, + repositoryPath: sourceRoot, + 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: repositoryPath, + baselineRef: BASELINE_REF, + abortSignal, + }); + const [manifest, lockfile, projection] = await Promise.all([ + readGitoxideTreeFileInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'package.json', + abortSignal, + }), + readGitoxideTreeFileInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'package-lock.json', + abortSignal, + }), + materializeGitoxideProjectionInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + projectionOwnerToken, + destinationPath: projectionPath, + 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(); + const rawResult = await input.filesystemWorker.execute({ + operation: route.workerOperation, + cwd: + route.root === 'dependency' ? dependencyLease.dependencyRoot : projection.destinationPath, + executionBoundary: route.executionBoundary, + abortSignal, + }); + const observation = await observeGitoxideProjectionInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + projectionOwnerToken, + projectionCapability: projection.projectionCapability, + abortSignal, + }); + if (observation.kind !== 'projection_observed') { + throw new Error( + `Gitoxide projection drifted at ${observation.path}: ${observation.reason}`, + ); + } + const result = remapInspectionResult(route, rawResult); + const response = Object.freeze({ + kind: 'gitoxide_managed_inspection_v1' as const, + acceptedCommitOid: imported.baselineCommitOid, + acceptedTreeOid: imported.baselineTreeOid, + dependencyEnvironmentId: dependencyLease.environmentId, + 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; + } 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 (cleanupErrors.length === 1) throw cleanupErrors[0]; + if (cleanupErrors.length > 1) { + throw new AggregateError(cleanupErrors, 'Gitoxide managed inspection cleanup failed'); + } + } + }; + + const tool: MakaTool = { + name: 'ManagedWorkspaceInspect', + displayName: 'Inspect isolated workspace', + description: + 'Read or glob a project through a fresh Maka-owned Gitoxide projection 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), + }; + + return Object.freeze({ + get state() { + return state; + }, + tool, + 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 = runtimeHostPackagedResourcesRoot(); + if (!resourcesRoot || !input.filesystemWorker) return undefined; + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + try { + const [helperCapability, npmRuntime] = await Promise.all([ + resolvePackagedGitoxideHelperInternal({ + resourcesRoot, + releaseOwnerToken, + invocationOwnerToken, + }), + 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; + } +} + +function runtimeHostPackagedResourcesRoot(): 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: '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' : 'projection', + logicalPath, + workerOperation: Object.freeze({ ...operation, path: workerPath }), + executionBoundary: createReadOnlyBoundary(), + }); +} + +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`); +} From 5a276972046173940b420f8cebcd5de64a95e094 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 10:58:05 +0800 Subject: [PATCH 36/86] fix(runtime-host): route managed inspection minimally --- .../gitoxide-managed-inspection.test.ts | 11 +- .../src/server/gitoxide-managed-inspection.ts | 186 +++++++++++------- 2 files changed, 124 insertions(+), 73 deletions(-) diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts index afb688a65f..9d33eff3c6 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts @@ -170,18 +170,21 @@ test('reads source and dependency files through the real Gitoxide product data p { 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(source.result, { kind: 'read', content: 'export const answer = 42;\n' }); assert.deepEqual(dependency.result, { kind: 'read', content: '{"name":"fixture-package"}\n', }); - assert.equal(identities.length, 2); - assert.notEqual(seenCwds[0], sourceRoot); - assert.equal(seenCwds[1], dependencyRoot); + assert.equal(identities.length, 1); + assert.equal(seenCwds[0], dependencyRoot); await composition.close(); assert.equal(closed, true); }); diff --git a/packages/runtime-host/src/server/gitoxide-managed-inspection.ts b/packages/runtime-host/src/server/gitoxide-managed-inspection.ts index 45a80b4c3d..82a7dd2d92 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-inspection.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-inspection.ts @@ -82,7 +82,8 @@ export interface GitoxideManagedInspectionResult { readonly kind: 'gitoxide_managed_inspection_v1'; readonly acceptedCommitOid: string; readonly acceptedTreeOid: string; - readonly dependencyEnvironmentId: `sha256:${string}`; + /** Present only when this operation actually consumed the dependency environment. */ + readonly dependencyEnvironmentId?: `sha256:${string}`; readonly result: ManagedWorkspaceReadOnlyResult; } @@ -131,6 +132,7 @@ export async function createGitoxideManagedInspectionComposition( activeOperations += 1; let operationRoot: string | undefined; let dependencyLease: Awaited> | undefined; + let primaryError: unknown; try { const sourceRoot = await realpath(sourceCwd); abortSignal.throwIfAborted(); @@ -157,90 +159,118 @@ export async function createGitoxideManagedInspectionComposition( baselineRef: BASELINE_REF, abortSignal, }); - const [manifest, lockfile, projection] = await Promise.all([ - readGitoxideTreeFileInternal({ + 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 readGitoxideTreeFileInternal({ invocationOwnerToken, helperCapability: input.helperCapability, managedRepositoryOwnerToken, managedRepositoryCapability: imported.managedRepositoryCapability, - path: 'package.json', + path: route.workerOperation.path, abortSignal, - }), - readGitoxideTreeFileInternal({ + }); + rawResult = Object.freeze({ + kind: 'read' as const, + content: sliceReadContent(file.content, operation.offset, operation.limit), + }); + } else if (route.root === 'projection') { + const projection = await materializeGitoxideProjectionInternal({ invocationOwnerToken, helperCapability: input.helperCapability, managedRepositoryOwnerToken, managedRepositoryCapability: imported.managedRepositoryCapability, - path: 'package-lock.json', + projectionOwnerToken, + destinationPath: projectionPath, abortSignal, - }), - materializeGitoxideProjectionInternal({ + }); + rawResult = await input.filesystemWorker.execute({ + operation: route.workerOperation, + cwd: projection.destinationPath, + executionBoundary: route.executionBoundary, + abortSignal, + }); + const observation = await observeGitoxideProjectionInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + projectionOwnerToken, + projectionCapability: projection.projectionCapability, + abortSignal, + }); + if (observation.kind !== 'projection_observed') { + throw new Error( + `Gitoxide projection drifted at ${observation.path}: ${observation.reason}`, + ); + } + } else { + // These reads are intentionally sequential. A rejected child cannot outlive + // the operation and race cleanup of the shared managed repository. + const manifest = await readGitoxideTreeFileInternal({ invocationOwnerToken, helperCapability: input.helperCapability, managedRepositoryOwnerToken, managedRepositoryCapability: imported.managedRepositoryCapability, - projectionOwnerToken, - destinationPath: projectionPath, + path: 'package.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(); - const rawResult = await input.filesystemWorker.execute({ - operation: route.workerOperation, - cwd: - route.root === 'dependency' ? dependencyLease.dependencyRoot : projection.destinationPath, - executionBoundary: route.executionBoundary, - abortSignal, - }); - const observation = await observeGitoxideProjectionInternal({ - invocationOwnerToken, - helperCapability: input.helperCapability, - projectionOwnerToken, - projectionCapability: projection.projectionCapability, - abortSignal, - }); - if (observation.kind !== 'projection_observed') { - throw new Error( - `Gitoxide projection drifted at ${observation.path}: ${observation.reason}`, + }); + const lockfile = await readGitoxideTreeFileInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: '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: imported.baselineCommitOid, acceptedTreeOid: imported.baselineTreeOid, - dependencyEnvironmentId: dependencyLease.environmentId, + ...(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 { @@ -258,8 +288,20 @@ export async function createGitoxideManagedInspectionComposition( for (const resolveWaiter of drainWaiters) resolveWaiter(); drainWaiters.clear(); } - if (cleanupErrors.length === 1) throw cleanupErrors[0]; - if (cleanupErrors.length > 1) { + 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'); } } @@ -310,14 +352,12 @@ export async function tryOpenPackagedGitoxideManagedInspectionComposition(input: const releaseOwnerToken = {}; const invocationOwnerToken = {}; try { - const [helperCapability, npmRuntime] = await Promise.all([ - resolvePackagedGitoxideHelperInternal({ - resourcesRoot, - releaseOwnerToken, - invocationOwnerToken, - }), - resolveBundledNpmRuntime({ resourcesRoot }), - ]); + const helperCapability = await resolvePackagedGitoxideHelperInternal({ + resourcesRoot, + releaseOwnerToken, + invocationOwnerToken, + }); + const npmRuntime = await resolveBundledNpmRuntime({ resourcesRoot }); const producerCapability = createManagedDependencyEnvironmentProducerCapability( npmRuntime.runtimeIdentitySha256, ); @@ -364,7 +404,7 @@ function runtimeHostPackagedResourcesRoot(): string | undefined { } interface InspectionRoute { - readonly root: 'projection' | 'dependency'; + readonly root: 'source_tree' | 'projection' | 'dependency'; readonly logicalPath: string; readonly workerOperation: ManagedWorkspaceReadOnlyOperation; readonly executionBoundary: Parameters< @@ -385,13 +425,21 @@ function routeInspectionOperation(operation: GitoxideManagedInspectionInput): In : canonicalPath; const workerPath = dependencyRoot ? segments.slice(1).join('/') || '.' : canonicalPath; return Object.freeze({ - root: dependencyRoot ? 'dependency' : 'projection', + 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('\\') || From 26d955ba5b16d196134e7827d6ba933e911396b1 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 14:50:54 +0800 Subject: [PATCH 37/86] feat(git): add short-lived Gitoxide admission helper --- .../workflows/gitoxide-helper-admission.yml | 57 + .gitignore | 3 + ...e-short-lived-helper-admission-v1.zh-CN.md | 100 ++ native/gitoxide-helper/Cargo.lock | 1431 +++++++++++++++++ native/gitoxide-helper/Cargo.toml | 33 + native/gitoxide-helper/rust-toolchain.toml | 21 + native/gitoxide-helper/src/main.rs | 146 ++ .../tests/repository_admission.rs | 175 ++ package.json | 1 + scripts/asf-license-headers.mjs | 1 + 10 files changed, 1968 insertions(+) create mode 100644 .github/workflows/gitoxide-helper-admission.yml create mode 100644 docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md create mode 100644 native/gitoxide-helper/Cargo.lock create mode 100644 native/gitoxide-helper/Cargo.toml create mode 100644 native/gitoxide-helper/rust-toolchain.toml create mode 100644 native/gitoxide-helper/src/main.rs create mode 100644 native/gitoxide-helper/tests/repository_admission.rs diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml new file mode 100644 index 0000000000..d3f8ac570f --- /dev/null +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -0,0 +1,57 @@ +# 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/**' + push: + branches: + - main + paths: + - '.github/workflows/gitoxide-helper-admission.yml' + - 'native/gitoxide-helper/**' + +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 + - 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 diff --git a/.gitignore b/.gitignore index 0f6f738912..664f0303c1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ 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. 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..fe3f23f83c --- /dev/null +++ b/docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md @@ -0,0 +1,100 @@ + + +# 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. 下一切片 + +下一 PR 只建立一个 owner 边界:由 Host/Storage 验证 helper artifact identity,并将一次 +repository observation 转换成 T1 前可消费的 opaque admission capability。source import、fresh +projection 与 candidate ref CAS 继续分别验证,不能在 admission PR 中顺手恢复旧 Git CLI adapter。 diff --git a/native/gitoxide-helper/Cargo.lock b/native/gitoxide-helper/Cargo.lock new file mode 100644 index 0000000000..b37203abb7 --- /dev/null +++ b/native/gitoxide-helper/Cargo.lock @@ -0,0 +1,1431 @@ +# 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", + "serde", + "serde_json", +] + +[[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..3c1bc18359 --- /dev/null +++ b/native/gitoxide-helper/Cargo.toml @@ -0,0 +1,33 @@ +# 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"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" 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..a762bcd5bd --- /dev/null +++ b/native/gitoxide-helper/src/main.rs @@ -0,0 +1,146 @@ +/* + * 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::{ + io::{self, Read}, + path::PathBuf, + process::ExitCode, +}; + +use serde::{Deserialize, Serialize}; + +const PROTOCOL_VERSION: u8 = 1; +const MAX_REQUEST_BYTES: u64 = 64 * 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct InspectRepositoryRequest { + protocol_version: u8, + operation: String, + repository_path: PathBuf, +} + +#[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")] + 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()?; + if request.protocol_version != PROTOCOL_VERSION { + return Err("unsupported_protocol_version"); + } + if request.operation != "inspect_repository" { + return Err("unsupported_operation"); + } + + let repository = gix::open::Options::isolated() + .strict_config(true) + .open(request.repository_path) + .map_err(|_| "repository_open_failed")? + .to_thread_local(); + + 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 => { + write_response(&Response::RepositoryRejected { + protocol_version: PROTOCOL_VERSION, + reason: "unsupported_object_format", + object_format: "sha256".to_owned(), + supported_object_formats: ["sha1"], + }); + Ok(ExitCode::from(2)) + } + _ => { + write_response(&Response::RepositoryRejected { + protocol_version: PROTOCOL_VERSION, + reason: "unsupported_object_format", + object_format: "unknown".to_owned(), + supported_object_formats: ["sha1"], + }); + Ok(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..bc5152c8c3 --- /dev/null +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -0,0 +1,175 @@ +/* + * 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}, + time::{SystemTime, UNIX_EPOCH}, +}; + +const HELPER: &str = env!("CARGO_BIN_EXE_maka-gitoxide-helper"); + +#[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"], + }) + ); +} + +fn invoke_helper(repository_path: &Path) -> 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(); + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "inspect_repository", + "repositoryPath": repository_path, + }); + child + .stdin + .take() + .unwrap() + .write_all(serde_json::to_string(&request).unwrap().as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +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 init(object_format: &str) -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "maka-gitoxide-helper-admission-{}-{nonce}", + 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.json b/package.json index 7c5c6c330e..42ce3e8613 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", 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', From 6f1d6c42392eeaf983ee558f684d1e495dff64e2 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 15:58:00 +0800 Subject: [PATCH 38/86] fix(git): classify unsupported repository formats --- native/gitoxide-helper/src/main.rs | 52 +++++++++------- .../tests/repository_admission.rs | 62 +++++++++++++++++++ 2 files changed, 93 insertions(+), 21 deletions(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index a762bcd5bd..33d99bb6de 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -82,11 +82,27 @@ fn run() -> Result { return Err("unsupported_operation"); } - let repository = gix::open::Options::isolated() + let repository = match gix::open::Options::isolated() .strict_config(true) .open(request.repository_path) - .map_err(|_| "repository_open_failed")? - .to_thread_local(); + { + 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 => { @@ -107,27 +123,21 @@ fn run() -> Result { }); Ok(ExitCode::SUCCESS) } - gix::hash::Kind::Sha256 => { - write_response(&Response::RepositoryRejected { - protocol_version: PROTOCOL_VERSION, - reason: "unsupported_object_format", - object_format: "sha256".to_owned(), - supported_object_formats: ["sha1"], - }); - Ok(ExitCode::from(2)) - } - _ => { - write_response(&Response::RepositoryRejected { - protocol_version: PROTOCOL_VERSION, - reason: "unsupported_object_format", - object_format: "unknown".to_owned(), - supported_object_formats: ["sha1"], - }); - Ok(ExitCode::from(2)) - } + gix::hash::Kind::Sha256 => Ok(reject_unsupported_object_format("sha256".to_owned())), + _ => Ok(reject_unsupported_object_format("unknown".to_owned())), } } +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() diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index bc5152c8c3..a5064a8153 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -73,6 +73,38 @@ fn rejects_sha256_before_returning_repository_identity() { ); } +#[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); +} + fn invoke_helper(repository_path: &Path) -> Output { let mut child = Command::new(HELPER) .env("PATH", "") @@ -123,6 +155,36 @@ impl RepositoryFixture { 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) From b1d4e67eb4a73298d0d8c45c2ac58a50acd80458 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 22:14:03 +0800 Subject: [PATCH 39/86] feat(git): bind helper artifacts to opaque capabilities --- ...xide-helper-artifact-authority-v1.zh-CN.md | 115 ++++++++ ...e-short-lived-helper-admission-v1.zh-CN.md | 8 +- ...helper-artifact-authority-internal.test.ts | 164 +++++++++++ ...xide-helper-artifact-authority-internal.ts | 269 ++++++++++++++++++ 4 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/gitoxide-helper-artifact-authority-internal.test.ts create mode 100644 packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts 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..49bc1045e5 --- /dev/null +++ b/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md @@ -0,0 +1,115 @@ + + +# 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; +3. repository observation 再转换为 T1 前的 opaque admission capability。 + +在第 1 项完成以前,不接 Desktop/CLI,也不恢复旧 Git CLI adapter。 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 index fe3f23f83c..9af0a37afa 100644 --- a/docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md +++ b/docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md @@ -95,6 +95,8 @@ v1 的 `supportedObjectFormats` 固定为 `["sha1"]`。未来支持必须显式 ## 7. 下一切片 -下一 PR 只建立一个 owner 边界:由 Host/Storage 验证 helper artifact identity,并将一次 -repository observation 转换成 T1 前可消费的 opaque admission capability。source import、fresh -projection 与 candidate ref CAS 继续分别验证,不能在 admission PR 中顺手恢复旧 Git CLI adapter。 +后续 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/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/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..882da884b0 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts @@ -0,0 +1,269 @@ +/* + * 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 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, + 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', + ); + } + } +} From 08da1fb5716c3c8ed53b3fc6545d7d13319f3aad Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 22:29:20 +0800 Subject: [PATCH 40/86] feat(git): own short-lived helper invocations --- .../workflows/gitoxide-helper-admission.yml | 22 + ...xide-helper-artifact-authority-v1.zh-CN.md | 3 +- ...toxide-helper-invocation-owner-v1.zh-CN.md | 93 +++++ ...itoxide-helper-invocation-internal.test.ts | 155 +++++++ .../gitoxide-helper-invocation-internal.ts | 393 ++++++++++++++++++ 5 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts create mode 100644 packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index d3f8ac570f..35e3b9e72c 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -22,12 +22,18 @@ on: 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' + - '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' + - 'docs/architecture/gitoxide-*.md' permissions: contents: read @@ -49,9 +55,25 @@ jobs: - 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: Install JavaScript dependencies without packaging hooks + run: npm ci --ignore-scripts + - 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-invocation-internal.test.js diff --git a/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md b/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md index 49bc1045e5..2e21c7c47b 100644 --- a/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md +++ b/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md @@ -109,7 +109,8 @@ admission 与每次 invocation resolve 都执行这套校验。它可以识别 后续只能按下面顺序推进: 1. 发布/安装 owner 把受信 helper identity 绑定到 signed product artifact; -2. 短生命周期 invocation owner 消费 opaque capability 并运行 strict helper protocol; +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..11657fe0f9 --- /dev/null +++ b/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md @@ -0,0 +1,93 @@ + + +# 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。 +不在该切片中实现 source import、worktree projection、candidate 或 ref CAS。 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/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts new file mode 100644 index 0000000000..34a2902a99 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -0,0 +1,393 @@ +/* + * 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_REQUEST_BYTES = 64 * 1024; +const MAX_STDOUT_BYTES = 64 * 1024; +const MAX_STDERR_BYTES = 16 * 1024; +const INVOCATION_TIMEOUT_MS = 5_000; +const SHA1_OID_PATTERN = /^[0-9a-f]{40}$/; +const OBJECT_FORMAT_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; +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', +]); + +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 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); +} + +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; +}): 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'), + 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 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 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); +} From a45ce4bf5c83ebc1903b12b7f857182fc06d206a Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 22:35:05 +0800 Subject: [PATCH 41/86] feat(git): issue repository admission capabilities --- .../workflows/gitoxide-helper-admission.yml | 6 +- ...toxide-helper-invocation-owner-v1.zh-CN.md | 5 +- ...epository-admission-capability-v1.zh-CN.md | 92 +++++++++++ ...me-workspace-version-authority-v1.zh-CN.md | 16 +- ...itory-admission-authority-internal.test.ts | 156 ++++++++++++++++++ ...repository-admission-authority-internal.ts | 106 ++++++++++++ 6 files changed, 376 insertions(+), 5 deletions(-) create mode 100644 docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts create mode 100644 packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index 35e3b9e72c..deb4861724 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -76,4 +76,8 @@ jobs: - 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-invocation-internal.test.js + 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-repository-admission-authority-internal.test.js diff --git a/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md b/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md index 11657fe0f9..f68359a811 100644 --- a/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md +++ b/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md @@ -89,5 +89,6 @@ packaged-release trust root。 ## 6. 下一切片 下一步只把 exact repository observation 转换成 T1 前可消费的 owner-bound opaque admission -capability,并绑定 canonical repository path、object format、HEAD commit/tree 与 observation protocol。 -不在该切片中实现 source import、worktree projection、candidate 或 ref CAS。 +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-repository-admission-capability-v1.zh-CN.md b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md new file mode 100644 index 0000000000..1c75cdc171 --- /dev/null +++ b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md @@ -0,0 +1,92 @@ + + +# Gitoxide repository admission capability v1 + +状态:Gitoxide 验证栈的最后一个 API-only Draft;尚未接 T1、source import 或 managed workspace。 + +## 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、source import、projection、candidate 与 ref CAS。 + +因此这些 PR 可以作为 Gitoxide backend 的验证栈审查,但在正式 release owner 和生产消费者接入前 +继续保持 Draft。 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/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..9797f96d53 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts @@ -0,0 +1,156 @@ +/* + * 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, + GitoxideRepositoryAdmissionAuthorityError, + requireGitoxideRepositoryAdmissionInternal, +} 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'], + }, + ); +}); + +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(); +} 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..bbc1f238bd --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -0,0 +1,106 @@ +/* + * 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 { realpath } from 'node:fs/promises'; +import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artifact-authority-internal.js'; +import { + inspectRepositoryWithGitoxideHelperInternal, + type GitoxideRepositoryRejectionV1, +} from './gitoxide-helper-invocation-internal.js'; + +export interface GitoxideRepositoryAdmissionCapability { + readonly kind: 'gitoxide_repository_admission_capability_v1'; +} + +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') { + super('Gitoxide repository admission capability is invalid'); + this.name = 'GitoxideRepositoryAdmissionAuthorityError'; + } +} + +interface AdmissionCapabilityRecord { + readonly admissionOwnerToken: object; + readonly state: GitoxideRepositoryAdmissionStateInternal; +} + +const admissions = 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; +} From 4e5333cd802911aace1db663a6c515b55dc8cdd6 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 23:21:29 +0800 Subject: [PATCH 42/86] test(git): isolate concurrent helper fixtures --- native/gitoxide-helper/tests/repository_admission.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index a5064a8153..06891dbdba 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -22,10 +22,12 @@ use std::{ 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() { @@ -190,9 +192,10 @@ impl RepositoryFixture { .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}", - std::process::id() + "maka-gitoxide-helper-admission-{}-{nonce}-{sequence}", + std::process::id(), )); fs::create_dir_all(&root).unwrap(); let fixture = Self { root }; From 303ce5618cea49c32e0ff6e21dc9eb951b171fc0 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 23:29:08 +0800 Subject: [PATCH 43/86] feat(git): import admitted source heads --- ...epository-admission-capability-v1.zh-CN.md | 4 +- ...oxide-source-import-data-plane-v1.zh-CN.md | 66 +++++ native/gitoxide-helper/Cargo.toml | 1 + native/gitoxide-helper/src/main.rs | 273 +++++++++++++++++- .../tests/repository_admission.rs | 95 +++++- ...itory-admission-authority-internal.test.ts | 67 +++++ .../gitoxide-helper-invocation-internal.ts | 158 ++++++++++ ...repository-admission-authority-internal.ts | 35 +++ 8 files changed, 681 insertions(+), 18 deletions(-) create mode 100644 docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md diff --git a/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md index 1c75cdc171..1aa81dd329 100644 --- a/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md +++ b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md @@ -19,7 +19,7 @@ # Gitoxide repository admission capability v1 -状态:Gitoxide 验证栈的最后一个 API-only Draft;尚未接 T1、source import 或 managed workspace。 +状态:Gitoxide control-plane admission Draft;source import data plane 作为下一独立切片消费该 capability。 ## 1. 主要不变量 @@ -86,7 +86,7 @@ capability 表示一次明确线性化点上的 immutable Git commit/tree snapsh - signed packaged-release trust root 与受保护安装路径; - Desktop/CLI 消费者; -- T1 durable admission、source import、projection、candidate 与 ref CAS。 +- T1 durable admission、projection、candidate 与 ref CAS;source import 由后续独立 Draft 实现。 因此这些 PR 可以作为 Gitoxide backend 的验证栈审查,但在正式 release owner 和生产消费者接入前 继续保持 Draft。 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..c58eee81d4 --- /dev/null +++ b/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md @@ -0,0 +1,66 @@ + + +# 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。 + +## 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/native/gitoxide-helper/Cargo.toml b/native/gitoxide-helper/Cargo.toml index 3c1bc18359..66a0f19dc0 100644 --- a/native/gitoxide-helper/Cargo.toml +++ b/native/gitoxide-helper/Cargo.toml @@ -31,3 +31,4 @@ path = "src/main.rs" gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha256"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +unicode-normalization = "0.1" diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 33d99bb6de..2ffe301fc1 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -18,22 +18,41 @@ */ use std::{ + collections::HashSet, + fs, io::{self, Read}, path::PathBuf, process::ExitCode, }; use serde::{Deserialize, Serialize}; +use unicode_normalization::UnicodeNormalization; const PROTOCOL_VERSION: u8 = 1; const MAX_REQUEST_BYTES: u64 = 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; #[derive(Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct InspectRepositoryRequest { - protocol_version: u8, - operation: String, - repository_path: PathBuf, +#[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, + }, } #[derive(Serialize)] @@ -54,6 +73,18 @@ enum Response<'a> { 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")] HelperError { protocol_version: u8, reason: &'a str, @@ -75,16 +106,43 @@ fn main() -> ExitCode { fn run() -> Result { let request = read_request()?; - if request.protocol_version != PROTOCOL_VERSION { - return Err("unsupported_protocol_version"); + 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, + ) + } } - if request.operation != "inspect_repository" { - return Err("unsupported_operation"); +} + +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(request.repository_path) + .open(repository_path) { Ok(repository) => repository.to_thread_local(), Err(gix::open::Error::Config(gix::config::Error::ConfigTypedString(error))) @@ -128,6 +186,199 @@ fn run() -> Result { } } +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(); + + match fs::symlink_metadata(&destination_repository_path) { + Ok(_) => return Err("import_destination_not_fresh"), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(_) => return Err("import_destination_unreadable"), + } + let destination = gix::init_bare(&destination_repository_path) + .map_err(|_| "import_destination_create_failed")?; + 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 = ImportStats::default(); + copy_source_tree(&source, &destination, source_tree, "", &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(); + 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, + stats: &mut ImportStats, +) -> Result<(), &'static str> { + 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) { + return Err("unsupported_source_path"); + } + let relative_path = if prefix.is_empty() { + component.to_owned() + } else { + format!("{prefix}/{component}") + }; + let folded_path: String = relative_path.nfc().flat_map(char::to_lowercase).collect(); + if !stats.folded_paths.insert(folded_path) { + return Err("source_path_collision"); + } + match entry.mode().kind() { + gix::objs::tree::EntryKind::Tree => { + copy_source_tree( + source, + destination, + entry.object_id(), + &relative_path, + stats, + )?; + } + gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable => { + stats.files = stats + .files + .checked_add(1) + .filter(|count| *count <= MAX_IMPORT_FILES) + .ok_or("source_file_limit_exceeded")?; + let header = entry.id().header().map_err(|_| "source_blob_unavailable")?; + if header.kind() != gix::objs::Kind::Blob || header.size() > MAX_IMPORT_FILE_BYTES { + return Err("source_file_limit_exceeded"); + } + stats.bytes = stats + .bytes + .checked_add(header.size()) + .filter(|bytes| *bytes <= MAX_IMPORT_BYTES) + .ok_or("source_byte_limit_exceeded")?; + 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 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(Default)] +struct ImportStats { + files: u64, + bytes: u64, + folded_paths: HashSet, +} + fn reject_unsupported_object_format(object_format: String) -> ExitCode { write_response(&Response::RepositoryRejected { protocol_version: PROTOCOL_VERSION, @@ -138,7 +389,7 @@ fn reject_unsupported_object_format(object_format: String) -> ExitCode { ExitCode::from(2) } -fn read_request() -> Result { +fn read_request() -> Result { let mut bytes = Vec::new(); io::stdin() .take(MAX_REQUEST_BYTES + 1) diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index 06891dbdba..9b5499e1ac 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -107,7 +107,76 @@ fn observes_raw_head_identity_instead_of_replacement_ref_semantics() { 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()); +} + 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") @@ -118,11 +187,6 @@ fn invoke_helper(repository_path: &Path) -> Output { .stderr(Stdio::piped()) .spawn() .unwrap(); - let request = serde_json::json!({ - "protocolVersion": 1, - "operation": "inspect_repository", - "repositoryPath": repository_path, - }); child .stdin .take() @@ -132,6 +196,27 @@ fn invoke_helper(repository_path: &Path) -> Output { 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() +} + struct RepositoryFixture { root: PathBuf, } 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 index 9797f96d53..70843bb65d 100644 --- 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 @@ -32,6 +32,7 @@ import { import { admitGitoxideRepositoryInternal, GitoxideRepositoryAdmissionAuthorityError, + importAdmittedGitoxideRepositoryInternal, requireGitoxideRepositoryAdmissionInternal, } from '../server/gitoxide-repository-admission-authority-internal.js'; @@ -116,6 +117,66 @@ test('returns a policy rejection without issuing an admission capability', async ); }); +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 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, + 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, + destinationRepositoryPath: join(repositoryPath, 'forged.git'), + baselineRef: 'refs/maka/forged', + }), + (error) => + error instanceof GitoxideRepositoryAdmissionAuthorityError && + error.code === 'gitoxide_repository_admission_capability_invalid', + ); +}); + async function admittedHelper(): Promise { if (admittedHelperPromise) return admittedHelperPromise; admittedHelperPromise = (async () => { @@ -154,3 +215,9 @@ async function createRepository(t: TestContext, objectFormat: 'sha1' | 'sha256') 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/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts index 34a2902a99..9f34d435c4 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -32,6 +32,7 @@ const MAX_STDERR_BYTES = 16 * 1024; const INVOCATION_TIMEOUT_MS = 5_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', @@ -41,6 +42,31 @@ const HELPER_ERROR_REASONS = new Set([ 'repository_open_failed', 'head_commit_unavailable', 'head_tree_unavailable', + 'baseline_commit_write_failed', + 'baseline_publish_failed', + 'baseline_ref_outside_maka_namespace', + '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', + '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_tree_copy_failed', + 'source_tree_identity_mismatch', + 'source_tree_invalid', + 'source_tree_unavailable', + 'unsupported_source_entry_kind', + 'unsupported_source_path', ]); export interface GitoxideRepositoryObservationV1 { @@ -63,6 +89,19 @@ 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 type GitoxideHelperInvocationErrorCode = | 'gitoxide_helper_invocation_invalid' | 'gitoxide_helper_invocation_spawn_failed' @@ -129,6 +168,61 @@ export async function inspectRepositoryWithGitoxideHelperInternal(input: { 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); +} + interface HelperProcessOutcome { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null; @@ -293,6 +387,70 @@ function decodeOutcome(outcome: HelperProcessOutcome): GitoxideRepositoryInspect ); } +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 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, [ 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 index bbc1f238bd..115128ed20 100644 --- a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -20,7 +20,9 @@ import { realpath } from 'node:fs/promises'; import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artifact-authority-internal.js'; import { + importSourceHeadWithGitoxideHelperInternal, inspectRepositoryWithGitoxideHelperInternal, + type GitoxideSourceImportObservationV1, type GitoxideRepositoryRejectionV1, } from './gitoxide-helper-invocation-internal.js'; @@ -104,3 +106,36 @@ export function requireGitoxideRepositoryAdmissionInternal( } return state.state; } + +export async function importAdmittedGitoxideRepositoryInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly admissionOwnerToken: object; + readonly repositoryCapability: GitoxideRepositoryAdmissionCapability; + 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', + ); + } + return result; +} From 83c5e095dd27949cacadf7ce44b07f785b8e2ddd Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 23:44:39 +0800 Subject: [PATCH 44/86] build(git): lock source import dependency --- native/gitoxide-helper/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/native/gitoxide-helper/Cargo.lock b/native/gitoxide-helper/Cargo.lock index b37203abb7..c71ae8d448 100644 --- a/native/gitoxide-helper/Cargo.lock +++ b/native/gitoxide-helper/Cargo.lock @@ -1033,6 +1033,7 @@ dependencies = [ "gix", "serde", "serde_json", + "unicode-normalization", ] [[package]] From a00d338b91d4d105af8dda8808ba11786e43aa88 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:05:16 +0800 Subject: [PATCH 45/86] fix(git): bound managed tree traversal --- native/gitoxide-helper/src/main.rs | 206 ++++++++++++++++++++++++++--- 1 file changed, 185 insertions(+), 21 deletions(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 2ffe301fc1..4f3fe662f0 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -33,6 +33,17 @@ const MAX_REQUEST_BYTES: u64 = 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, +}; #[derive(Deserialize)] #[serde( @@ -242,8 +253,16 @@ fn import_source_head( fs::create_dir(destination_repository_path.join("hooks")) .map_err(|_| "import_hooks_cleanup_failed")?; - let mut stats = ImportStats::default(); - copy_source_tree(&source, &destination, source_tree, "", &mut stats)?; + 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(), @@ -289,8 +308,11 @@ fn copy_source_tree( destination: &gix::Repository, tree_oid: gix::hash::ObjectId, prefix: &str, - stats: &mut ImportStats, + 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")?; @@ -298,7 +320,9 @@ fn copy_source_tree( 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) { + 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() { @@ -306,10 +330,7 @@ fn copy_source_tree( } else { format!("{prefix}/{component}") }; - let folded_path: String = relative_path.nfc().flat_map(char::to_lowercase).collect(); - if !stats.folded_paths.insert(folded_path) { - return Err("source_path_collision"); - } + stats.observe_entry(&relative_path, policy)?; match entry.mode().kind() { gix::objs::tree::EntryKind::Tree => { copy_source_tree( @@ -317,24 +338,17 @@ fn copy_source_tree( 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 => { - stats.files = stats - .files - .checked_add(1) - .filter(|count| *count <= MAX_IMPORT_FILES) - .ok_or("source_file_limit_exceeded")?; let header = entry.id().header().map_err(|_| "source_blob_unavailable")?; - if header.kind() != gix::objs::Kind::Blob || header.size() > MAX_IMPORT_FILE_BYTES { - return Err("source_file_limit_exceeded"); + if header.kind() != gix::objs::Kind::Blob { + return Err("source_blob_invalid"); } - stats.bytes = stats - .bytes - .checked_add(header.size()) - .filter(|bytes| *bytes <= MAX_IMPORT_BYTES) - .ok_or("source_byte_limit_exceeded")?; + stats.observe_blob(header.size(), policy)?; let blob = entry .object() .map_err(|_| "source_blob_unavailable")? @@ -372,13 +386,163 @@ fn is_supported_source_component(component: &str) -> bool { && !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 ImportStats { +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") + ); + } +} + fn reject_unsupported_object_format(object_format: String) -> ExitCode { write_response(&Response::RepositoryRejected { protocol_version: PROTOCOL_VERSION, From c93034720141860c0060ccf4b5d6230c4f8734c3 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:06:30 +0800 Subject: [PATCH 46/86] fix(runtime-host): preserve tree policy failures --- .../src/server/gitoxide-helper-invocation-internal.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts index 9f34d435c4..416c9ef773 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -61,10 +61,15 @@ const HELPER_ERROR_REASONS = new Set([ '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', 'unsupported_source_entry_kind', 'unsupported_source_path', ]); From 94ab9afa43d5436f3d5d1962a20d8f9c2cc9d4b5 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 23:54:20 +0800 Subject: [PATCH 47/86] feat(git): publish exact-base successors --- ...oxide-source-import-data-plane-v1.zh-CN.md | 3 + ...e-successor-ref-cas-data-plane-v1.zh-CN.md | 59 ++++++ native/gitoxide-helper/src/main.rs | 179 +++++++++++++++- .../tests/repository_admission.rs | 113 ++++++++++ ...itory-admission-authority-internal.test.ts | 88 ++++++++ .../gitoxide-helper-invocation-internal.ts | 198 +++++++++++++++++- ...repository-admission-authority-internal.ts | 117 ++++++++++- 7 files changed, 751 insertions(+), 6 deletions(-) create mode 100644 docs/architecture/gitoxide-successor-ref-cas-data-plane-v1.zh-CN.md 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 index c58eee81d4..176ee4a09a 100644 --- a/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md +++ b/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md @@ -29,6 +29,9 @@ > 只把该 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; 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..da606c4d54 --- /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; +2. M1.3 product composition 消费 admission/import/candidate/projection capabilities; +3. 数据面完成后,从最新 `main` 重建 M2.2 candidate durable owner 与 M2.4 Write/Edit 生产闭环。 diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 4f3fe662f0..63aae09db5 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -29,7 +29,7 @@ use serde::{Deserialize, Serialize}; use unicode_normalization::UnicodeNormalization; const PROTOCOL_VERSION: u8 = 1; -const MAX_REQUEST_BYTES: u64 = 64 * 1024; +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; @@ -64,6 +64,14 @@ enum Request { 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, + }, } #[derive(Serialize)] @@ -96,6 +104,26 @@ enum Response<'a> { 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")] HelperError { protocol_version: u8, reason: &'a str, @@ -140,6 +168,23 @@ fn run() -> Result { 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, + ) + } } } @@ -375,6 +420,138 @@ fn copy_source_tree( Ok(()) } +fn create_successor( + repository_path: PathBuf, + expected_base_commit_oid: String, + target_ref: String, + path: String, + content: String, +) -> 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"); + } + + 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(); + 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 + .find_reference(target_ref.as_str()) + .map_err(|_| "target_ref_unavailable")? + .into_fully_peeled_id() + .map_err(|_| "target_ref_unavailable")? + .detach(); + if current != expected_base && current != 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.to_string(), + target_ref, + }); + return Ok(ExitCode::from(3)); + } + if current == 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 is_canonical_successor_path(path: &str) -> bool { + path.len() <= 4096 + && !path.is_empty() + && !path.starts_with('/') + && !path.contains('\\') + && !path.contains('\0') + && path.split('/').all(|component| { + !component.is_empty() + && component != "." + && component != ".." + && !component.eq_ignore_ascii_case(".git") + }) +} + fn is_supported_source_component(component: &str) -> bool { !component.is_empty() && component != "." diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index 9b5499e1ac..22a872159d 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -168,6 +168,108 @@ fn imports_an_exact_source_head_into_a_fresh_managed_repository() { assert!(!destination.join("objects/info/alternates").exists()); } +#[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 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"] + ); +} + fn invoke_helper(repository_path: &Path) -> Output { invoke_request(serde_json::json!({ "protocolVersion": 1, @@ -217,6 +319,17 @@ fn git_bare_succeeds(repository: &Path, args: [&str; N]) -> bool .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, } 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 index 70843bb65d..69b8fa98a7 100644 --- 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 @@ -31,6 +31,7 @@ import { } from '../server/gitoxide-helper-artifact-authority-internal.js'; import { admitGitoxideRepositoryInternal, + createGitoxideSuccessorInternal, GitoxideRepositoryAdmissionAuthorityError, importAdmittedGitoxideRepositoryInternal, requireGitoxideRepositoryAdmissionInternal, @@ -139,6 +140,7 @@ test('imports only the exact repository identity bound to the admission capabili 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, @@ -152,6 +154,7 @@ test('imports only the exact repository identity bound to the admission capabili ...helper, admissionOwnerToken, repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, destinationRepositoryPath, baselineRef: 'refs/maka/baseline', }); @@ -168,6 +171,7 @@ test('imports only the exact repository identity bound to the admission capabili ...helper, admissionOwnerToken: {}, repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, destinationRepositoryPath: join(repositoryPath, 'forged.git'), baselineRef: 'refs/maka/forged', }), @@ -177,6 +181,90 @@ test('imports only the exact repository identity bound to the admission capabili ); }); +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', + ); +}); + async function admittedHelper(): Promise { if (admittedHelperPromise) return admittedHelperPromise; admittedHelperPromise = (async () => { diff --git a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts index 416c9ef773..b9b6b6aa19 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -26,7 +26,8 @@ import { verifyGitoxideHelperArtifactForInvocationInternal, } from './gitoxide-helper-artifact-authority-internal.js'; -const MAX_REQUEST_BYTES = 64 * 1024; +const MAX_SUCCESSOR_CONTENT_BYTES = 64 * 1024 * 1024; +const MAX_REQUEST_BYTES = MAX_SUCCESSOR_CONTENT_BYTES + 64 * 1024; const MAX_STDOUT_BYTES = 64 * 1024; const MAX_STDERR_BYTES = 16 * 1024; const INVOCATION_TIMEOUT_MS = 5_000; @@ -45,12 +46,19 @@ const HELPER_ERROR_REASONS = new Set([ 'baseline_commit_write_failed', 'baseline_publish_failed', 'baseline_ref_outside_maka_namespace', + 'base_commit_unavailable', + 'base_path_lookup_failed', + 'base_tree_unavailable', + 'blob_write_failed', + 'commit_write_failed', '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_successor_path', 'source_blob_copy_failed', 'source_blob_identity_mismatch', 'source_blob_invalid', @@ -70,6 +78,13 @@ const HELPER_ERROR_REASONS = new Set([ 'source_tree_invalid', 'source_tree_unavailable', 'source_tree_visit_limit_exceeded', + '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_source_entry_kind', 'unsupported_source_path', ]); @@ -107,6 +122,30 @@ export interface GitoxideSourceImportObservationV1 { 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 type GitoxideHelperInvocationErrorCode = | 'gitoxide_helper_invocation_invalid' | 'gitoxide_helper_invocation_spawn_failed' @@ -228,6 +267,64 @@ export async function importSourceHeadWithGitoxideHelperInternal(input: { 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); +} + interface HelperProcessOutcome { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null; @@ -421,6 +518,86 @@ function decodeSourceImportOutcome( ); } +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)) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + `Gitoxide helper could not publish the successor: ${value.reason}`, + value.reason, + ); + } + const stderr = outcome.stderr.toString('utf8').trim(); + throw protocolInvalid( + `Gitoxide helper exit code and response disagree${stderr ? `: ${stderr}` : ''}`, + ); +} + +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 isSourceImportObservation(value: unknown): value is GitoxideSourceImportObservationV1 { return ( hasExactKeys(value, [ @@ -519,6 +696,25 @@ function hasExactKeys( return keys.length === expected.length && keys.every((key, index) => key === expected[index]); } +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 helperEnvironment(): NodeJS.ProcessEnv { return { PATH: '', 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 index 115128ed20..f57bb1c536 100644 --- a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -22,6 +22,8 @@ import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artif import { importSourceHeadWithGitoxideHelperInternal, inspectRepositoryWithGitoxideHelperInternal, + createSuccessorWithGitoxideHelperInternal, + type GitoxideSuccessorPublishedV1, type GitoxideSourceImportObservationV1, type GitoxideRepositoryRejectionV1, } from './gitoxide-helper-invocation-internal.js'; @@ -30,6 +32,18 @@ export interface GitoxideRepositoryAdmissionCapability { readonly kind: 'gitoxide_repository_admission_capability_v1'; } +export interface GitoxideManagedRepositoryCapability { + readonly kind: 'gitoxide_managed_repository_capability_v1'; +} + +export interface GitoxideManagedRepositoryImportResultV1 extends GitoxideSourceImportObservationV1 { + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; +} + +export interface GitoxideManagedRepositorySuccessorResultV1 extends GitoxideSuccessorPublishedV1 { + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; +} + export interface GitoxideRepositoryAdmissionStateInternal { readonly protocolVersion: 1; readonly repositoryPath: string; @@ -46,8 +60,16 @@ export type GitoxideRepositoryAdmissionResultV1 = | GitoxideRepositoryRejectionV1; export class GitoxideRepositoryAdmissionAuthorityError extends Error { - constructor(readonly code: 'gitoxide_repository_admission_capability_invalid') { - super('Gitoxide repository admission capability is invalid'); + constructor( + readonly code: + | 'gitoxide_repository_admission_capability_invalid' + | 'gitoxide_managed_repository_base_mismatch', + ) { + super( + code === 'gitoxide_managed_repository_base_mismatch' + ? 'Gitoxide managed repository base no longer matches' + : 'Gitoxide repository admission capability is invalid', + ); this.name = 'GitoxideRepositoryAdmissionAuthorityError'; } } @@ -59,6 +81,16 @@ interface AdmissionCapabilityRecord { 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(); + export async function admitGitoxideRepositoryInternal(input: { readonly invocationOwnerToken: object; readonly helperCapability: GitoxideHelperInvocationCapability; @@ -112,10 +144,11 @@ export async function importAdmittedGitoxideRepositoryInternal(input: { readonly helperCapability: GitoxideHelperInvocationCapability; readonly admissionOwnerToken: object; readonly repositoryCapability: GitoxideRepositoryAdmissionCapability; + readonly managedRepositoryOwnerToken: object; readonly destinationRepositoryPath: string; readonly baselineRef: string; readonly abortSignal?: AbortSignal; -}): Promise { +}): Promise { const source = requireGitoxideRepositoryAdmissionInternal( input.admissionOwnerToken, input.repositoryCapability, @@ -137,5 +170,81 @@ export async function importAdmittedGitoxideRepositoryInternal(input: { 'gitoxide_repository_admission_capability_invalid', ); } - return result; + 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 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 }); +} + +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; } From d3f749b297e9920320997b41c767e914d98cadf4 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 23:56:52 +0800 Subject: [PATCH 48/86] build(git): enable tree editing --- native/gitoxide-helper/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/gitoxide-helper/Cargo.toml b/native/gitoxide-helper/Cargo.toml index 66a0f19dc0..4e0affcca9 100644 --- a/native/gitoxide-helper/Cargo.toml +++ b/native/gitoxide-helper/Cargo.toml @@ -28,7 +28,7 @@ name = "maka-gitoxide-helper" path = "src/main.rs" [dependencies] -gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha256"] } +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" From 081a57ed3ece0bfe067c3a3456089bf716fdfae5 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:09:06 +0800 Subject: [PATCH 49/86] fix(git): validate complete successor trees --- native/gitoxide-helper/src/main.rs | 60 +++++++++++++++++++ .../tests/repository_admission.rs | 41 +++++++++++++ 2 files changed, 101 insertions(+) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 63aae09db5..4793926769 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -478,6 +478,7 @@ fn create_successor( .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(), @@ -538,6 +539,65 @@ fn create_successor( 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() <= 4096 && !path.is_empty() diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index 22a872159d..e310630542 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -270,6 +270,47 @@ fn rejects_a_successor_when_the_target_ref_no_longer_matches_the_base() { ); } +#[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, From 05f0148f76d90450097aa61a2ee7f73b63a3f029 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 02:10:15 +0800 Subject: [PATCH 50/86] feat(runtime-host): prepare owner-bound Gitoxide candidates --- native/gitoxide-helper/src/main.rs | 95 ++++++++++++++ .../tests/repository_admission.rs | 54 ++++++++ ...itory-admission-authority-internal.test.ts | 86 +++++++++++++ .../gitoxide-helper-invocation-internal.ts | 69 +++++++++++ ...repository-admission-authority-internal.ts | 117 +++++++++++++++++- 5 files changed, 419 insertions(+), 2 deletions(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 4793926769..504ede5d70 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -72,6 +72,15 @@ enum Request { 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, + }, } #[derive(Serialize)] @@ -185,6 +194,25 @@ fn run() -> Result { content, ) } + 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, + ) + } } } @@ -420,6 +448,73 @@ fn copy_source_tree( 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)); + } + let candidate_exists = repository + .try_find_reference(candidate_ref.as_str()) + .map_err(|_| "candidate_ref_unavailable")? + .is_some(); + if !candidate_exists { + let publication = repository + .reference( + candidate_ref.as_str(), + expected_base, + gix::refs::transaction::PreviousValue::MustNotExist, + "maka managed workspace candidate base", + ); + if publication.is_err() { + repository + .find_reference(candidate_ref.as_str()) + .map_err(|_| "candidate_publish_failed")?; + } + } + create_successor( + repository_path, + expected_base_commit_oid, + candidate_ref, + path, + content, + ) +} + fn create_successor( repository_path: PathBuf, expected_base_commit_oid: String, diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index e310630542..df24731525 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -223,6 +223,60 @@ fn publishes_and_exactly_retries_a_successor_from_the_current_ref() { 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 rejects_a_successor_when_the_target_ref_no_longer_matches_the_base() { let fixture = RepositoryFixture::sha1_with_commit(); 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 index 69b8fa98a7..13038ba079 100644 --- 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 @@ -34,7 +34,9 @@ import { createGitoxideSuccessorInternal, GitoxideRepositoryAdmissionAuthorityError, importAdmittedGitoxideRepositoryInternal, + prepareGitoxideMutationCandidateInternal, requireGitoxideRepositoryAdmissionInternal, + requireGitoxideMutationCandidateInternal, } from '../server/gitoxide-repository-admission-authority-internal.js'; interface AdmittedHelper { @@ -265,6 +267,90 @@ test('binds successor publication to the imported repository capability and exac ); }); +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); + await assert.rejects( + async () => requireGitoxideMutationCandidateInternal({}, candidate.candidateCapability), + (error) => + error instanceof GitoxideRepositoryAdmissionAuthorityError && + error.code === 'gitoxide_mutation_candidate_capability_invalid', + ); +}); + async function admittedHelper(): Promise { if (admittedHelperPromise) return admittedHelperPromise; admittedHelperPromise = (async () => { diff --git a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts index b9b6b6aa19..fe7330a892 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -46,6 +46,9 @@ const HELPER_ERROR_REASONS = new Set([ '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', @@ -325,6 +328,68 @@ export async function createSuccessorWithGitoxideHelperInternal(input: { 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); +} + interface HelperProcessOutcome { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null; @@ -715,6 +780,10 @@ function isCanonicalSuccessorPath(path: string): boolean { ); } +function isCandidateRef(ref: string): boolean { + return MAKA_REF_PATTERN.test(ref) && ref.startsWith('refs/maka/candidates/'); +} + function helperEnvironment(): NodeJS.ProcessEnv { return { PATH: '', 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 index f57bb1c536..bcd630eb7f 100644 --- a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -17,12 +17,14 @@ * 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, inspectRepositoryWithGitoxideHelperInternal, createSuccessorWithGitoxideHelperInternal, + prepareCandidateWithGitoxideHelperInternal, type GitoxideSuccessorPublishedV1, type GitoxideSourceImportObservationV1, type GitoxideRepositoryRejectionV1, @@ -36,6 +38,10 @@ export interface GitoxideManagedRepositoryCapability { readonly kind: 'gitoxide_managed_repository_capability_v1'; } +export interface GitoxideMutationCandidateCapability { + readonly kind: 'gitoxide_mutation_candidate_capability_v1'; +} + export interface GitoxideManagedRepositoryImportResultV1 extends GitoxideSourceImportObservationV1 { readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; } @@ -44,6 +50,23 @@ export interface GitoxideManagedRepositorySuccessorResultV1 extends GitoxideSucc 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 GitoxideRepositoryAdmissionStateInternal { readonly protocolVersion: 1; readonly repositoryPath: string; @@ -63,12 +86,15 @@ export class GitoxideRepositoryAdmissionAuthorityError extends Error { constructor( readonly code: | 'gitoxide_repository_admission_capability_invalid' - | 'gitoxide_managed_repository_base_mismatch', + | 'gitoxide_managed_repository_base_mismatch' + | 'gitoxide_mutation_candidate_capability_invalid', ) { super( code === 'gitoxide_managed_repository_base_mismatch' ? 'Gitoxide managed repository base no longer matches' - : 'Gitoxide repository admission capability is invalid', + : code === 'gitoxide_mutation_candidate_capability_invalid' + ? 'Gitoxide mutation candidate capability is invalid' + : 'Gitoxide repository admission capability is invalid', ); this.name = 'GitoxideRepositoryAdmissionAuthorityError'; } @@ -91,6 +117,13 @@ interface ManagedRepositoryCapabilityRecord { const managedRepositories = new WeakMap(); +interface MutationCandidateCapabilityRecord { + readonly candidateOwnerToken: object; + readonly state: GitoxideMutationCandidateStateInternal; +} + +const mutationCandidates = new WeakMap(); + export async function admitGitoxideRepositoryInternal(input: { readonly invocationOwnerToken: object; readonly helperCapability: GitoxideHelperInvocationCapability; @@ -226,6 +259,86 @@ export async function createGitoxideSuccessorInternal(input: { 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')}`; + 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; +} + function issueManagedRepositoryCapability( record: ManagedRepositoryCapabilityRecord, ): GitoxideManagedRepositoryCapability { From 8ddc86443d9cd6da99b99da86fbfef801454c2a8 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 02:18:17 +0800 Subject: [PATCH 51/86] feat(runtime-host): persist Gitoxide candidate receipts --- .../workflows/gitoxide-helper-admission.yml | 1 + ...oxide-mutation-candidate-owner-v1.zh-CN.md | 95 +++++ ...ace-mutation-version-authority-v1.zh-CN.md | 9 +- native/gitoxide-helper/src/main.rs | 64 ++- .../tests/repository_admission.rs | 30 ++ .../gitoxide-candidate-crash-child.ts | 72 ++++ ...ation-candidate-authority-internal.test.ts | 331 +++++++++++++++ ...itory-admission-authority-internal.test.ts | 33 ++ .../gitoxide-helper-invocation-internal.ts | 95 +++++ ...r-mutation-candidate-authority-internal.ts | 385 ++++++++++++++++++ ...repository-admission-authority-internal.ts | 37 ++ 11 files changed, 1142 insertions(+), 10 deletions(-) create mode 100644 docs/architecture/gitoxide-mutation-candidate-owner-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/fixtures/gitoxide-candidate-crash-child.ts create mode 100644 packages/runtime-host/src/__tests__/gitoxide-helper-mutation-candidate-authority-internal.test.ts create mode 100644 packages/runtime-host/src/server/gitoxide-helper-mutation-candidate-authority-internal.ts diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index deb4861724..69f21f1c67 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -80,4 +80,5 @@ jobs: 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 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..dcea0b4fff --- /dev/null +++ b/docs/architecture/gitoxide-mutation-candidate-owner-v1.zh-CN.md @@ -0,0 +1,95 @@ + + +# 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; +- 原子边界: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. 平台能力矩阵 + +| 平台 | 当前承诺 | +|---|---| +| 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 组合证明。 + +## 5. 不属于本切片 + +- 不提交 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/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md index cd671a544d..f1e712c33b 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.3 — Mutation execution admission diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 504ede5d70..16f11f9d04 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -81,6 +81,11 @@ enum Request { path: String, content: String, }, + InspectRef { + protocol_version: u8, + repository_path: PathBuf, + target_ref: String, + }, } #[derive(Serialize)] @@ -133,6 +138,14 @@ enum Response<'a> { 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")] HelperError { protocol_version: u8, reason: &'a str, @@ -213,6 +226,14 @@ fn run() -> Result { content, ) } + Request::InspectRef { + protocol_version, + repository_path, + target_ref, + } => { + assert_protocol_version(protocol_version)?; + inspect_ref(repository_path, target_ref) + } } } @@ -493,13 +514,12 @@ fn prepare_candidate( .map_err(|_| "candidate_ref_unavailable")? .is_some(); if !candidate_exists { - let publication = repository - .reference( - candidate_ref.as_str(), - expected_base, - gix::refs::transaction::PreviousValue::MustNotExist, - "maka managed workspace candidate base", - ); + let publication = repository.reference( + candidate_ref.as_str(), + expected_base, + gix::refs::transaction::PreviousValue::MustNotExist, + "maka managed workspace candidate base", + ); if publication.is_err() { repository .find_reference(candidate_ref.as_str()) @@ -515,6 +535,36 @@ fn prepare_candidate( ) } +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 create_successor( repository_path: PathBuf, expected_base_commit_oid: String, diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index df24731525..c4f5698056 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -277,6 +277,36 @@ fn prepares_and_exactly_retries_a_candidate_without_advancing_the_accepted_ref() ); } +#[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 rejects_a_successor_when_the_target_ref_no_longer_matches_the_base() { let fixture = RepositoryFixture::sha1_with_commit(); 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..21061c9430 --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/gitoxide-candidate-crash-child.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 { createHash } from 'node:crypto'; +import { readFile, realpath, stat, writeFile } from 'node:fs/promises'; +import type { WorkspaceHeadRecordV1 } from '@maka/core/workspace-version-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 authority = await createGitoxideMutationCandidateAuthorityInternal({ + invocationOwnerToken, + helperCapability, + storageRoot: input.storageRoot, + 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__/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..604cf95a91 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-helper-mutation-candidate-authority-internal.test.ts @@ -0,0 +1,331 @@ +/* + * 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 { + 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('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, + storageRoot: fixture.storageRoot, + 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, + storageRoot: fixture.storageRoot, + baseHead: fixture.baseHead, + }); + const retry = await reopened.capture(input); + assert.deepEqual(retry.receipt, first.receipt); + assert.notEqual(retry.candidateCapability, first.candidateCapability); +}); + +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, + storageRoot: fixture.storageRoot, + 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, + storageRoot: fixture.storageRoot, + 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, + }), + ); + 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 reopened = await createGitoxideMutationCandidateAuthorityInternal({ + ...fixture.helper, + storageRoot: fixture.storageRoot, + 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 storageRoot = join(root, 'storage'); + 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 baseHead: 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, baseHead); + 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.baselineCommitOid, baseHead.commitOid); + assert.equal(imported.baselineTreeOid, baseHead.treeOid); + return { helper, storageRoot, 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-repository-admission-authority-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts index 13038ba079..d9f1141e8c 100644 --- 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 @@ -35,6 +35,7 @@ import { GitoxideRepositoryAdmissionAuthorityError, importAdmittedGitoxideRepositoryInternal, prepareGitoxideMutationCandidateInternal, + reopenGitoxideManagedRepositoryInternal, requireGitoxideRepositoryAdmissionInternal, requireGitoxideMutationCandidateInternal, } from '../server/gitoxide-repository-admission-authority-internal.js'; @@ -343,6 +344,38 @@ test('prepares an owner-bound candidate without advancing the accepted ref', asy }); 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) => diff --git a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts index fe7330a892..dae20344c9 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -149,6 +149,15 @@ export interface GitoxideSuccessorRejectedV1 { 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 type GitoxideHelperInvocationErrorCode = | 'gitoxide_helper_invocation_invalid' | 'gitoxide_helper_invocation_spawn_failed' @@ -390,6 +399,46 @@ export async function prepareCandidateWithGitoxideHelperInternal(input: { 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); +} + interface HelperProcessOutcome { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null; @@ -608,6 +657,30 @@ function decodeSuccessorOutcome(outcome: HelperProcessOutcome): GitoxideSuccesso ); } +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 isSuccessorPublished(value: unknown): value is GitoxideSuccessorPublishedV1 { return ( hasExactKeys(value, [ @@ -663,6 +736,28 @@ function isSuccessorRejected(value: unknown): value is GitoxideSuccessorRejected ); } +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, [ 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..02a3594f66 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-helper-mutation-candidate-authority-internal.ts @@ -0,0 +1,385 @@ +/* + * 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 { syncDirectory } from '@maka/storage/stable-storage'; +import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artifact-authority-internal.js'; +import { + type GitoxideMutationCandidateCapability, + prepareGitoxideMutationCandidateInternal, + reopenGitoxideManagedRepositoryInternal, + requireGitoxideMutationCandidateInternal, +} from './gitoxide-repository-admission-authority-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 { + capture(input: GitoxideMutationCandidateCaptureInput): Promise; + validate(proof: GitoxideMutationCandidateProofV1): GitoxideMutationCandidateReceiptV1; +} + +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 storageRoot: string; + readonly baseHead: WorkspaceHeadRecordV1; + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly failpoint?: (point: GitoxideMutationCandidateFailpoint) => void | Promise; +}): Promise { + const storageRoot = await realpath(input.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); + + const managedRepositoryOwnerToken = {}; + const candidateOwnerToken = {}; + const managedRepositoryCapability = await reopenGitoxideManagedRepositoryInternal({ + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + repositoryPath, + acceptedRef: ACCEPTED_REF, + expectedAcceptedCommitOid: input.baseHead.commitOid, + expectedAcceptedTreeOid: input.baseHead.treeOid, + }); + + const capture = async ( + request: GitoxideMutationCandidateCaptureInput, + ): Promise => { + assertCaptureInput(request); + const operationIdentitySha256 = sha256(request.operationId); + const receiptPath = join(canonicalReceiptRoot, `${operationIdentitySha256.slice(7)}.json`); + return withProcessLifetimeFileUpdateLock(receiptPath, async () => { + request.abortSignal?.throwIfAborted(); + const durable = await readReceipt(receiptPath); + 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'); + } + return Object.freeze({ + receipt: durable ?? expected, + candidateCapability: candidate.candidateCapability, + }); + }); + }; + + return Object.freeze({ + capture, + validate(proof: GitoxideMutationCandidateProofV1) { + 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 proof.receipt; + }, + }); +} + +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'); +} + +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-repository-admission-authority-internal.ts b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts index bcd630eb7f..4878dee20b 100644 --- a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -22,6 +22,7 @@ import { realpath } from 'node:fs/promises'; import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artifact-authority-internal.js'; import { importSourceHeadWithGitoxideHelperInternal, + inspectManagedRefWithGitoxideHelperInternal, inspectRepositoryWithGitoxideHelperInternal, createSuccessorWithGitoxideHelperInternal, prepareCandidateWithGitoxideHelperInternal, @@ -213,6 +214,42 @@ export async function importAdmittedGitoxideRepositoryInternal(input: { 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; From eca997b2cee0fe684839cf4f03077e5fb82a593e Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 02:26:04 +0800 Subject: [PATCH 52/86] test(runtime-host): bind candidates to imported baseline --- ...er-mutation-candidate-authority-internal.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 index 604cf95a91..efbc392025 100644 --- 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 @@ -221,7 +221,7 @@ async function candidateFixture(t: TestContext) { }); assert.equal(admitted.kind, 'accepted'); if (admitted.kind !== 'accepted') return undefined; - const baseHead: WorkspaceHeadRecordV1 = { + const sourceHead: WorkspaceHeadRecordV1 = { repositoryId: `repository_${'1'.repeat(32)}`, workspaceId: `workspace_${'2'.repeat(32)}`, workspaceEpochId: `epoch_${'3'.repeat(32)}`, @@ -231,7 +231,7 @@ async function candidateFixture(t: TestContext) { treeOid: git(sourceRoot, ['rev-parse', 'HEAD^{tree}']), revision: 1, }; - const repositoryPath = gitoxideManagedRepositoryPathInternal(storageRoot, baseHead); + const repositoryPath = gitoxideManagedRepositoryPathInternal(storageRoot, sourceHead); await mkdir(dirname(repositoryPath), { recursive: true }); const imported = await importAdmittedGitoxideRepositoryInternal({ ...helper, @@ -241,8 +241,13 @@ async function candidateFixture(t: TestContext) { destinationRepositoryPath: repositoryPath, baselineRef: 'refs/maka/accepted', }); - assert.equal(imported.baselineCommitOid, baseHead.commitOid); - assert.equal(imported.baselineTreeOid, baseHead.treeOid); + 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, repositoryPath, baseHead }; } From 4ebfac7ea12788bde4bedb56fbb7ac62e26b7b74 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 02:32:48 +0800 Subject: [PATCH 53/86] fix(runtime-host): release candidate ref before CAS --- native/gitoxide-helper/src/main.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 16f11f9d04..73bd053c8a 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -525,6 +525,9 @@ fn prepare_candidate( .find_reference(candidate_ref.as_str()) .map_err(|_| "candidate_publish_failed")?; } + // On Windows the returned reference can retain a handle to the loose + // ref path. Release it before create_successor performs the CAS update. + drop(publication); } create_successor( repository_path, From 94a021b77382061b84db2c2d6accac6f2fd5a1f4 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 02:35:44 +0800 Subject: [PATCH 54/86] fix(runtime-host): close candidate observation before CAS --- native/gitoxide-helper/src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 73bd053c8a..8c7364a316 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -529,6 +529,10 @@ fn prepare_candidate( // ref path. Release it before create_successor performs the CAS update. drop(publication); } + // create_successor deliberately reopens the repository and repeats the + // expected-base CAS. Release this observation handle first so Windows does + // not retain a loose-ref handle across the second writer. + drop(repository); create_successor( repository_path, expected_base_commit_oid, From f88ea9e9aa4a01f462768a1269e47e476fff6b7a Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:13:38 +0800 Subject: [PATCH 55/86] fix(gitoxide): publish candidates in one ref transaction --- native/gitoxide-helper/src/main.rs | 59 ++++++++++++++---------------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 8c7364a316..2940a6a4f1 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -205,6 +205,7 @@ fn run() -> Result { target_ref, path, content, + false, ) } Request::PrepareCandidate { @@ -509,29 +510,9 @@ fn prepare_candidate( }); return Ok(ExitCode::from(3)); } - let candidate_exists = repository - .try_find_reference(candidate_ref.as_str()) - .map_err(|_| "candidate_ref_unavailable")? - .is_some(); - if !candidate_exists { - let publication = repository.reference( - candidate_ref.as_str(), - expected_base, - gix::refs::transaction::PreviousValue::MustNotExist, - "maka managed workspace candidate base", - ); - if publication.is_err() { - repository - .find_reference(candidate_ref.as_str()) - .map_err(|_| "candidate_publish_failed")?; - } - // On Windows the returned reference can retain a handle to the loose - // ref path. Release it before create_successor performs the CAS update. - drop(publication); - } - // create_successor deliberately reopens the repository and repeats the - // expected-base CAS. Release this observation handle first so Windows does - // not retain a loose-ref handle across the second writer. + // 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, @@ -539,6 +520,7 @@ fn prepare_candidate( candidate_ref, path, content, + true, ) } @@ -578,6 +560,7 @@ fn create_successor( target_ref: String, path: String, content: String, + create_target_if_missing: bool, ) -> Result { use gix::bstr::ByteSlice; @@ -649,23 +632,37 @@ fn create_successor( .detach(); let current = repository - .find_reference(target_ref.as_str()) - .map_err(|_| "target_ref_unavailable")? - .into_fully_peeled_id() + .try_find_reference(target_ref.as_str()) .map_err(|_| "target_ref_unavailable")? - .detach(); - if current != expected_base && current != successor_commit { + .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(|_| "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.to_string(), + actual_base_commit_oid: current.expect("checked above").to_string(), target_ref, }); return Ok(ExitCode::from(3)); - } - if current == expected_base { + } else if current == Some(expected_base) { repository .reference( target_ref.as_str(), From 3cc43749e1282075ee3c5e4fa6825010402d45a2 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:17:32 +0800 Subject: [PATCH 56/86] diagnostics(gitoxide): report ref transaction failures --- native/gitoxide-helper/src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 2940a6a4f1..c50ddfd05b 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -649,7 +649,10 @@ fn create_successor( gix::refs::transaction::PreviousValue::MustNotExist, "maka managed workspace candidate", ) - .map_err(|_| "candidate_publish_failed")?; + .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) { From 4cc1560f07c08da43aed99e71da3124a49152ae5 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:20:30 +0800 Subject: [PATCH 57/86] diagnostics(runtime-host): surface helper ref errors --- .../src/server/gitoxide-helper-invocation-internal.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts index dae20344c9..4f95444951 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -645,9 +645,10 @@ function decodeSuccessorOutcome(outcome: HelperProcessOutcome): GitoxideSuccesso 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}`, + `Gitoxide helper could not publish the successor: ${value.reason}${stderr ? `: ${stderr}` : ''}`, value.reason, ); } From 0776977c45cd60c1d5510fc19ba8d7d7eca3cfcf Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:23:41 +0800 Subject: [PATCH 58/86] diagnostics(gitoxide): include ref failure chain --- native/gitoxide-helper/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index c50ddfd05b..1af6a68363 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -650,7 +650,7 @@ fn create_successor( "maka managed workspace candidate", ) .map_err(|error| { - eprintln!("Gitoxide candidate publication failed: {error}"); + eprintln!("Gitoxide candidate publication failed: {error:?}"); "candidate_publish_failed" })?; } else if current.is_none() { From 63a064f59727e4bbdf0201dfbcba6d643e18dc33 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:27:23 +0800 Subject: [PATCH 59/86] fix(gitoxide): create the candidate ref namespace --- native/gitoxide-helper/src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 1af6a68363..fe31566140 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -574,6 +574,10 @@ fn create_successor( 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"); From 8881e8b955ac39aed51c18b1ab97884fd9582e15 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:30:58 +0800 Subject: [PATCH 60/86] fix(runtime-host): bound Gitoxide artifact path lengths --- .../gitoxide-helper-mutation-candidate-authority-internal.ts | 3 ++- .../gitoxide-repository-admission-authority-internal.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) 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 index 02a3594f66..485e7c6871 100644 --- 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 @@ -257,7 +257,8 @@ function identityDigest( ): string { return createHash('sha256') .update(`${head.workspaceId}\0${head.workspaceEpochId}`, 'utf8') - .digest('hex'); + .digest('hex') + .slice(0, 32); } function assertCaptureInput(input: GitoxideMutationCandidateCaptureInput): void { 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 index 4878dee20b..bc1a1cdc96 100644 --- a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -316,7 +316,10 @@ export async function prepareGitoxideMutationCandidateInternal(input: { input.managedRepositoryOwnerToken, input.managedRepositoryCapability, ); - const candidateRef = `refs/maka/candidates/${createHash('sha256').update(input.operationId).digest('hex')}`; + 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, From 71458f2a3c41d07e852973e3a228bcbca0de1b62 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 10:42:27 +0800 Subject: [PATCH 61/86] fix(runtime-host): bind candidate proofs to receipts --- ...ation-candidate-authority-internal.test.ts | 27 +++++++++++++++++++ ...r-mutation-candidate-authority-internal.ts | 20 +++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) 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 index efbc392025..cce2829617 100644 --- 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 @@ -84,6 +84,33 @@ test('persists and revalidates an exact Gitoxide candidate without advancing acc 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, + storageRoot: fixture.storageRoot, + 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('converges when execution stops after candidate ref publication and rejects receipt tampering', async (t) => { const fixture = await candidateFixture(t); if (!fixture) return; 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 index 485e7c6871..dd7229311a 100644 --- 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 @@ -134,6 +134,10 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { const managedRepositoryOwnerToken = {}; const candidateOwnerToken = {}; + const issuedProofs = new WeakMap< + GitoxideMutationCandidateProofV1, + GitoxideMutationCandidateReceiptV1 + >(); const managedRepositoryCapability = await reopenGitoxideManagedRepositoryInternal({ invocationOwnerToken: input.invocationOwnerToken, helperCapability: input.helperCapability, @@ -196,16 +200,26 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { await writeReceiptAtomic(receiptPath, expected); await input.failpoint?.('after_candidate_receipt'); } - return Object.freeze({ - receipt: durable ?? expected, + const receipt = durable ?? expected; + const proof = Object.freeze({ + receipt, candidateCapability: candidate.candidateCapability, }); + issuedProofs.set(proof, receipt); + return proof; }); }; return Object.freeze({ 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, @@ -222,7 +236,7 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { 'Gitoxide candidate capability does not match its durable receipt', ); } - return proof.receipt; + return issuedReceipt; }, }); } From aec531f1935af7ca7068cd1b898e3cb644e658e0 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:18:04 +0800 Subject: [PATCH 62/86] fix(runtime-host): require candidate storage ownership --- ...oxide-mutation-candidate-owner-v1.zh-CN.md | 21 ++- .../gitoxide-candidate-crash-child.ts | 9 +- ...ation-candidate-authority-internal.test.ts | 47 ++++- ...r-mutation-candidate-authority-internal.ts | 161 +++++++++++------- 4 files changed, 161 insertions(+), 77 deletions(-) diff --git a/docs/architecture/gitoxide-mutation-candidate-owner-v1.zh-CN.md b/docs/architecture/gitoxide-mutation-candidate-owner-v1.zh-CN.md index dcea0b4fff..1aee5b8954 100644 --- a/docs/architecture/gitoxide-mutation-candidate-owner-v1.zh-CN.md +++ b/docs/architecture/gitoxide-mutation-candidate-owner-v1.zh-CN.md @@ -27,6 +27,8 @@ > 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 @@ -71,7 +73,22 @@ receipt 不是第二事实源。新进程不会仅凭 JSON 恢复 capability, | receipt 被篡改 | ref/object 与 receipt 不一致 | fail closed | | accepted ref 已推进 | 旧 base 不再匹配 | 不创建/接受 candidate | -## 4. 平台能力矩阵 +## 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. 平台能力矩阵 | 平台 | 当前承诺 | |---|---| @@ -82,7 +99,7 @@ receipt 不是第二事实源。新进程不会仅凭 JSON 恢复 capability, 三平台由同一 Gitoxide helper workflow 执行 Rust contract、Runtime Host contract 和真实子进程 crash 用例。CI 通过只证明已列出的状态,不替代未实现的 discard/GC 与 M2.4 组合证明。 -## 5. 不属于本切片 +## 6. 不属于本切片 - 不提交 T2、workspace successor 或 canonical head; - 不推进 `refs/maka/accepted`; 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 index 21061c9430..4f6715b634 100644 --- a/packages/runtime-host/src/__tests__/fixtures/gitoxide-candidate-crash-child.ts +++ b/packages/runtime-host/src/__tests__/fixtures/gitoxide-candidate-crash-child.ts @@ -20,6 +20,10 @@ 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, @@ -57,10 +61,13 @@ const helperCapability = await admitGitoxideHelperArtifactInternal({ 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, - storageRoot: input.storageRoot, + storageRootLease: rootOwner.lease, baseHead: input.baseHead, async failpoint(point) { if (point !== 'after_candidate_ref') return; 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 index cce2829617..759efefe4e 100644 --- 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 @@ -26,6 +26,11 @@ 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, @@ -49,12 +54,27 @@ interface AdmittedHelper { 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, - storageRoot: fixture.storageRoot, + storageRootLease: fixture.rootOwner.lease, baseHead: fixture.baseHead, }); const input = { @@ -76,7 +96,7 @@ test('persists and revalidates an exact Gitoxide candidate without advancing acc const reopened = await createGitoxideMutationCandidateAuthorityInternal({ ...fixture.helper, - storageRoot: fixture.storageRoot, + storageRootLease: fixture.rootOwner.lease, baseHead: fixture.baseHead, }); const retry = await reopened.capture(input); @@ -89,7 +109,7 @@ test('rejects a candidate proof whose receipt was recomposed around a valid capa if (!fixture) return; const authority = await createGitoxideMutationCandidateAuthorityInternal({ ...fixture.helper, - storageRoot: fixture.storageRoot, + storageRootLease: fixture.rootOwner.lease, baseHead: fixture.baseHead, }); const proof = await authority.capture({ @@ -117,7 +137,7 @@ test('converges when execution stops after candidate ref publication and rejects let stopped = false; const interrupted = await createGitoxideMutationCandidateAuthorityInternal({ ...fixture.helper, - storageRoot: fixture.storageRoot, + storageRootLease: fixture.rootOwner.lease, baseHead: fixture.baseHead, failpoint(point) { if (point === 'after_candidate_ref' && !stopped) { @@ -136,7 +156,7 @@ test('converges when execution stops after candidate ref publication and rejects const reopened = await createGitoxideMutationCandidateAuthorityInternal({ ...fixture.helper, - storageRoot: fixture.storageRoot, + storageRootLease: fixture.rootOwner.lease, baseHead: fixture.baseHead, }); const recovered = await reopened.capture(input); @@ -184,6 +204,7 @@ test('reopens and completes a candidate after the owner process is killed post-r readyPath, }), ); + await fixture.rootOwner.close(); const child = spawn( process.execPath, [ @@ -201,9 +222,12 @@ test('reopens and completes a candidate after the owner process is killed post-r 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, - storageRoot: fixture.storageRoot, + storageRootLease: reopenedRootOwner.lease, baseHead: fixture.baseHead, }); const recovered = await reopened.capture(input); @@ -225,7 +249,14 @@ async function candidateFixture(t: TestContext) { 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 storageRoot = join(root, 'storage'); + 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']); @@ -275,7 +306,7 @@ async function candidateFixture(t: TestContext) { commitOid: imported.baselineCommitOid, treeOid: imported.baselineTreeOid, }; - return { helper, storageRoot, repositoryPath, baseHead }; + return { helper, storageRoot, rootCapability, rootOwner, repositoryPath, baseHead }; } async function admittedHelper(): Promise { 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 index dd7229311a..f2b73d41f4 100644 --- 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 @@ -23,6 +23,11 @@ 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 { @@ -115,22 +120,30 @@ export class GitoxideMutationCandidateAuthorityError extends Error { } export async function createGitoxideMutationCandidateAuthorityInternal(input: { - readonly storageRoot: string; + readonly storageRootLease: StorageRootLease<'interactive', 'write'>; readonly baseHead: WorkspaceHeadRecordV1; readonly invocationOwnerToken: object; readonly helperCapability: GitoxideHelperInvocationCapability; readonly failpoint?: (point: GitoxideMutationCandidateFailpoint) => void | Promise; }): Promise { - const storageRoot = await realpath(input.storageRoot); - const repositoryPath = await realpath( - gitoxideManagedRepositoryPathInternal(storageRoot, input.baseHead), + 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 }; + }, ); - 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); const managedRepositoryOwnerToken = {}; const candidateOwnerToken = {}; @@ -142,7 +155,7 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { invocationOwnerToken: input.invocationOwnerToken, helperCapability: input.helperCapability, managedRepositoryOwnerToken, - repositoryPath, + repositoryPath: rootContext.repositoryPath, acceptedRef: ACCEPTED_REF, expectedAcceptedCommitOid: input.baseHead.commitOid, expectedAcceptedTreeOid: input.baseHead.treeOid, @@ -152,62 +165,78 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { request: GitoxideMutationCandidateCaptureInput, ): Promise => { assertCaptureInput(request); - const operationIdentitySha256 = sha256(request.operationId); - const receiptPath = join(canonicalReceiptRoot, `${operationIdentitySha256.slice(7)}.json`); - return withProcessLifetimeFileUpdateLock(receiptPath, async () => { - request.abortSignal?.throwIfAborted(); - const durable = await readReceipt(receiptPath); - 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', + 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`, ); - } - 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; - }); + return withProcessLifetimeFileUpdateLock(receiptPath, async () => { + request.abortSignal?.throwIfAborted(); + const durable = await readReceipt(receiptPath); + 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; + }); + }, + ); }; return Object.freeze({ From 051e4ea2e6240906d5ec37736ca8bb4f75099d96 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:48:20 +0800 Subject: [PATCH 63/86] style(gitoxide): match pinned Rust formatting --- native/gitoxide-helper/src/main.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index fe31566140..baa087f0be 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -803,11 +803,7 @@ struct ManagedTreeStats { } impl ManagedTreeStats { - fn enter_tree( - &mut self, - depth: u64, - policy: ManagedTreePolicy, - ) -> Result<(), &'static str> { + fn enter_tree(&mut self, depth: u64, policy: ManagedTreePolicy) -> Result<(), &'static str> { if depth > policy.max_depth { return Err("source_tree_depth_exceeded"); } @@ -845,11 +841,7 @@ impl ManagedTreeStats { Ok(()) } - fn observe_blob( - &mut self, - size: u64, - policy: ManagedTreePolicy, - ) -> Result<(), &'static str> { + fn observe_blob(&mut self, size: u64, policy: ManagedTreePolicy) -> Result<(), &'static str> { if size > policy.max_file_bytes { return Err("source_file_limit_exceeded"); } From 8313bab48717cd477eb7ff3e97d7eb98d0f8057c Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:55:08 +0800 Subject: [PATCH 64/86] fix(gitoxide): make source import restartable --- native/gitoxide-helper/src/main.rs | 46 +++++++++++++------ .../tests/repository_admission.rs | 43 +++++++++++++++++ 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index baa087f0be..797c77e2ab 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -332,13 +332,17 @@ fn import_source_head( .map_err(|_| "source_head_tree_unavailable")? .detach(); - match fs::symlink_metadata(&destination_repository_path) { + 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 => {} + 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"), - } - let destination = gix::init_bare(&destination_repository_path) - .map_err(|_| "import_destination_create_failed")?; + }; if destination.object_hash() != gix::hash::Kind::Sha1 { return Err("import_destination_object_format_mismatch"); } @@ -375,14 +379,30 @@ fn import_source_head( .map_err(|_| "baseline_commit_write_failed")? .id() .detach(); - destination - .reference( - baseline_ref.as_str(), - baseline_commit, - gix::refs::transaction::PreviousValue::MustNotExist, - "maka managed workspace baseline", - ) - .map_err(|_| "baseline_publish_failed")?; + 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, diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index c4f5698056..f1445d0bfd 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -166,6 +166,49 @@ fn imports_an_exact_source_head_into_a_fresh_managed_repository() { ["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] From f80f9666debf8c862ae694f507b6fc20a2fd7646 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 02:31:17 +0800 Subject: [PATCH 65/86] feat(runtime-host): accept Gitoxide mutation candidates --- ...gitoxide-write-edit-acceptance-v1.zh-CN.md | 51 +++++++++ ...ation-candidate-authority-internal.test.ts | 23 ++++ ...r-mutation-candidate-authority-internal.ts | 61 ++++++++++ packages/runtime/package.json | 1 + .../managed-mutation-transform.test.ts | 59 ++++++++++ .../runtime/src/managed-mutation-transform.ts | 104 ++++++++++++++++++ 6 files changed, 299 insertions(+) create mode 100644 docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md create mode 100644 packages/runtime/src/__tests__/managed-mutation-transform.test.ts create mode 100644 packages/runtime/src/managed-mutation-transform.ts 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..0fe40a0232 --- /dev/null +++ b/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md @@ -0,0 +1,51 @@ +# Gitoxide Write/Edit 接受链 v1 + +## 状态 + +API-only stacked Draft。此切片先证明 Git 数据面的两个必要边界,不代表 Desktop/CLI 已开放 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`。 + +## 原子性与恢复 + +`promote_candidate` 的线性化点是 accepted ref 的 compare-and-swap: + +- `accepted == base`:推进到 exact candidate; +- `accepted == candidate`:视为精确重试并成功收敛; +- 其他状态:fail closed,不覆盖、不 fallback。 + +因此 SQLite 提交后、ref 推进前崩溃时,只重放 ref projection,不重新执行 Write/Edit。 + +## 失败状态与回滚 + +- candidate 创建失败:不产生 accepted successor;保留或清理由 candidate 生命周期 owner 处理。 +- SQLite successor 未提交:禁止推进 accepted ref。 +- accepted ref CAS 冲突:park;SQLite accepted truth 保留,等待显式 reconciliation。 +- projection 失败:不得回滚 SQLite 事实,也不得重跑工具。 + +## 平台能力矩阵 + +| 平台 | 当前承诺 | +| --- | --- | +| Linux | Gitoxide helper 的 candidate CAS 与精确重试;CI 必须运行真实 helper。 | +| macOS | 与 Linux 相同;不在本切片声明断电持久性。 | +| Windows | 与 Linux 相同;不依赖 POSIX rename 或系统 Git。 | + +## 后续闭环 + +本切片之后仍需把 Runtime-owned outcome、SQLite successor writer 和 ref projection 串成一个生产 session owner,并补“SQLite 已提交、projection 前杀 Host、重启后只推进 ref”的真实进程测试。完成前保持 Draft,也不进入 M3 的自动恢复策略。 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 index 759efefe4e..c679a8c70d 100644 --- 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 @@ -131,6 +131,29 @@ test('rejects a candidate proof whose receipt was recomposed around a valid capa ); }); +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('converges when execution stops after candidate ref publication and rejects receipt tampering', async (t) => { const fixture = await candidateFixture(t); if (!fixture) return; 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 index f2b73d41f4..bebfd3e68e 100644 --- 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 @@ -33,9 +33,14 @@ import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artif import { type GitoxideMutationCandidateCapability, 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; @@ -99,8 +104,16 @@ export interface GitoxideMutationCandidateProofV1 { } export interface GitoxideMutationCandidateAuthorityInternal { + readBaseFile( + path: string, + abortSignal?: AbortSignal, + ): Promise<{ readonly content: string; readonly blobOid: string } | null>; capture(input: GitoxideMutationCandidateCaptureInput): Promise; validate(proof: GitoxideMutationCandidateProofV1): GitoxideMutationCandidateReceiptV1; + promote( + proof: GitoxideMutationCandidateProofV1, + abortSignal?: AbortSignal, + ): Promise; } export type GitoxideMutationCandidateFailpoint = 'after_candidate_ref' | 'after_candidate_receipt'; @@ -240,6 +253,28 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { }; return Object.freeze({ + async readBaseFile(path: string, abortSignal?: AbortSignal) { + try { + 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; + } + }, capture, validate(proof: GitoxideMutationCandidateProofV1) { const issuedReceipt = issuedProofs.get(proof); @@ -267,6 +302,32 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { } return issuedReceipt; }, + async promote(proof: GitoxideMutationCandidateProofV1, abortSignal?: AbortSignal) { + const receipt = this.validate(proof); + const result = await promoteCandidateWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + 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; + }, }); } diff --git a/packages/runtime/package.json b/packages/runtime/package.json index e333e5c45f..84a7bcc34d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -80,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__/managed-mutation-transform.test.ts b/packages/runtime/src/__tests__/managed-mutation-transform.test.ts new file mode 100644 index 0000000000..b8de078b31 --- /dev/null +++ b/packages/runtime/src/__tests__/managed-mutation-transform.test.ts @@ -0,0 +1,59 @@ +/* + * 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, + ); +}); diff --git a/packages/runtime/src/managed-mutation-transform.ts b/packages/runtime/src/managed-mutation-transform.ts new file mode 100644 index 0000000000..6a5dcd89a5 --- /dev/null +++ b/packages/runtime/src/managed-mutation-transform.ts @@ -0,0 +1,104 @@ +/* + * 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; +} + +/** + * 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; +} From ce590aba34283ffa2a6466cb95b0bdb7f8d864c0 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 02:40:31 +0800 Subject: [PATCH 66/86] feat(runtime): execute managed mutations as Git transforms --- .../tool-runtime-durable-boundary.test.ts | 58 +++++++++++++++ .../runtime/src/managed-mutation-transform.ts | 4 + packages/runtime/src/tool-runtime.ts | 73 ++++++++++++++++++- 3 files changed, 132 insertions(+), 3 deletions(-) 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..b3c2bc5a88 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -261,6 +261,64 @@ 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:992cc9a7a2f7cd32b1062241146727aac11ae111ab81d480c57c5d68ad8f35cc', + }, + }; + }, + }, + ); + 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, + }); + }); + it('does not replace a committed managed result when admission cleanup fails', async () => { let operationId = ''; const harness = makeHarness( diff --git a/packages/runtime/src/managed-mutation-transform.ts b/packages/runtime/src/managed-mutation-transform.ts index 6a5dcd89a5..a055f631bd 100644 --- a/packages/runtime/src/managed-mutation-transform.ts +++ b/packages/runtime/src/managed-mutation-transform.ts @@ -26,6 +26,10 @@ export interface ManagedMutationTransformResult { readonly changed: boolean; } +/** Frozen semantic identity of the Runtime-owned immutable Git transform. */ +export const GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST = + 'sha256:992cc9a7a2f7cd32b1062241146727aac11ae111ab81d480c57c5d68ad8f35cc' 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. diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 0b8fd99475..9d49de1084 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -94,6 +94,10 @@ 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, + transformManagedMutation, +} from './managed-mutation-transform.js'; import { RuntimeInteractionAdmissionRejectedError, RuntimeInteractionClosedError, @@ -160,7 +164,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 +483,11 @@ interface RuntimeManagedMutationOperationValue { readonly isError: boolean; readonly durationMs: number; }; + readonly managedMutationResult?: { + readonly canonicalPath: string; + readonly content: string; + readonly changed: boolean; + }; } /** @@ -490,6 +499,11 @@ export interface RuntimeManagedMutationOperationProof { readonly content: ToolResultContent; readonly isError: boolean; readonly durationMs: number; + readonly managedMutationResult?: { + readonly canonicalPath: string; + readonly content: string; + readonly changed: boolean; + }; } export type RuntimeManagedMutationSettlement = @@ -507,6 +521,10 @@ export type RuntimeManagedMutationSettlement = export interface RuntimeManagedMutationAdmission { readonly durableDispatch: Readonly; + readonly gitoxideTransform?: { + readonly canonicalPath: string; + readonly baseContent: string | null; + }; execute( operation: () => Promise, ): Promise; @@ -1342,7 +1360,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 +1383,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 +1546,37 @@ 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; + 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(); + } const result = immutableSnapshot ? snapshotManagedToolResult(rawResult, ctx.maxResultBytes) : rawResult; @@ -1535,6 +1598,7 @@ export class ToolRuntime { const value = { result, outcome: immutableSnapshot ? Object.freeze(outcome) : outcome, + ...(managedMutationResult ? { managedMutationResult } : {}), }; return immutableSnapshot ? Object.freeze(value) : value; }; @@ -1571,6 +1635,9 @@ export class ToolRuntime { content: value.outcome.content, isError: value.outcome.isError, durationMs: value.outcome.durationMs, + ...(value.managedMutationResult + ? { managedMutationResult: value.managedMutationResult } + : {}), }; } finally { if (operationLifecycle.state === 'running') { From 7941af24db839d1b35c562f616f3a626ff145c8c Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 02:50:48 +0800 Subject: [PATCH 67/86] feat(runtime): hand managed outcome proof to settlement owner --- .../__tests__/tool-runtime-durable-boundary.test.ts | 2 ++ packages/runtime/src/tool-runtime.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+) 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 b3c2bc5a88..a326d0d13d 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -317,6 +317,8 @@ describe('ToolRuntime durable boundary', () => { content: 'after\n', changed: true, }); + assert.equal(observedProof?.durableOutcome.content?.kind, 'function_response'); + assert.equal(observedProof?.durableOutcome.refs?.operationId, operationId); }); it('does not replace a committed managed result when admission cleanup fails', async () => { diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 9d49de1084..2055602556 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -499,6 +499,8 @@ 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; @@ -535,6 +537,7 @@ export interface RuntimeManagedMutationAdmission { interface DurableToolAttempt { operationId: string; responseEventId: string; + buildOutcome(result: ToolResultContent, isError: boolean, durationMs: number): RuntimeEvent; commitOutcome( result: unknown, isError: boolean, @@ -1635,6 +1638,11 @@ export class ToolRuntime { 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 } : {}), @@ -2154,6 +2162,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()); From 011ec01295e481763e92af43d4a1be0608d4b2c0 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 02:50:48 +0800 Subject: [PATCH 68/86] feat(storage): bind workspace settlement to execution stores --- packages/storage/package.json | 2 + ...tores-workspace-authority-internal.test.ts | 57 +++++++++++++++++ ...ion-stores-workspace-authority-internal.ts | 64 +++++++++++++++++++ packages/storage/src/execution-stores.ts | 5 ++ 4 files changed, 128 insertions(+) create mode 100644 packages/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts create mode 100644 packages/storage/src/execution-stores-workspace-authority-internal.ts diff --git a/packages/storage/package.json b/packages/storage/package.json index 1d2d11802d..ef8829205f 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -42,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..1507ef082a --- /dev/null +++ b/packages/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts @@ -0,0 +1,57 @@ +/* + * 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); + } finally { + await stores.sessionStore.close?.(); + await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); 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..40cd791d51 --- /dev/null +++ b/packages/storage/src/execution-stores-workspace-authority-internal.ts @@ -0,0 +1,64 @@ +/* + * 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 { RuntimeWorkspaceVersionAuthorityStore } from '@maka/core/runtime-event-store'; +import type { + WorkspaceHeadRecordV1, + WorkspaceVersionRecordV1, +} from '@maka/core/workspace-version-authority'; +import { + commitWorkspaceSuccessorInternal, + type WorkspaceSuccessorCommitInput, + type WorkspaceSuccessorCommitResult, +} from './workspace-version-authority-internal.js'; + +export interface ExecutionStoresWorkspaceMutationAuthorityInternal { + readHead( + workspaceId: string, + workspaceEpochId: string, + ): Promise; + readVersion(workspaceVersionId: string): Promise; + commitSuccessor(input: WorkspaceSuccessorCommitInput): Promise; +} + +const workspaceAuthorities = new WeakMap(); + +export function registerExecutionStoresWorkspaceMutationAuthorityInternal( + stores: object, + authority: RuntimeWorkspaceVersionAuthorityStore, +): void { + if (workspaceAuthorities.has(stores)) { + throw new Error('Execution stores workspace mutation authority is already registered'); + } + workspaceAuthorities.set(stores, authority); +} + +export function requireExecutionStoresWorkspaceMutationAuthorityInternal( + stores: object, +): ExecutionStoresWorkspaceMutationAuthorityInternal { + const authority = workspaceAuthorities.get(stores); + if (!authority) throw new Error('Execution stores workspace mutation authority is unavailable'); + return Object.freeze({ + readHead: (workspaceId: string, workspaceEpochId: string) => + authority.readWorkspaceHead(workspaceId, workspaceEpochId), + readVersion: (workspaceVersionId: string) => authority.readWorkspaceVersion(workspaceVersionId), + commitSuccessor: (input: WorkspaceSuccessorCommitInput) => + commitWorkspaceSuccessorInternal(authority, input), + }); +} diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index ea8e49bcda..ac237cc24a 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,10 @@ async function createExecutionStoresForWrite Date: Mon, 24 Aug 2026 02:50:49 +0800 Subject: [PATCH 69/86] feat(runtime-host): settle Gitoxide managed mutations --- ...ation-candidate-authority-internal.test.ts | 30 ++ ...itoxide-managed-mutation-admission.test.ts | 265 +++++++++++++ ...r-mutation-candidate-authority-internal.ts | 91 ++++- .../gitoxide-managed-mutation-admission.ts | 357 ++++++++++++++++++ 4 files changed, 723 insertions(+), 20 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/gitoxide-managed-mutation-admission.test.ts create mode 100644 packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts 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 index c679a8c70d..d84777373a 100644 --- 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 @@ -154,6 +154,36 @@ test('promotes an exact candidate only after the caller presents its owner-bound 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, + storageRoot: fixture.storageRoot, + 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, + storageRoot: fixture.storageRoot, + 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, + ); +}); + test('converges when execution stops after candidate ref publication and rejects receipt tampering', async (t) => { const fixture = await candidateFixture(t); if (!fixture) return; 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..d68b092695 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-admission.test.ts @@ -0,0 +1,265 @@ +/* + * 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 type { + WorkspaceHeadRecordV1, + WorkspaceVersionRecordV1, +} from '@maka/core/workspace-version-authority'; +import { + createGitoxideManagedMutationAdmissionInternal, + reconcileGitoxideManagedMutationProjectionInternal, + type GitoxideManagedMutationSettlementAuthorityInternal, +} from '../server/gitoxide-managed-mutation-admission.js'; + +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 }, + }; + }, + }; + 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:992cc9a7a2f7cd32b1062241146727aac11ae111ab81d480c57c5d68ad8f35cc', + }, + }), + 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('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:992cc9a7a2f7cd32b1062241146727aac11ae111ab81d480c57c5d68ad8f35cc', + 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'); + }, + }, + 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(): RuntimeEvent { + return { + id: 'op-1_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' }, + }, + }, + refs: { operationId: 'op-1', 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/server/gitoxide-helper-mutation-candidate-authority-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-mutation-candidate-authority-internal.ts index bebfd3e68e..cd5cc8c67e 100644 --- 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 @@ -114,6 +114,10 @@ export interface GitoxideMutationCandidateAuthorityInternal { proof: GitoxideMutationCandidateProofV1, abortSignal?: AbortSignal, ): Promise; + promoteDurable( + operationId: string, + abortSignal?: AbortSignal, + ): Promise; } export type GitoxideMutationCandidateFailpoint = 'after_candidate_ref' | 'after_candidate_receipt'; @@ -252,6 +256,35 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { ); }; + const promoteReceipt = async ( + receipt: GitoxideMutationCandidateReceiptV1, + abortSignal?: AbortSignal, + ): Promise => { + const result = await promoteCandidateWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + 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 { @@ -304,33 +337,51 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { }, async promote(proof: GitoxideMutationCandidateProofV1, abortSignal?: AbortSignal) { const receipt = this.validate(proof); - const result = await promoteCandidateWithGitoxideHelperInternal({ - invocationOwnerToken: input.invocationOwnerToken, - capability: input.helperCapability, - 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 - ) { + return promoteReceipt(receipt, abortSignal); + }, + async promoteDurable(operationId: string, abortSignal?: AbortSignal) { + if (operationId.length === 0 || operationId.length > 1024) { throw new GitoxideMutationCandidateAuthorityError( - 'gitoxide_mutation_candidate_identity_conflict', - 'Gitoxide accepted ref no longer matches the candidate promotion proof', + 'gitoxide_mutation_candidate_request_invalid', + 'Gitoxide durable candidate operation identity is invalid', ); } - return receipt; + const operationIdentitySha256 = sha256(operationId); + const receiptPath = join(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, 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..63af26dfb7 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts @@ -0,0 +1,357 @@ +/* + * 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 { + WorkspaceHeadRecordV1, + WorkspaceSuccessorAuthorityInput, + WorkspaceVersionRecordV1, +} from '@maka/core/workspace-version-authority'; +import { GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST } from '@maka/runtime/managed-mutation-transform'; +import type { RuntimeManagedMutationAdmission, ToolRuntimeInput } from '@maka/runtime/tool-runtime'; +import type { + WorkspaceSuccessorCommitInput, + WorkspaceSuccessorCommitResult, +} from '@maka/storage/workspace-version-authority-internal'; + +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; +} + +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; +} + +export function createGitoxideManagedMutationAdmissionInternal(input: { + readonly workspaceInstanceId: string; + readonly workspaceId: string; + readonly workspaceEpochId: string; + readonly settlementAuthority: GitoxideManagedMutationSettlementAuthorityInternal; + readonly candidateAuthorityForHead: ( + head: WorkspaceHeadRecordV1, + ) => 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(); + const mutation = proof.managedMutationResult; + if (proof.isError || !mutation || !mutation.changed || mutation.canonicalPath !== path) { + return Object.freeze({ + kind: 'unsettled' as const, + error: new Error('Gitoxide managed mutation has no changed success candidate'), + }); + } + const candidate = await candidateAuthority.capture({ + operationId: request.operationId, + path, + content: mutation.content, + executionProfileDigest: GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST, + abortSignal: request.abortSignal, + }); + 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: { + operationId: request.operationId, + journalEventId: `${request.operationId}_outcome`, + runtimeEvent: proof.durableOutcome, + committedAt: proof.durableOutcome.ts, + }, + }); + await candidateAuthority.promote(candidate, request.abortSignal); + return Object.freeze({ + kind: 'workspace_successor_committed' as const, + durableOutcome: proof.durableOutcome, + }); + }, + async dispose() {}, + }); + }; +} + +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')}`; +} From 59471895e11d242ee53a084868bc81d84f8f948a Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:11:17 +0800 Subject: [PATCH 70/86] feat(storage): settle managed no-effect outcomes atomically --- packages/core/src/runtime-event.ts | 46 ++- ...tores-workspace-authority-internal.test.ts | 33 ++ ...pace-version-authority-persistence.test.ts | 73 ++++- ...ion-stores-workspace-authority-internal.ts | 33 +- packages/storage/src/execution-stores.ts | 1 + .../storage/src/runtime-event-authority.ts | 3 + packages/storage/src/sqlite-runtime-store.ts | 286 +++++++++++++++++- .../workspace-version-authority-internal.ts | 44 +++ 8 files changed, 509 insertions(+), 10 deletions(-) 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/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts b/packages/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts index 1507ef082a..9ba7188d03 100644 --- a/packages/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts +++ b/packages/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts @@ -49,6 +49,39 @@ test('binds the private workspace mutation authority to authentic execution stor 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(); 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 index 40cd791d51..458f2de6e4 100644 --- a/packages/storage/src/execution-stores-workspace-authority-internal.ts +++ b/packages/storage/src/execution-stores-workspace-authority-internal.ts @@ -19,46 +19,71 @@ 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, + type ManagedMutationTerminalCommitInput, + type ManagedMutationTerminalCommitResult, type WorkspaceSuccessorCommitInput, type WorkspaceSuccessorCommitResult, } from './workspace-version-authority-internal.js'; export interface ExecutionStoresWorkspaceMutationAuthorityInternal { + adoptRootForManagedExecution(): void; readHead( workspaceId: string, workspaceEpochId: string, ): Promise; readVersion(workspaceVersionId: string): Promise; + commitBaseline(input: WorkspaceBaselineAuthorityInput): Promise; commitSuccessor(input: WorkspaceSuccessorCommitInput): Promise; + commitTerminal( + input: ManagedMutationTerminalCommitInput, + ): Promise; } -const workspaceAuthorities = new WeakMap(); +interface RegisteredWorkspaceAuthority { + readonly authority: RuntimeWorkspaceVersionAuthorityStore; + readonly rootId: string; +} + +const workspaceAuthorities = new WeakMap(); export function registerExecutionStoresWorkspaceMutationAuthorityInternal( stores: object, authority: RuntimeWorkspaceVersionAuthorityStore, + rootId: string, ): void { if (workspaceAuthorities.has(stores)) { throw new Error('Execution stores workspace mutation authority is already registered'); } - workspaceAuthorities.set(stores, authority); + workspaceAuthorities.set(stores, Object.freeze({ authority, rootId })); } export function requireExecutionStoresWorkspaceMutationAuthorityInternal( stores: object, ): ExecutionStoresWorkspaceMutationAuthorityInternal { - const authority = workspaceAuthorities.get(stores); - if (!authority) throw new Error('Execution stores workspace mutation authority is unavailable'); + 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), + 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 ac237cc24a..3c9e2a493e 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -562,6 +562,7 @@ 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, From 18eb9760aab9e29b800a56acdbbad3f8a3710855 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:11:22 +0800 Subject: [PATCH 71/86] feat(runtime): close managed mutation terminal states --- .../src/server/execution-model-composition.ts | 4 +- .../runtime-event-read-model.test.ts | 24 ++++ .../tool-runtime-durable-boundary.test.ts | 81 ++++------- packages/runtime/src/ai-sdk-backend.ts | 3 + packages/runtime/src/conversation-copy.ts | 24 +++- .../runtime/src/runtime-event-read-model.ts | 7 + packages/runtime/src/tool-runtime.ts | 126 +++++++++--------- 7 files changed, 148 insertions(+), 121 deletions(-) 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/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__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index a326d0d13d..e168c5b64b 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -634,22 +634,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'; @@ -684,21 +678,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'; @@ -733,15 +722,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, }; }); }, @@ -749,7 +733,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'; @@ -760,7 +744,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 () => { @@ -785,8 +769,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 }, @@ -840,8 +823,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' } }, @@ -887,21 +869,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'; @@ -935,28 +912,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'; 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/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/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/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 2055602556..902960c08c 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -514,9 +514,11 @@ 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 }; @@ -1557,28 +1559,39 @@ export class ToolRuntime { } | undefined; let rawResult: unknown; - 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'); + 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(); } - 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) @@ -1691,7 +1704,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( @@ -1706,9 +1719,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, }; } @@ -3208,17 +3226,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)) { @@ -3229,7 +3247,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') @@ -3244,36 +3266,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, }; } From 190fe51633fa8e169f1faeb53187d7874b8a9b17 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:11:31 +0800 Subject: [PATCH 72/86] feat(runtime-host): compose Gitoxide managed coding sessions --- ...gitoxide-write-edit-acceptance-v1.zh-CN.md | 19 +- packages/core/src/session.ts | 2 +- ...itoxide-managed-mutation-admission.test.ts | 94 +++- .../gitoxide-managed-mutation-session.test.ts | 109 +++++ .../hosted-execution-tool-profile.test.ts | 20 + .../src/server/execution-composition.ts | 75 ++- .../src/server/gitoxide-managed-inspection.ts | 4 +- .../gitoxide-managed-mutation-admission.ts | 48 +- .../gitoxide-managed-mutation-session.ts | 443 ++++++++++++++++++ .../server/hosted-execution-tool-profile.ts | 44 +- 10 files changed, 830 insertions(+), 28 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts create mode 100644 packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts diff --git a/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md b/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md index 0fe40a0232..a8d281cd61 100644 --- a/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md +++ b/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md @@ -2,7 +2,8 @@ ## 状态 -API-only stacked Draft。此切片先证明 Git 数据面的两个必要边界,不代表 Desktop/CLI 已开放 managed Write/Edit。 +stacked Draft。Runtime Host 已能消费持久化的 `managed-coding-v1` Session profile;Desktop/CLI +尚未提供创建该 profile 的产品入口,因此不能视为默认开放 managed Write/Edit。 ## 主要不变量 @@ -21,6 +22,16 @@ immutable accepted tree - 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`:纯转换在接触 Git candidate 前确定失败; +- `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: @@ -34,6 +45,7 @@ immutable accepted tree ## 失败状态与回滚 - candidate 创建失败:不产生 accepted successor;保留或清理由 candidate 生命周期 owner 处理。 +- no-op / 确定性转换失败:不创建 candidate;SQLite 原子提交 no-effect terminal 并释放 reservation。 - SQLite successor 未提交:禁止推进 accepted ref。 - accepted ref CAS 冲突:park;SQLite accepted truth 保留,等待显式 reconciliation。 - projection 失败:不得回滚 SQLite 事实,也不得重跑工具。 @@ -48,4 +60,7 @@ immutable accepted tree ## 后续闭环 -本切片之后仍需把 Runtime-owned outcome、SQLite successor writer 和 ref projection 串成一个生产 session owner,并补“SQLite 已提交、projection 前杀 Host、重启后只推进 ref”的真实进程测试。完成前保持 Draft,也不进入 M3 的自动恢复策略。 +Runtime-owned outcome、SQLite successor writer、baseline session owner 与 ref projection 已串入 +`managed-coding-v1` Host backend。转 Ready 前仍需由打包 helper 的三平台 lane 证明 Rust import +exact retry,并补“SQLite 已提交、projection 前杀 Host、重启后只推进 ref”的真实进程测试;完成前 +保持 Draft,也不进入 M3 的自动恢复策略。 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/runtime-host/src/__tests__/gitoxide-managed-mutation-admission.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-admission.test.ts index d68b092695..8d85292c27 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-admission.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-admission.test.ts @@ -48,6 +48,9 @@ test('commits the exact Runtime outcome before promoting the Gitoxide candidate' 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', @@ -113,6 +116,87 @@ test('commits the exact Runtime outcome before promoting the Gitoxide candidate' 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('replays only candidate promotion after SQLite already accepted the successor', async () => { const parent = baselineHead(); const parentVersion = baselineVersion(parent); @@ -162,6 +246,9 @@ test('replays only candidate promotion after SQLite already accepted the success 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); @@ -235,9 +322,9 @@ function baselineVersion(head: WorkspaceHeadRecordV1): WorkspaceVersionRecordV1 }; } -function outcomeEvent(): RuntimeEvent { +function outcomeEvent(operationId = 'op-1', isError = false): RuntimeEvent { return { - id: 'op-1_response', + id: `${operationId}_response`, sessionId: 'session-1', invocationId: 'run-1', runId: 'run-1', @@ -254,8 +341,9 @@ function outcomeEvent(): RuntimeEvent { kind: 'json', value: { kind: 'file_diff', paths: ['notes.txt'], diff: 'diff' }, }, + ...(isError ? { isError: true } : {}), }, - refs: { operationId: 'op-1', toolCallId: 'call-1' }, + refs: { operationId, toolCallId: 'call-1' }, actions: { stateDelta: { durationMs: 5 } }, }; } 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..68e207e0c8 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts @@ -0,0 +1,109 @@ +/* + * 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 } 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 { 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'; + +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 = { + storageRoot: storageCapability.canonicalPath, + 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}'])); + } finally { + await stores.sessionStore.close?.(); + await storageOwner.close(); + } +}); + +function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', args, { cwd, 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..531812e0e6 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,23 @@ 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, ['Write', 'Edit']); + assert.equal(profile.memoryExtraction, false); + assert.doesNotMatch(profile.systemPrompt, /Bash/u); + + const tools: MakaTool[] = ['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) { + assert.equal(tool.recoveryMode, 'reconcile'); + assert.equal(tool.durableExecutionProfile, 'managed_mutation_v1'); + } +}); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 518cc524da..f6b7fce3c8 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'; @@ -177,9 +178,13 @@ import { 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 } from './gitoxide-managed-mutation-session.js'; export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition { readonly workspaceExecution: RuntimeHostWorkspaceExecutionComposition; @@ -234,6 +239,12 @@ export async function createExecutionRuntimeHostComposition( 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; @@ -324,6 +335,26 @@ export async function createExecutionRuntimeHostComposition( `[runtime-host] Gitoxide managed inspection unavailable: ${generalizedErrorMessage(error)}`, ), }); + const packagedResourcesRoot = runtimeHostPackagedResourcesRootInternal(); + if (packagedResourcesRoot) { + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + try { + const helperCapability = await resolvePackagedGitoxideHelperInternal({ + resourcesRoot: packagedResourcesRoot, + releaseOwnerToken, + invocationOwnerToken, + }); + gitoxideManagedMutationRuntime = Object.freeze({ + invocationOwnerToken, + helperCapability, + }); + } catch (error) { + console.warn( + `[runtime-host] Gitoxide managed mutation unavailable: ${generalizedErrorMessage(error)}`, + ); + } + } workspaceExecution = createRuntimeHostWorkspaceExecutionComposition({ ...(managedFilesystemWorker ? { filesystemWorker: managedFilesystemWorker } : {}), }); @@ -617,8 +648,25 @@ export async function createExecutionRuntimeHostComposition( backends.register( 'ai-sdk', dependencies.primaryBackendFactory ?? - ((backendContext) => - createHostAiSdkBackend({ + (async (backendContext) => { + const managedMutationSession = + backendContext.header.toolProfile === 'managed-coding-v1' + ? await openGitoxideManagedMutationSession({ + storageRoot: context.owner.capability.canonicalPath, + sourceRoot: backendContext.header.cwd, + sessionId: backendContext.sessionId, + invocationOwnerToken: requireGitoxideManagedMutationRuntime( + gitoxideManagedMutationRuntime, + ).invocationOwnerToken, + helperCapability: requireGitoxideManagedMutationRuntime( + gitoxideManagedMutationRuntime, + ).helperCapability, + settlementAuthority: + requireExecutionStoresWorkspaceMutationAuthorityInternal(stores), + abortSignal: backendContext.abortSignal, + }) + : undefined; + return createHostAiSdkBackend({ context: backendContext, runtimePolicy: runtimePolicyStores, oauthCredentials, @@ -655,8 +703,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), @@ -1667,6 +1719,23 @@ function requireWorkspaceExecution( 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/gitoxide-managed-inspection.ts b/packages/runtime-host/src/server/gitoxide-managed-inspection.ts index 82a7dd2d92..fa9fa4bec6 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-inspection.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-inspection.ts @@ -347,7 +347,7 @@ export async function tryOpenPackagedGitoxideManagedInspectionComposition(input: readonly filesystemWorker?: ManagedWorkspaceFilesystemWorker; readonly onUnavailable?: (error: unknown) => void; }): Promise { - const resourcesRoot = runtimeHostPackagedResourcesRoot(); + const resourcesRoot = runtimeHostPackagedResourcesRootInternal(); if (!resourcesRoot || !input.filesystemWorker) return undefined; const releaseOwnerToken = {}; const invocationOwnerToken = {}; @@ -396,7 +396,7 @@ export async function tryOpenPackagedGitoxideManagedInspectionComposition(input: } } -function runtimeHostPackagedResourcesRoot(): string | undefined { +export function runtimeHostPackagedResourcesRootInternal(): string | undefined { if (!process.versions.electron) return undefined; const resourcesPath = (process as NodeJS.Process & { readonly resourcesPath?: string }) .resourcesPath; diff --git a/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts b/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts index 63af26dfb7..6c62ec24df 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts @@ -27,6 +27,8 @@ import type { import { GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST } from '@maka/runtime/managed-mutation-transform'; import type { RuntimeManagedMutationAdmission, ToolRuntimeInput } from '@maka/runtime/tool-runtime'; import type { + ManagedMutationTerminalCommitInput, + ManagedMutationTerminalCommitResult, WorkspaceSuccessorCommitInput, WorkspaceSuccessorCommitResult, } from '@maka/storage/workspace-version-authority-internal'; @@ -77,6 +79,9 @@ export interface GitoxideManagedMutationSettlementAuthorityInternal { ): Promise; readVersion(workspaceVersionId: string): Promise; commitSuccessor(input: WorkspaceSuccessorCommitInput): Promise; + commitTerminal( + input: ManagedMutationTerminalCommitInput, + ): Promise; } export function createGitoxideManagedMutationAdmissionInternal(input: { @@ -129,11 +134,31 @@ export function createGitoxideManagedMutationAdmissionInternal(input: { }), 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 (proof.isError || !mutation || !mutation.changed || mutation.canonicalPath !== path) { + if (!mutation || mutation.canonicalPath !== path) { return Object.freeze({ kind: 'unsettled' as const, - error: new Error('Gitoxide managed mutation has no changed success candidate'), + 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 candidate = await candidateAuthority.capture({ @@ -154,12 +179,7 @@ export function createGitoxideManagedMutationAdmissionInternal(input: { }); await input.settlementAuthority.commitSuccessor({ successor, - toolOutcome: { - operationId: request.operationId, - journalEventId: `${request.operationId}_outcome`, - runtimeEvent: proof.durableOutcome, - committedAt: proof.durableOutcome.ts, - }, + toolOutcome: toolOutcomeInput(request.operationId, proof.durableOutcome), }); await candidateAuthority.promote(candidate, request.abortSignal); return Object.freeze({ @@ -172,6 +192,18 @@ export function createGitoxideManagedMutationAdmissionInternal(input: { }; } +function toolOutcomeInput( + operationId: string, + durableOutcome: import('@maka/core/runtime-event').RuntimeEvent, +) { + return { + operationId, + journalEventId: `${operationId}_outcome`, + runtimeEvent: durableOutcome, + committedAt: durableOutcome.ts, + }; +} + export async function reconcileGitoxideManagedMutationProjectionInternal(input: { readonly workspaceId: string; readonly workspaceEpochId: string; 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..bb5907f10c --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts @@ -0,0 +1,443 @@ +/* + * 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 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 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, + reconcileGitoxideManagedMutationProjectionInternal, +} from './gitoxide-managed-mutation-admission.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 reconcileProjection: (abortSignal?: AbortSignal) => Promise; +} + +/** + * 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 storageRoot: string; + readonly sourceRoot: string; + readonly sessionId: string; + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly settlementAuthority: ExecutionStoresWorkspaceMutationAuthorityInternal; + readonly abortSignal?: AbortSignal; +}): Promise { + input.abortSignal?.throwIfAborted(); + const [storageRoot, sourceRoot, helper] = await Promise.all([ + realpath(input.storageRoot), + realpath(input.sourceRoot), + verifyGitoxideHelperArtifactForInvocationInternal( + input.invocationOwnerToken, + input.helperCapability, + ), + ]); + const identity = managedMutationIdentity(sourceRoot, input.sessionId); + 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: sha256( + `maka-gitoxide-materialization-v1\0${receipt.helperArtifactSha256}\0`, + ), + materializationSemantics: 'git_tree_materialized_with_fixed_config_v1', + policyHash: sha256('maka-gitoxide-managed-mutation-policy-v1\0'), + }, + 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'); + 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 + ) { + throw new Error('Gitoxide baseline receipt conflicts with accepted baseline authority'); + } + + const candidateAuthorityForHead = (baseHead: typeof head) => + createGitoxideMutationCandidateAuthorityInternal({ + storageRoot, + baseHead, + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + }); + 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, + }); + return Object.freeze({ + head, + admitManagedMutation, + 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/hosted-execution-tool-profile.ts b/packages/runtime-host/src/server/hosted-execution-tool-profile.ts index 151aebb05a..e39aecf748 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 = ['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: 'managed_mutation_v1' as const, + }; + } + return tool; + }); } From fb1fe60b0343c0a42b98bf88a72da1bf03aef131 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:15:17 +0800 Subject: [PATCH 73/86] test(runtime-host): exercise real Gitoxide managed sessions --- .../gitoxide-managed-mutation-session.test.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) 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 index 68e207e0c8..43ab91ee0d 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts @@ -24,6 +24,8 @@ import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test, type TestContext } from 'node:test'; +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'; @@ -98,12 +100,128 @@ test('opens one durable Gitoxide baseline and exactly reuses it for the session' 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 changed = await executeManagedWrite({ + stores, + session: reopened, + 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, 2); + assert.notEqual(afterChange.head.commitOid, first.head.commitOid); + + 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(); } }); +async function executeManagedWrite(input: { + readonly stores: Awaited>; + readonly session: Awaited>; + readonly operationId: string; + readonly content: string; + readonly changed: boolean; +}) { + 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-event`, + ...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, + }); + 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: 'notes.txt', + content: input.content, + changed: input.changed, + }, + })); +} + function git(cwd: string, args: readonly string[]): string { return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); } From ded49115f54c6a97a79193f9d3c8204f9db30673 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:19:34 +0800 Subject: [PATCH 74/86] test(runtime-host): prove successor projection recovery --- ...de-managed-mutation-session-crash-child.ts | 160 +++++++++++++++++ .../gitoxide-managed-mutation-session.test.ts | 167 +++++++++++++++++- .../gitoxide-managed-mutation-admission.ts | 4 + .../gitoxide-managed-mutation-session.ts | 3 + 4 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 packages/runtime-host/src/__tests__/fixtures/gitoxide-managed-mutation-session-crash-child.ts 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..3727bb2dba --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/gitoxide-managed-mutation-session-crash-child.ts @@ -0,0 +1,160 @@ +/* + * 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({ + storageRoot: storageCapability.canonicalPath, + 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-event`, + ...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-managed-mutation-session.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts index 43ab91ee0d..b7f5d1dea7 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts @@ -19,11 +19,13 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; -import { execFileSync } from 'node:child_process'; +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'; @@ -129,6 +131,112 @@ test('opens one durable Gitoxide baseline and exactly reuses it for the session' } }); +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({ + storageRoot: initialStorage.canonicalPath, + 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({ + storageRoot: reopenedStorage.canonicalPath, + sourceRoot, + sessionId, + ...reopenedHelper, + settlementAuthority: + requireExecutionStoresWorkspaceMutationAuthorityInternal(reopenedStores), + }); + assert.equal(reopened.head.revision, 2); + const exactRetry = await openGitoxideManagedMutationSession({ + storageRoot: reopenedStorage.canonicalPath, + 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>; @@ -225,3 +333,60 @@ async function executeManagedWrite(input: { 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/server/gitoxide-managed-mutation-admission.ts b/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts index 6c62ec24df..d1d6d0904a 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts @@ -84,6 +84,8 @@ export interface GitoxideManagedMutationSettlementAuthorityInternal { ): Promise; } +export type GitoxideManagedMutationAdmissionFailpoint = 'after_successor_commit'; + export function createGitoxideManagedMutationAdmissionInternal(input: { readonly workspaceInstanceId: string; readonly workspaceId: string; @@ -92,6 +94,7 @@ export function createGitoxideManagedMutationAdmissionInternal(input: { readonly candidateAuthorityForHead: ( head: WorkspaceHeadRecordV1, ) => Promise; + readonly failpoint?: (point: GitoxideManagedMutationAdmissionFailpoint) => void | Promise; }): NonNullable { return async (request: AdmissionInput): Promise => { if (request.toolName !== 'Write' && request.toolName !== 'Edit') { @@ -181,6 +184,7 @@ export function createGitoxideManagedMutationAdmissionInternal(input: { 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, diff --git a/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts b/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts index bb5907f10c..3bb9cd1d1b 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts @@ -40,6 +40,7 @@ import { } from './gitoxide-helper-mutation-candidate-authority-internal.js'; import { createGitoxideManagedMutationAdmissionInternal, + type GitoxideManagedMutationAdmissionFailpoint, reconcileGitoxideManagedMutationProjectionInternal, } from './gitoxide-managed-mutation-admission.js'; @@ -87,6 +88,7 @@ export async function openGitoxideManagedMutationSession(input: { 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([ @@ -255,6 +257,7 @@ export async function openGitoxideManagedMutationSession(input: { workspaceEpochId: identity.workspaceEpochId, settlementAuthority: input.settlementAuthority, candidateAuthorityForHead, + failpoint: input.failpoint, }); return Object.freeze({ head, From 062b7813ddda994cd479b148263357a5e3011eda Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:27:44 +0800 Subject: [PATCH 75/86] docs(runtime): record Gitoxide projection crash proof --- .../gitoxide-write-edit-acceptance-v1.zh-CN.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md b/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md index a8d281cd61..18d44cff71 100644 --- a/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md +++ b/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md @@ -61,6 +61,9 @@ reservation;generic T2 writer 在数据库层拒绝 managed mutation。 ## 后续闭环 Runtime-owned outcome、SQLite successor writer、baseline session owner 与 ref projection 已串入 -`managed-coding-v1` Host backend。转 Ready 前仍需由打包 helper 的三平台 lane 证明 Rust import -exact retry,并补“SQLite 已提交、projection 前杀 Host、重启后只推进 ref”的真实进程测试;完成前 -保持 Draft,也不进入 M3 的自动恢复策略。 +`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。 From 55f26b6e5e033b4bb9d48dee8014682a615af034 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:35:00 +0800 Subject: [PATCH 76/86] fix(runtime-host): align real Gitoxide session contracts --- .../fixtures/gitoxide-managed-mutation-session-crash-child.ts | 3 +-- .../src/__tests__/gitoxide-managed-mutation-session.test.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) 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 index 3727bb2dba..f9fba39839 100644 --- 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 @@ -124,7 +124,7 @@ const dispatchEvent: RuntimeEvent = { }; await stores.runtimeEventStore.commitToolPrepared({ operationId, - journalEventId: `${operationId}-prepared`, + journalEventId: `${operationId}_prepared`, runtimeEvent: callEvent, dispatchRuntimeEvent: dispatchEvent, providerToolCallId: toolCallId, @@ -157,4 +157,3 @@ await admission.execute(async () => ({ changed: true, }, })); - 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 index b7f5d1dea7..0764ddb221 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts @@ -290,7 +290,7 @@ async function executeManagedWrite(input: { }; await input.stores.runtimeEventStore.commitToolPrepared({ operationId: input.operationId, - journalEventId: `${input.operationId}-prepared`, + journalEventId: `${input.operationId}_prepared`, runtimeEvent: callEvent, dispatchRuntimeEvent: dispatchEvent, providerToolCallId: toolCallId, From ee94ccf7b3927d7a0d108012fc67ed34a35a9a1e Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:40:05 +0800 Subject: [PATCH 77/86] test(runtime-host): align managed dispatch identity --- .../fixtures/gitoxide-managed-mutation-session-crash-child.ts | 2 +- .../src/__tests__/gitoxide-managed-mutation-session.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index f9fba39839..f10ae4eba2 100644 --- 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 @@ -103,7 +103,7 @@ const callEvent: RuntimeEvent = { refs: { operationId, toolCallId }, }; const dispatchEvent: RuntimeEvent = { - id: `${operationId}-dispatch-event`, + id: `${operationId}_dispatch`, ...identity, ts: 20, partial: false, 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 index 0764ddb221..3fb8efe577 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts @@ -269,7 +269,7 @@ async function executeManagedWrite(input: { refs: { operationId: input.operationId, toolCallId }, }; const dispatchEvent: RuntimeEvent = { - id: `${input.operationId}-dispatch-event`, + id: `${input.operationId}_dispatch`, ...identity, ts: 10, partial: false, From 2a9e4ffaa73da14fc89781befb14bb396292c153 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 03:43:57 +0800 Subject: [PATCH 78/86] fix(runtime-host): replay promoted Gitoxide candidates --- ...ation-candidate-authority-internal.test.ts | 7 ++++ ...r-mutation-candidate-authority-internal.ts | 33 ++++++++++++------- 2 files changed, 29 insertions(+), 11 deletions(-) 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 index d84777373a..d7216e08f1 100644 --- 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 @@ -182,6 +182,13 @@ test('replays promotion from the strict durable receipt without recreating the m gitBare(fixture.repositoryPath, ['rev-parse', 'refs/maka/accepted']), proof.receipt.candidateCommitOid, ); + + const exactRetry = await createGitoxideMutationCandidateAuthorityInternal({ + ...fixture.helper, + storageRoot: fixture.storageRoot, + 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) => { 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 index cd5cc8c67e..54cf299d33 100644 --- 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 @@ -168,15 +168,21 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { GitoxideMutationCandidateProofV1, GitoxideMutationCandidateReceiptV1 >(); - const managedRepositoryCapability = await reopenGitoxideManagedRepositoryInternal({ - invocationOwnerToken: input.invocationOwnerToken, - helperCapability: input.helperCapability, - managedRepositoryOwnerToken, - repositoryPath: rootContext.repositoryPath, - acceptedRef: ACCEPTED_REF, - expectedAcceptedCommitOid: input.baseHead.commitOid, - expectedAcceptedTreeOid: input.baseHead.treeOid, - }); + 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, @@ -201,6 +207,7 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { 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, @@ -263,7 +270,7 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { const result = await promoteCandidateWithGitoxideHelperInternal({ invocationOwnerToken: input.invocationOwnerToken, capability: input.helperCapability, - repositoryPath, + repositoryPath: rootContext.repositoryPath, expectedBaseCommitOid: receipt.baseCommitOid, acceptedRef: receipt.acceptedRef, candidateRef: receipt.candidateRef, @@ -288,6 +295,7 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { return Object.freeze({ async readBaseFile(path: string, abortSignal?: AbortSignal) { try { + const managedRepositoryCapability = await requireBaseRepositoryCapability(); const result = await readGitoxideTreeFileInternal({ invocationOwnerToken: input.invocationOwnerToken, helperCapability: input.helperCapability, @@ -347,7 +355,10 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { ); } const operationIdentitySha256 = sha256(operationId); - const receiptPath = join(canonicalReceiptRoot, `${operationIdentitySha256.slice(7)}.json`); + const receiptPath = join( + rootContext.canonicalReceiptRoot, + `${operationIdentitySha256.slice(7)}.json`, + ); return withProcessLifetimeFileUpdateLock(receiptPath, async () => { abortSignal?.throwIfAborted(); const receipt = await readReceipt(receiptPath); From 80fb34aa680bfe913ed06267cf1a125cdf7bc3eb Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 10:39:17 +0800 Subject: [PATCH 79/86] fix(runtime-host): route managed coding through Gitoxide --- .../src/__tests__/hosted-execution-tool-profile.test.ts | 2 +- .../runtime-host/src/server/hosted-execution-tool-profile.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 531812e0e6..bf960a5f4a 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 @@ -108,6 +108,6 @@ test('the managed coding profile exposes only owner-backed file operations', () const projected = projectHostedExecutionTools(tools, 'managed-coding-v1'); for (const tool of projected) { assert.equal(tool.recoveryMode, 'reconcile'); - assert.equal(tool.durableExecutionProfile, 'managed_mutation_v1'); + assert.equal(tool.durableExecutionProfile, 'gitoxide_managed_mutation_v1'); } }); 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 e39aecf748..2518054e37 100644 --- a/packages/runtime-host/src/server/hosted-execution-tool-profile.ts +++ b/packages/runtime-host/src/server/hosted-execution-tool-profile.ts @@ -109,7 +109,7 @@ export function projectHostedExecutionTools( return { ...tool, recoveryMode: 'reconcile' as const, - durableExecutionProfile: 'managed_mutation_v1' as const, + durableExecutionProfile: 'gitoxide_managed_mutation_v1' as const, }; } return tool; From 3422000a8692d6eb3ba378ec8138855cbd916700 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:35:59 +0800 Subject: [PATCH 80/86] fix(runtime-host): preserve managed mutation ownership --- .../gitoxide-managed-mutation-session-crash-child.ts | 7 +++++-- ...-helper-mutation-candidate-authority-internal.test.ts | 6 +++--- .../src/__tests__/gitoxide-managed-inspection.test.ts | 1 + .../__tests__/gitoxide-managed-mutation-session.test.ts | 8 ++++---- .../runtime-host/src/server/execution-composition.ts | 5 +---- .../src/server/gitoxide-managed-inspection.ts | 3 --- .../src/server/gitoxide-managed-mutation-session.ts | 9 +++++---- .../src/__tests__/tool-runtime-durable-boundary.test.ts | 9 +++++++-- 8 files changed, 26 insertions(+), 22 deletions(-) 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 index f10ae4eba2..3418fd7155 100644 --- 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 @@ -58,7 +58,10 @@ const helperCapability = await admitGitoxideHelperArtifactInternal({ invocationOwnerToken, claim, }); -const storageCapability = await resolveStorageRoot({ path: input.storageRoot, kind: 'interactive' }); +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); @@ -66,7 +69,7 @@ const operationId = 'operation-gitoxide-process-crash'; const toolCallId = `${operationId}-call`; const args = { path: 'notes.txt', content: 'after crash boundary\n' }; const session = await openGitoxideManagedMutationSession({ - storageRoot: storageCapability.canonicalPath, + storageRootLease: storageOwner.lease, sourceRoot: input.sourceRoot, sessionId: input.sessionId, invocationOwnerToken, 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 index d7216e08f1..b72f53d39f 100644 --- 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 @@ -160,7 +160,7 @@ test('replays promotion from the strict durable receipt without recreating the m const operationId = 'operation-durable-promote-1'; const authority = await createGitoxideMutationCandidateAuthorityInternal({ ...fixture.helper, - storageRoot: fixture.storageRoot, + storageRootLease: fixture.rootOwner.lease, baseHead: fixture.baseHead, }); const proof = await authority.capture({ @@ -171,7 +171,7 @@ test('replays promotion from the strict durable receipt without recreating the m }); const reopened = await createGitoxideMutationCandidateAuthorityInternal({ ...fixture.helper, - storageRoot: fixture.storageRoot, + storageRootLease: fixture.rootOwner.lease, baseHead: fixture.baseHead, }); @@ -185,7 +185,7 @@ test('replays promotion from the strict durable receipt without recreating the m const exactRetry = await createGitoxideMutationCandidateAuthorityInternal({ ...fixture.helper, - storageRoot: fixture.storageRoot, + storageRootLease: fixture.rootOwner.lease, baseHead: fixture.baseHead, }); assert.deepEqual(await exactRetry.promoteDurable(operationId), receipt); diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts index 9d33eff3c6..1c73cfe464 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts @@ -45,6 +45,7 @@ const fakeNpmRuntime = Object.freeze({ 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'), 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 index 3fb8efe577..ed8cbc4411 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts @@ -87,7 +87,7 @@ test('opens one durable Gitoxide baseline and exactly reuses it for the session' claim, }); const input = { - storageRoot: storageCapability.canonicalPath, + storageRootLease: storageOwner.lease, sourceRoot, sessionId: 'session-gitoxide-managed-1', invocationOwnerToken, @@ -167,7 +167,7 @@ test('reopens after process death between successor acceptance and Git ref promo const initialHelper = await admitRealHelper(helperPath); try { const initial = await openGitoxideManagedMutationSession({ - storageRoot: initialStorage.canonicalPath, + storageRootLease: initialOwner.lease, sourceRoot, sessionId, ...initialHelper, @@ -211,7 +211,7 @@ test('reopens after process death between successor acceptance and Git ref promo const reopenedHelper = await admitRealHelper(helperPath); try { const reopened = await openGitoxideManagedMutationSession({ - storageRoot: reopenedStorage.canonicalPath, + storageRootLease: reopenedOwner.lease, sourceRoot, sessionId, ...reopenedHelper, @@ -220,7 +220,7 @@ test('reopens after process death between successor acceptance and Git ref promo }); assert.equal(reopened.head.revision, 2); const exactRetry = await openGitoxideManagedMutationSession({ - storageRoot: reopenedStorage.canonicalPath, + storageRootLease: reopenedOwner.lease, sourceRoot, sessionId, ...reopenedHelper, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index f6b7fce3c8..03cf2f09e8 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -337,12 +337,9 @@ export async function createExecutionRuntimeHostComposition( }); const packagedResourcesRoot = runtimeHostPackagedResourcesRootInternal(); if (packagedResourcesRoot) { - const releaseOwnerToken = {}; const invocationOwnerToken = {}; try { const helperCapability = await resolvePackagedGitoxideHelperInternal({ - resourcesRoot: packagedResourcesRoot, - releaseOwnerToken, invocationOwnerToken, }); gitoxideManagedMutationRuntime = Object.freeze({ @@ -652,7 +649,7 @@ export async function createExecutionRuntimeHostComposition( const managedMutationSession = backendContext.header.toolProfile === 'managed-coding-v1' ? await openGitoxideManagedMutationSession({ - storageRoot: context.owner.capability.canonicalPath, + storageRootLease: context.owner.lease, sourceRoot: backendContext.header.cwd, sessionId: backendContext.sessionId, invocationOwnerToken: requireGitoxideManagedMutationRuntime( diff --git a/packages/runtime-host/src/server/gitoxide-managed-inspection.ts b/packages/runtime-host/src/server/gitoxide-managed-inspection.ts index fa9fa4bec6..17da4c12b7 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-inspection.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-inspection.ts @@ -349,12 +349,9 @@ export async function tryOpenPackagedGitoxideManagedInspectionComposition(input: }): Promise { const resourcesRoot = runtimeHostPackagedResourcesRootInternal(); if (!resourcesRoot || !input.filesystemWorker) return undefined; - const releaseOwnerToken = {}; const invocationOwnerToken = {}; try { const helperCapability = await resolvePackagedGitoxideHelperInternal({ - resourcesRoot, - releaseOwnerToken, invocationOwnerToken, }); const npmRuntime = await resolveBundledNpmRuntime({ resourcesRoot }); diff --git a/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts b/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts index 3bb9cd1d1b..d3324a59f1 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts @@ -24,6 +24,7 @@ import { dirname, join } from 'node:path'; 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 { @@ -81,7 +82,7 @@ export interface GitoxideManagedMutationSession { * versions, and Runtime owns each operation result. */ export async function openGitoxideManagedMutationSession(input: { - readonly storageRoot: string; + readonly storageRootLease: StorageRootLease<'interactive', 'write'>; readonly sourceRoot: string; readonly sessionId: string; readonly invocationOwnerToken: object; @@ -92,7 +93,7 @@ export async function openGitoxideManagedMutationSession(input: { }): Promise { input.abortSignal?.throwIfAborted(); const [storageRoot, sourceRoot, helper] = await Promise.all([ - realpath(input.storageRoot), + runWithStorageRootLease(input.storageRootLease, 'interactive', 'write', async (root) => root), realpath(input.sourceRoot), verifyGitoxideHelperArtifactForInvocationInternal( input.invocationOwnerToken, @@ -234,9 +235,9 @@ export async function openGitoxideManagedMutationSession(input: { throw new Error('Gitoxide baseline receipt conflicts with accepted baseline authority'); } - const candidateAuthorityForHead = (baseHead: typeof head) => + const candidateAuthorityForHead = (baseHead: WorkspaceHeadRecordV1) => createGitoxideMutationCandidateAuthorityInternal({ - storageRoot, + storageRootLease: input.storageRootLease, baseHead, invocationOwnerToken: input.invocationOwnerToken, helperCapability: input.helperCapability, 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 e168c5b64b..7787f7b7e1 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'; @@ -1492,13 +1493,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) => { From 5829cdf0afdb4d3e14277d44e94cc0bf82afec8e Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:36:18 +0800 Subject: [PATCH 81/86] docs(runtime): define helper upgrade and crash scope --- .../gitoxide-write-edit-acceptance-v1.zh-CN.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md b/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md index 18d44cff71..63605513cc 100644 --- a/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md +++ b/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md @@ -42,6 +42,15 @@ reservation;generic T2 writer 在数据库层拒绝 managed mutation。 因此 SQLite 提交后、ref 推进前崩溃时,只重放 ref projection,不重新执行 Write/Edit。 +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 推导出来。 + ## 失败状态与回滚 - candidate 创建失败:不产生 accepted successor;保留或清理由 candidate 生命周期 owner 处理。 From 6ac4a5231dfb5d02c130d7bd25f2faa0846bbfea Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 12:14:36 +0800 Subject: [PATCH 82/86] fix(runtime-host): bind managed epochs to helper artifact --- .../src/__tests__/packaged-gitoxide-helper.test.ts | 6 +++++- .../server/gitoxide-helper-artifact-authority-internal.ts | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts b/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts index f80fa62b36..1b0dea8f14 100644 --- a/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts +++ b/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts @@ -41,6 +41,7 @@ test('turns an exact packaged helper manifest into an owner-bound invocation cap capability, ); assert.equal(verified.executablePath, fixture.executablePath); + assert.equal(verified.artifactSha256, fixture.artifactSha256); assert.equal(verified.protocolVersion, 1); } finally { await fixture.cleanup(); @@ -101,6 +102,7 @@ async function withPackagedResourcesRoot(root: string, run: () => Promise) async function createFixture(): Promise<{ root: string; executablePath: string; + artifactSha256: `sha256:${string}`; cleanup(): Promise; }> { const root = await mkdtemp(join(tmpdir(), 'maka-packaged-gitoxide-')); @@ -110,6 +112,7 @@ async function createFixture(): Promise<{ 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'), @@ -122,13 +125,14 @@ async function createFixture(): Promise<{ protocolVersion: 1, executableRelativePath: `gitoxide/${executableName}`, bytes: bytes.byteLength, - sha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + sha256: artifactSha256, distributionReady: true, })}\n`, ); return { root, executablePath, + artifactSha256, cleanup: () => rm(root, { recursive: true, force: true }), }; } 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 index 882da884b0..8cd23aa2b7 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts @@ -44,6 +44,7 @@ export interface GitoxideHelperReleaseArtifactStateInternal { export interface VerifiedGitoxideHelperArtifactInternal { readonly executablePath: string; + readonly artifactSha256: `sha256:${string}`; readonly protocolVersion: 1; } @@ -146,6 +147,7 @@ export async function verifyGitoxideHelperArtifactForInvocationInternal( } return Object.freeze({ executablePath: canonicalExecutablePath, + artifactSha256: record.claim.expectedSha256, protocolVersion: record.claim.protocolVersion, }); } From 44759be359ff2be88221323c668e6184ce85f1a3 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 13:38:20 +0800 Subject: [PATCH 83/86] fix(runtime-host): close managed mutation recovery authority --- .../workspace-version-authority.test.ts | 14 + .../core/src/workspace-version-authority.ts | 20 ++ .../gitoxide-managed-inspection.test.ts | 36 +++ ...itoxide-managed-mutation-admission.test.ts | 143 +++++++++++ .../gitoxide-managed-mutation-session.test.ts | 110 +++++--- .../hosted-execution-tool-profile.test.ts | 13 +- .../src/server/execution-composition.ts | 18 +- ...r-mutation-candidate-authority-internal.ts | 39 +++ .../src/server/gitoxide-managed-inspection.ts | 188 ++++++++------ .../gitoxide-managed-mutation-admission.ts | 241 +++++++++++++++++- .../gitoxide-managed-mutation-session.ts | 91 ++++++- .../server/hosted-execution-tool-profile.ts | 2 +- ...ion-stores-workspace-authority-internal.ts | 22 +- 13 files changed, 821 insertions(+), 116 deletions(-) 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/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__/gitoxide-managed-inspection.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts index 1c73cfe464..4e86fb9525 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts @@ -88,6 +88,42 @@ test('keeps provisioning-backed managed inspection out of read-only Plan Mode', ); }); +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) { 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 index 8d85292c27..97a36bfc3e 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-admission.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-admission.test.ts @@ -21,16 +21,159 @@ 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'; +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:992cc9a7a2f7cd32b1062241146727aac11ae111ab81d480c57c5d68ad8f35cc' 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('commits the exact Runtime outcome before promoting the Gitoxide candidate', async () => { const order: string[] = []; const head = baselineHead(); 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 index ed8cbc4411..a0e7262f27 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts @@ -102,18 +102,55 @@ test('opens one durable Gitoxide baseline and exactly reuses it for the session' 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', + ); - const changed = await executeManagedWrite({ + await prepareManagedWrite({ stores, session: reopened, + operationId: 'operation-gitoxide-recover-t1', + content: 'recovered\n', + }); + 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, 2); + 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, @@ -243,6 +280,45 @@ async function executeManagedWrite(input: { 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 }; @@ -299,35 +375,7 @@ async function executeManagedWrite(input: { recoveryMode: 'reconcile', committedAt: 10, }); - 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: 'notes.txt', - content: input.content, - changed: input.changed, - }, - })); + return { admission, toolCallId, args, identity }; } function git(cwd: string, args: readonly string[]): string { 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 bf960a5f4a..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 @@ -95,11 +95,11 @@ test('the headless coding profile freezes prompt, tools, memory, and foreground test('the managed coding profile exposes only owner-backed file operations', () => { const profile = hostedExecutionRunProfile('managed-coding-v1'); assert.ok(profile); - assert.deepEqual(profile.toolNames, ['Write', 'Edit']); + assert.deepEqual(profile.toolNames, ['ManagedWorkspaceInspect', 'Write', 'Edit']); assert.equal(profile.memoryExtraction, false); assert.doesNotMatch(profile.systemPrompt, /Bash/u); - const tools: MakaTool[] = ['Write', 'Edit'].map((name) => ({ + const tools: MakaTool[] = ['ManagedWorkspaceInspect', 'Write', 'Edit'].map((name) => ({ name, description: name, parameters: z.object({}), @@ -107,7 +107,12 @@ test('the managed coding profile exposes only owner-backed file operations', () })); const projected = projectHostedExecutionTools(tools, 'managed-coding-v1'); for (const tool of projected) { - assert.equal(tool.recoveryMode, 'reconcile'); - assert.equal(tool.durableExecutionProfile, 'gitoxide_managed_mutation_v1'); + 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/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 03cf2f09e8..0a7d92b276 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -663,6 +663,15 @@ export async function createExecutionRuntimeHostComposition( 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, @@ -681,7 +690,7 @@ export async function createExecutionRuntimeHostComposition( ), goalTools: requireGoal(goal).tools, builtinTools, - hostTools, + hostTools: backendHostTools, resolveRootTools: (sessionId) => requireGraphCoordinator(graphCoordinator).toolsForSession(sessionId), parentAgentTools: childAgentTools.parentTools, @@ -1716,6 +1725,13 @@ 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: | { 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 index 54cf299d33..6123bf3d5c 100644 --- 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 @@ -32,6 +32,8 @@ 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, @@ -108,6 +110,13 @@ export interface GitoxideMutationCandidateAuthorityInternal { 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( @@ -316,6 +325,36 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { 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); diff --git a/packages/runtime-host/src/server/gitoxide-managed-inspection.ts b/packages/runtime-host/src/server/gitoxide-managed-inspection.ts index 17da4c12b7..5a359dae35 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-inspection.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-inspection.ts @@ -87,9 +87,34 @@ export interface GitoxideManagedInspectionResult { 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; } @@ -121,10 +146,81 @@ export async function createGitoxideManagedInspectionComposition( 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); @@ -139,89 +235,32 @@ export async function createGitoxideManagedInspectionComposition( operationRoot = await mkdtemp(join(canonicalStagingRoot, 'inspection-')); const repositoryPath = join(operationRoot, 'repository.git'); const projectionPath = join(operationRoot, 'projection'); - const admitted = await admitGitoxideRepositoryInternal({ - invocationOwnerToken, - helperCapability: input.helperCapability, - admissionOwnerToken, - repositoryPath: sourceRoot, - 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: repositoryPath, - baselineRef: BASELINE_REF, - abortSignal, - }); + 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 readGitoxideTreeFileInternal({ - invocationOwnerToken, - helperCapability: input.helperCapability, - managedRepositoryOwnerToken, - managedRepositoryCapability: imported.managedRepositoryCapability, - path: route.workerOperation.path, - abortSignal, - }); + 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 materializeGitoxideProjectionInternal({ - invocationOwnerToken, - helperCapability: input.helperCapability, - managedRepositoryOwnerToken, - managedRepositoryCapability: imported.managedRepositoryCapability, - projectionOwnerToken, - destinationPath: projectionPath, - abortSignal, - }); + const projection = await repository.materializeProjection(projectionPath, abortSignal); rawResult = await input.filesystemWorker.execute({ operation: route.workerOperation, cwd: projection.destinationPath, executionBoundary: route.executionBoundary, abortSignal, }); - const observation = await observeGitoxideProjectionInternal({ - invocationOwnerToken, - helperCapability: input.helperCapability, - projectionOwnerToken, - projectionCapability: projection.projectionCapability, - abortSignal, - }); - if (observation.kind !== 'projection_observed') { - throw new Error( - `Gitoxide projection drifted at ${observation.path}: ${observation.reason}`, - ); - } + 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 readGitoxideTreeFileInternal({ - invocationOwnerToken, - helperCapability: input.helperCapability, - managedRepositoryOwnerToken, - managedRepositoryCapability: imported.managedRepositoryCapability, - path: 'package.json', - abortSignal, - }); - const lockfile = await readGitoxideTreeFileInternal({ - invocationOwnerToken, - helperCapability: input.helperCapability, - managedRepositoryOwnerToken, - managedRepositoryCapability: imported.managedRepositoryCapability, - path: 'package-lock.json', - abortSignal, - }); + 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( @@ -259,8 +298,8 @@ export async function createGitoxideManagedInspectionComposition( const result = remapInspectionResult(route, rawResult); const response = Object.freeze({ kind: 'gitoxide_managed_inspection_v1' as const, - acceptedCommitOid: imported.baselineCommitOid, - acceptedTreeOid: imported.baselineTreeOid, + acceptedCommitOid: repository.acceptedCommitOid, + acceptedTreeOid: repository.acceptedTreeOid, ...(dependencyEnvironmentId ? { dependencyEnvironmentId } : {}), result, }); @@ -307,24 +346,31 @@ export async function createGitoxideManagedInspectionComposition( } }; - const tool: MakaTool = { + const createTool = ( + repositoryProvider?: GitoxideInspectionRepositoryProviderInternal, + ): MakaTool => ({ name: 'ManagedWorkspaceInspect', displayName: 'Inspect isolated workspace', description: - 'Read or glob a project through a fresh Maka-owned Gitoxide projection and its attested npm dependency environment. ' + + '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), - }; + 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'; }, diff --git a/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts b/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts index d1d6d0904a..02a59a9e35 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts @@ -19,14 +19,21 @@ 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 } from '@maka/runtime/managed-mutation-transform'; +import { + GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST, + 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, @@ -86,6 +93,148 @@ export interface GitoxideManagedMutationSettlementAuthorityInternal { 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 candidate = await candidateAuthority.capture({ + operationId: operation.operationId, + path, + content: transformed!.content, + executionProfileDigest: GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST, + abortSignal: input.abortSignal, + }); + 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; @@ -208,6 +357,96 @@ function toolOutcomeInput( }; } +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; diff --git a/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts b/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts index d3324a59f1..0201a8df58 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts @@ -21,6 +21,8 @@ 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'; @@ -41,9 +43,11 @@ import { } 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'; @@ -73,6 +77,7 @@ interface BaselineReceiptV1 extends Omit { export interface GitoxideManagedMutationSession { readonly head: WorkspaceHeadRecordV1; readonly admitManagedMutation: NonNullable; + readonly inspectionRepositoryProvider: GitoxideInspectionRepositoryProviderInternal; readonly reconcileProjection: (abortSignal?: AbortSignal) => Promise; } @@ -101,6 +106,13 @@ export async function openGitoxideManagedMutationSession(input: { ), ]); 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'); @@ -204,11 +216,9 @@ export async function openGitoxideManagedMutationSession(input: { objectFormat: 'sha1', sourceCommitOid: receipt.sourceCommitOid, sourceTreeOid: receipt.sourceTreeOid, - materializationProfileDigest: sha256( - `maka-gitoxide-materialization-v1\0${receipt.helperArtifactSha256}\0`, - ), + materializationProfileDigest, materializationSemantics: 'git_tree_materialized_with_fixed_config_v1', - policyHash: sha256('maka-gitoxide-managed-mutation-policy-v1\0'), + policyHash: workspacePolicyHash, }, baseline: { workspaceVersionId: receipt.workspaceVersionId, @@ -222,6 +232,9 @@ export async function openGitoxideManagedMutationSession(input: { 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 || @@ -230,7 +243,8 @@ export async function openGitoxideManagedMutationSession(input: { baselineVersion.workspaceId !== receipt.workspaceId || baselineVersion.workspaceEpochId !== receipt.workspaceEpochId || baselineVersion.commitOid !== receipt.baselineCommitOid || - baselineVersion.treeOid !== receipt.baselineTreeOid + baselineVersion.treeOid !== receipt.baselineTreeOid || + baselineVersion.policyHash !== workspacePolicyHash ) { throw new Error('Gitoxide baseline receipt conflicts with accepted baseline authority'); } @@ -242,6 +256,30 @@ export async function openGitoxideManagedMutationSession(input: { 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, @@ -263,6 +301,49 @@ export async function openGitoxideManagedMutationSession(input: { 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, 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 2518054e37..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,7 +31,7 @@ const HEADLESS_CODING_V1_TOOL_NAMES = [ 'apply_patch', ] as const; -const MANAGED_CODING_V1_TOOL_NAMES = ['Write', 'Edit'] 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.', diff --git a/packages/storage/src/execution-stores-workspace-authority-internal.ts b/packages/storage/src/execution-stores-workspace-authority-internal.ts index 458f2de6e4..9261c08f78 100644 --- a/packages/storage/src/execution-stores-workspace-authority-internal.ts +++ b/packages/storage/src/execution-stores-workspace-authority-internal.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeWorkspaceVersionAuthorityStore } from '@maka/core/runtime-event-store'; import type { WorkspaceBaselineAuthorityInput, @@ -29,11 +30,18 @@ import { 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; @@ -42,6 +50,11 @@ export interface ExecutionStoresWorkspaceMutationAuthorityInternal { 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( @@ -50,7 +63,7 @@ export interface ExecutionStoresWorkspaceMutationAuthorityInternal { } interface RegisteredWorkspaceAuthority { - readonly authority: RuntimeWorkspaceVersionAuthorityStore; + readonly authority: WorkspaceMutationRecoveryStore; readonly rootId: string; } @@ -58,7 +71,7 @@ const workspaceAuthorities = new WeakMap() export function registerExecutionStoresWorkspaceMutationAuthorityInternal( stores: object, - authority: RuntimeWorkspaceVersionAuthorityStore, + authority: WorkspaceMutationRecoveryStore, rootId: string, ): void { if (workspaceAuthorities.has(stores)) { @@ -79,6 +92,11 @@ export function requireExecutionStoresWorkspaceMutationAuthorityInternal( 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) => From 8512c78edbe1b7863f04585b43b08a78865be187 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 14:28:28 +0800 Subject: [PATCH 84/86] fix(core): encode frozen durable runtime events --- .../__tests__/canonical-runtime-event.test.ts | 23 +++++++++++++++++++ packages/core/src/canonical-runtime-event.ts | 14 ++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) 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/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; } From ef9cd839e1914d1b8311966b1ea9928498eb96a0 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 14:54:42 +0800 Subject: [PATCH 85/86] fix(release): pin Windows upgrade source authority --- scripts/prepare-windows-upgrade-baseline.mjs | 12 ++- .../prepare-windows-upgrade-baseline.test.mjs | 78 +++++++++++++++++++ scripts/verify-windows-harness.test.mjs | 1 + scripts/windows-upgrade-baseline.json | 1 + 4 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 scripts/prepare-windows-upgrade-baseline.test.mjs 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/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/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", From 998774a6c63aedfe3d690131050b01d31d9575b9 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 15:35:49 +0800 Subject: [PATCH 86/86] fix(runtime-host): close managed mutation recovery --- ...gitoxide-write-edit-acceptance-v1.zh-CN.md | 20 +- ...itoxide-managed-mutation-admission.test.ts | 235 +++++++++++++++++- .../gitoxide-managed-mutation-session.test.ts | 12 +- .../src/server/execution-composition.ts | 24 +- .../gitoxide-managed-mutation-admission.ts | 86 ++++++- .../gitoxide-managed-mutation-session.ts | 52 ++++ .../managed-mutation-transform.test.ts | 13 + .../session-manager-terminal-ledger.test.ts | 44 ++++ .../tool-runtime-durable-boundary.test.ts | 63 ++++- .../runtime/src/managed-mutation-transform.ts | 5 +- packages/runtime/src/session-manager.ts | 26 ++ packages/runtime/src/tool-runtime.ts | 76 ++++-- 12 files changed, 628 insertions(+), 28 deletions(-) diff --git a/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md b/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md index 63605513cc..afadbb7f93 100644 --- a/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md +++ b/docs/architecture/gitoxide-write-edit-acceptance-v1.zh-CN.md @@ -26,7 +26,8 @@ T1 后只有四种互斥终态: - `workspace_successor_committed`:成功且产生新 Git successor; - `no_workspace_change_committed`:成功但结果内容与 base 相同; -- `operation_failed_no_effect_committed`:纯转换在接触 Git candidate 前确定失败; +- `operation_failed_no_effect_committed`:纯转换失败,或 helper 以稳定 policy reason 证明 + candidate ref 尚未发布; - `unsettled`:无法证明以上任一终态,保留 reservation 并 fail-stop。 前两种 no-effect terminal 由同一个 SQLite writer 原子提交 exact T2、terminal fact 并释放 @@ -42,6 +43,14 @@ reservation;generic T2 writer 在数据库层拒绝 managed mutation。 因此 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,不把二进制摘要 @@ -53,12 +62,19 @@ rebaseline/新 epoch 流程选择升级。这里绑定的是 materialization art ## 失败状态与回滚 -- candidate 创建失败:不产生 accepted successor;保留或清理由 candidate 生命周期 owner 处理。 +- 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。 + ## 平台能力矩阵 | 平台 | 当前承诺 | 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 index 97a36bfc3e..8b09a3f84d 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-admission.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-admission.test.ts @@ -32,6 +32,7 @@ import { 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(); @@ -53,7 +54,7 @@ test('reconciles an active T1 from immutable Git and ledger facts without rerunn baseTreeOid: head.treeOid, expectedPaths: ['notes.txt'], executionProfileDigest: - 'sha256:992cc9a7a2f7cd32b1062241146727aac11ae111ab81d480c57c5d68ad8f35cc' as const, + 'sha256:4d9d03626705fdc7f895256b7a94b6c6fdd04c7bf76c70e67bab6a6f177e4b99' as const, }; const callEvent: RuntimeEvent = { id: `${operationId}_call`, @@ -174,6 +175,138 @@ test('reconciles an active T1 from immutable Git and ledger facts without rerunn 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(); @@ -218,7 +351,7 @@ test('commits the exact Runtime outcome before promoting the Gitoxide candidate' path: 'notes.txt', contentSha256: sha256('after\n'), executionProfileDigest: - 'sha256:992cc9a7a2f7cd32b1062241146727aac11ae111ab81d480c57c5d68ad8f35cc', + 'sha256:4d9d03626705fdc7f895256b7a94b6c6fdd04c7bf76c70e67bab6a6f177e4b99', }, }), promote: async (proof) => { @@ -340,6 +473,102 @@ test('commits no-op success and deterministic failure without advancing the work ]); }); +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); @@ -367,7 +596,7 @@ test('replays only candidate promotion after SQLite already accepted the success changedFileCount: 1, deletedFileCount: 0, executionProfileDigest: - 'sha256:992cc9a7a2f7cd32b1062241146727aac11ae111ab81d480c57c5d68ad8f35cc', + 'sha256:4d9d03626705fdc7f895256b7a94b6c6fdd04c7bf76c70e67bab6a6f177e4b99', acceptedEventId: 'successor-event-1', committedAt: 10, }; 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 index a0e7262f27..147f1dac59 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-mutation-session.test.ts @@ -35,7 +35,10 @@ import { admitGitoxideHelperArtifactInternal, issueGitoxideHelperReleaseArtifactClaimInternal, } from '../server/gitoxide-helper-artifact-authority-internal.js'; -import { openGitoxideManagedMutationSession } from '../server/gitoxide-managed-mutation-session.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; @@ -118,6 +121,13 @@ test('opens one durable Gitoxide baseline and exactly reuses it for the session' 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({ diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 0a7d92b276..7ed7cc3632 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -184,7 +184,10 @@ import { } 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 } from './gitoxide-managed-mutation-session.js'; +import { + openGitoxideManagedMutationSession, + recoverGitoxideManagedMutationBeforeRunClosureInternal, +} from './gitoxide-managed-mutation-session.js'; export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition { readonly workspaceExecution: RuntimeHostWorkspaceExecutionComposition; @@ -957,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, diff --git a/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts b/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts index 02a59a9e35..b2027d7425 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-mutation-admission.ts @@ -28,6 +28,7 @@ import type { } 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'; @@ -39,6 +40,7 @@ import type { WorkspaceSuccessorCommitInput, WorkspaceSuccessorCommitResult, } from '@maka/storage/workspace-version-authority-internal'; +import { GitoxideHelperInvocationError } from './gitoxide-helper-invocation-internal.js'; type AdmissionInput = Parameters>[0]; @@ -63,6 +65,26 @@ 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, @@ -212,13 +234,28 @@ export async function reconcilePreparedGitoxideManagedMutationInternal(input: { }); return 'terminal_committed'; } - const candidate = await candidateAuthority.capture({ + 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({ @@ -313,13 +350,39 @@ export function createGitoxideManagedMutationAdmissionInternal(input: { durableOutcome: proof.durableOutcome, }); } - const candidate = await candidateAuthority.capture({ + 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, @@ -345,6 +408,25 @@ export function createGitoxideManagedMutationAdmissionInternal(input: { }; } +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, diff --git a/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts b/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts index 0201a8df58..c171f20149 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-mutation-session.ts @@ -81,6 +81,58 @@ export interface GitoxideManagedMutationSession { 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 diff --git a/packages/runtime/src/__tests__/managed-mutation-transform.test.ts b/packages/runtime/src/__tests__/managed-mutation-transform.test.ts index b8de078b31..9c7d3338f1 100644 --- a/packages/runtime/src/__tests__/managed-mutation-transform.test.ts +++ b/packages/runtime/src/__tests__/managed-mutation-transform.test.ts @@ -57,3 +57,16 @@ test('uses the production Edit matcher and rejects an absent target', () => { /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__/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 7787f7b7e1..777caf1836 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -294,7 +294,7 @@ describe('ToolRuntime durable boundary', () => { durableDispatch: { ...managedMutationDispatch(), executionProfileDigest: - 'sha256:992cc9a7a2f7cd32b1062241146727aac11ae111ab81d480c57c5d68ad8f35cc', + 'sha256:4d9d03626705fdc7f895256b7a94b6c6fdd04c7bf76c70e67bab6a6f177e4b99', }, }; }, @@ -322,6 +322,67 @@ describe('ToolRuntime durable boundary', () => { 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( diff --git a/packages/runtime/src/managed-mutation-transform.ts b/packages/runtime/src/managed-mutation-transform.ts index a055f631bd..20a8524770 100644 --- a/packages/runtime/src/managed-mutation-transform.ts +++ b/packages/runtime/src/managed-mutation-transform.ts @@ -28,7 +28,10 @@ export interface ManagedMutationTransformResult { /** Frozen semantic identity of the Runtime-owned immutable Git transform. */ export const GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST = - 'sha256:992cc9a7a2f7cd32b1062241146727aac11ae111ab81d480c57c5d68ad8f35cc' as const; + '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 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 902960c08c..e4ee9e596c 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -96,6 +96,7 @@ 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 { @@ -508,6 +509,13 @@ export interface RuntimeManagedMutationOperationProof { }; } +export interface RuntimeManagedMutationOperationCapability { + (): Promise; + readonly rejectNoEffect?: ( + reason: 'candidate_rejected_before_publication', + ) => Promise; +} + export type RuntimeManagedMutationSettlement = | { readonly kind: 'workspace_successor_committed'; @@ -530,7 +538,7 @@ export interface RuntimeManagedMutationAdmission { readonly baseContent: string | null; }; execute( - operation: () => Promise, + operation: RuntimeManagedMutationOperationCapability, ): Promise; /** Idempotent for an unused, failed-T1, or already executed admission. */ dispose(): Promise; @@ -1631,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')); @@ -1645,21 +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, - durableOutcome: durableAttempt!.buildOutcome( - value.outcome.content, - value.outcome.isError, - value.outcome.durationMs, - ), - ...(value.managedMutationResult - ? { managedMutationResult: value.managedMutationResult } - : {}), - }; + return proofForValue(value); } finally { if (operationLifecycle.state === 'running') { operationLifecycle.state = 'settled'; @@ -1671,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;