diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index f9cfde7101b..1bf0dc3662f 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -27,10 +27,19 @@ on: - 'packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts' - 'packages/runtime-host/src/server/gitoxide-mutation-candidate-receipt-authority-internal.ts' - 'packages/runtime-host/src/server/gitoxide-managed-write-edit-owner-internal.ts' + - 'packages/runtime-host/src/server/gitoxide-managed-session-owner-internal.ts' + - 'packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts' + - 'packages/runtime-host/src/server/hosted-execution-tool-profile.ts' + - 'packages/runtime-host/src/server/execution-composition.ts' - 'packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts' - 'packages/runtime-host/src/__tests__/gitoxide-managed-write-edit-owner-internal.test.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-managed-session-owner-internal.test.ts' + - 'packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts' + - 'packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts' + - 'scripts/prepare-gitoxide-helper*' - 'packages/runtime-host/src/__tests__/fixtures/gitoxide-candidate-receipt-crash-child.ts' - 'packages/runtime-host/src/__tests__/fixtures/gitoxide-managed-write-edit-owner-crash-child.ts' + - 'packages/runtime-host/src/__tests__/fixtures/gitoxide-managed-session-owner-crash-child.ts' - 'packages/storage/src/execution-stores-workspace-authority-internal.ts' - 'packages/storage/src/workspace-version-authority-internal.ts' - 'packages/storage/src/sqlite-runtime-store.ts' @@ -47,10 +56,19 @@ on: - 'packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts' - 'packages/runtime-host/src/server/gitoxide-mutation-candidate-receipt-authority-internal.ts' - 'packages/runtime-host/src/server/gitoxide-managed-write-edit-owner-internal.ts' + - 'packages/runtime-host/src/server/gitoxide-managed-session-owner-internal.ts' + - 'packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts' + - 'packages/runtime-host/src/server/hosted-execution-tool-profile.ts' + - 'packages/runtime-host/src/server/execution-composition.ts' - 'packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts' - 'packages/runtime-host/src/__tests__/gitoxide-managed-write-edit-owner-internal.test.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-managed-session-owner-internal.test.ts' + - 'packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts' + - 'packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts' + - 'scripts/prepare-gitoxide-helper*' - 'packages/runtime-host/src/__tests__/fixtures/gitoxide-candidate-receipt-crash-child.ts' - 'packages/runtime-host/src/__tests__/fixtures/gitoxide-managed-write-edit-owner-crash-child.ts' + - 'packages/runtime-host/src/__tests__/fixtures/gitoxide-managed-session-owner-crash-child.ts' - 'packages/storage/src/execution-stores-workspace-authority-internal.ts' - 'packages/storage/src/workspace-version-authority-internal.ts' - 'packages/storage/src/sqlite-runtime-store.ts' @@ -107,3 +125,8 @@ jobs: 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-write-edit-owner-internal.test.js + packages/runtime-host/dist/__tests__/gitoxide-managed-session-owner-internal.test.js + packages/runtime-host/dist/__tests__/packaged-gitoxide-helper.test.js + packages/runtime-host/dist/__tests__/hosted-execution-tool-profile.test.js + - name: Test packaged helper preparation + run: node --test scripts/prepare-gitoxide-helper.test.mjs diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index f321c5f8bce..ab17556bc8f 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -1,6 +1,7 @@ # Playwright E2E run artifacts: traces, videos, screenshots, last-run state. test-results/ resources/workers/ +.generated/ .maka-dev/ .maka-dev.staging/ # Written by the pre-opt-in dev launcher, which ran unconditionally on macOS. diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index f50786a29d1..29488960b8e 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -91,6 +91,18 @@ const baseDesktopBuilderConfig = { '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-managed-product-composition-v1.zh-CN.md b/docs/architecture/gitoxide-managed-product-composition-v1.zh-CN.md new file mode 100644 index 00000000000..184e50cfa5b --- /dev/null +++ b/docs/architecture/gitoxide-managed-product-composition-v1.zh-CN.md @@ -0,0 +1,57 @@ +# Gitoxide managed product composition v1 + +## 主要不变量 + +只有显式选择 `managed-coding-v1` 的 Session,才能使用 packaged Gitoxide helper 打开 managed epoch,且该 +profile 的工具上限只有 Write/Edit。普通 Session 不进入该数据面;helper 缺失或身份不匹配时,managed Session +在 provider dispatch 前失败,禁止从 PATH 发现 Git、回退 attached checkout 或改走普通文件工具。 + +## Owner 与权限 + +- product release owner 生成 helper binary、strict manifest 和锁定 Cargo graph 的第三方 notices; +- packaged-resource resolver 只接受 `process.resourcesPath` 内的固定路径,并绑定 platform、arch、bytes、SHA-256、 + protocol version 与完整 operation allowlist; +- artifact authority 将 release claim 转为 Runtime Host 私有的 invocation capability;调用者不能提交 executable path; +- managed session owner 从 source root 与 Session ID 派生 durable identity,并向 ToolRuntime 提供 owner-bound + `admitManagedMutation`; +- ToolRuntime 保持 provider result 的唯一 owner,Gitoxide owner 只提交 terminal proof 与 durable successor。 + +外层已签名应用/安装包是 v1 的发布信任根。本合同防止误打包、manifest/helper 漂移、PATH 注入和普通 Session +误入 managed 数据面;不把本机恶意管理员或可任意改写完整安装目录的进程纳入密码学攻击模型。 + +## 原子性边界 + +1. Session header 在任何 T1 以前持久化 `managed-coding-v1`; +2. backend creation 在 provider dispatch 前要求 packaged helper capability,并打开 exact source epoch; +3. Write/Edit tool 在 T1 前取得 managed admission; +4. T1 后只允许 managed mutation state machine 提交 terminal outcome,禁止 generic T2 fallback; +5. Git candidate、SQLite successor 与 accepted ref projection 继续由下层 owner 按既有恢复协议收敛。 + +package build 的边界是:锁定 Cargo build成功后,复制到 `.generated`、计算 manifest、生成 notices,最后才允许 +electron-builder 读取这些资源。最终包验证必须要求 binary、manifest 与 notices 同时存在。 + +## 失败状态与回滚 + +| 状态 | 行为 | +| --- | --- | +| dev/CLI Host 没有 packaged helper | 普通 Session 正常;managed Session 明确 `managed_workspace_profile_unavailable` | +| manifest 缺字段、operation list 漂移 | Host 不授予 capability | +| helper bytes/path/platform/arch 不匹配 | fail closed;不从 PATH 替代 | +| source admission/import 失败 | provider dispatch 前失败,不产生 managed T1 | +| T1 后进程退出 | 下层 SQLite/Gitoxide owner 按 durable evidence 恢复,不重跑 Write/Edit | + +回滚本切片会删除 `managed-coding-v1` 产品入口和 packaged resource composition;现有普通 Session、SQLite 数据与 +attached execution 行为不需要迁移。 + +## 平台能力矩阵 + +Linux、macOS、Windows 使用相同 manifest、artifact digest、operation allowlist 与 short-lived helper protocol。 +正式 Desktop 当前打包 Windows x64 与 macOS arm64 helper;Linux workflow 继续证明 helper/owner 合同,但本切片 +不新增 Linux Desktop 发布物。进程崩溃恢复由三平台 helper workflow 证明;硬件断电与恶意本机管理员不在 v1 +新增承诺中。 + +## 非目标 + +- 不接入 npm dependency environment;纯 Write/Edit 不依赖 npm。 +- 不向 managed profile 暴露 Bash、Read、Glob、Grep 或 attached filesystem worker。 +- 不自动迁移普通 Session,也不把 managed capability 当作 resident Host 的隐式 fallback。 diff --git a/docs/architecture/gitoxide-managed-session-owner-v1.zh-CN.md b/docs/architecture/gitoxide-managed-session-owner-v1.zh-CN.md new file mode 100644 index 00000000000..156e552da4e --- /dev/null +++ b/docs/architecture/gitoxide-managed-session-owner-v1.zh-CN.md @@ -0,0 +1,51 @@ +# Gitoxide managed session owner v1 + +## 主要不变量 + +一个显式 managed session 只能从一次受权的 source observation 打开一个 durable workspace epoch。source +import、SQLite baseline 与后续 Write/Edit owner 必须绑定同一 source commit/tree、helper artifact、managed +tree policy 和 session identity。 + +## Owner 与权限 + +- Gitoxide repository admission owner 观察 source HEAD,并签发不可伪造的 admission capability; +- Gitoxide import owner 只从该 capability 导入 exact source commit; +- session owner 从 canonical source root 与 session ID 派生 repository/workspace/epoch/instance/version ID,调用者 + 不能自报 durable identity; +- Execution Stores baseline capability 各自持有一个 process-local imported-repository verifier。SQLite writer + 只接收 verifier 返回的 baseline descriptor,不接受裸 commit/tree/epoch; +- Write/Edit owner 只在 durable baseline 成立后签发 mutation admission。 + +## 原子性与恢复 + +Git repository import 与 SQLite baseline 不能组成一个物理事务。v1 使用以下有序提交: + +1. Gitoxide 将 exact source HEAD 导入 Maka-owned bare repository,并发布 `refs/maka/accepted`; +2. SQLite 原子提交 epoch、baseline version、head 和所有投影; +3. session owner 创建 Write/Edit owner,并验证 accepted ref 与 durable head 一致。 + +进程若在 1 与 2 之间退出,新 owner重新观察 source,并只允许 exact source commit/tree。Gitoxide import 做 exact +retry,Execution Stores 为新的 process-local proof 重新签发 baseline capability,SQLite 再做 exact retry。不会导入 +另一个 source HEAD,也不会覆盖已接受 epoch。 + +## 失败状态 + +| 状态 | 行为 | +| --- | --- | +| source 不是 policy-v3、SHA-1 repository | T1/baseline 前拒绝 | +| source 在已打开 epoch 后前进 | fail closed;不会静默 rebaseline | +| helper artifact digest 或 workspace policy 改变 | fail closed;新能力必须显式开新 epoch | +| import 已完成、baseline 未提交 | exact import + baseline retry | +| epoch/head/version 只有部分或彼此不一致 | corruption,拒绝签发 Write/Edit owner | +| accepted ref 落后 durable successor | Write/Edit projection recovery 只重放 candidate/ref CAS | + +## 平台能力矩阵 + +Linux、macOS、Windows 使用同一个 short-lived Gitoxide helper、source admission、import 和 SQLite baseline +协议。三平台 workflow 会在 import 后直接终止子进程,并由新进程证明 exact retry。该合同只覆盖进程崩溃; +macOS/Windows 的硬件断电持久性不由本切片扩大承诺。 + +## 当前交付边界 + +本切片是 packaged/Host composition 的 session owner。它不从 PATH 发现 helper,不接受公开 helper 路径,也不 +自动把普通 Desktop session 升级成 managed session。发布资源 authority 与显式产品入口由后续组合切片提供。 diff --git a/docs/architecture/runtime-recovery-post-gitoxide-rebuild-ledger.zh-CN.md b/docs/architecture/runtime-recovery-post-gitoxide-rebuild-ledger.zh-CN.md index 28edee32332..8e2a040a15b 100644 --- a/docs/architecture/runtime-recovery-post-gitoxide-rebuild-ledger.zh-CN.md +++ b/docs/architecture/runtime-recovery-post-gitoxide-rebuild-ledger.zh-CN.md @@ -131,4 +131,7 @@ Gitoxide helper 与 npm producer 分别保留自己的 trust root。packaged Hos helper workflow。 - R3:accepted-ref projection 已实现为 accepted truth 的派生 CAS;filesystem checkout projection 仍保持 独立延期,不参与 canonical Write/Edit read/write。 -- R4、R5:尚未在新基线上重建。 +- R4:session owner 已完成 source admission/import、durable baseline、helper identity/source drift 校验和 + import 后进程退出的 exact retry;packaged resource 与 Host 产品入口仍待接线。npm producer 不再作为纯 + Write/Edit 恢复链的前置。 +- R5:尚未在新基线上重建。 diff --git a/package.json b/package.json index f171851d5c7..657aa672baf 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,9 @@ "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", + "build:gitoxide-helper": "cargo +1.98.0 build --release --locked --manifest-path native/gitoxide-helper/Cargo.toml", + "prepare:gitoxide-helper": "node scripts/prepare-gitoxide-helper.mjs", + "generate:gitoxide-helper-notices": "node scripts/generate-gitoxide-helper-notices.mjs", "build:runtime-host-peer": "node native/runtime-host-peer/build.mjs", "lint:runtime-host-peer": "cargo clippy --locked --all-targets --manifest-path native/runtime-host-peer/Cargo.toml -- -D warnings", "dev": "npm --workspace @maka/desktop run dev:hmr --", diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index cbfc7efec37..5584d197823 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -205,7 +205,11 @@ export function isTurnStatus(value: unknown): value is TurnStatus { // Header (JSONL line 1) // ============================================================================ -export const SESSION_TOOL_PROFILES = ['headless-coding-v1', 'workhub-coordination-v1'] as const; +export const SESSION_TOOL_PROFILES = [ + 'headless-coding-v1', + 'managed-coding-v1', + 'workhub-coordination-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__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 3483b11db35..afeeb3a7484 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -1409,6 +1409,36 @@ test('hosted execution freezes the headless coding provider wire contract', asyn ); assert.equal(stableHash(profiled.body.tools), HEADLESS_CODING_V1_TOOLS_HASH); } + + const requestCountBeforeManagedAdmission = provider.requests.length; + const managedOutcome = await composition.handlers['hosted.execution.start']( + { + executionId: '00000000-0000-4000-8000-000000000779', + session: { + workspace: { kind: 'host_path', path: root }, + modelTarget: { + kind: 'explicit', + connectionId: connection.connectionId, + connectionSlug: 'profile-deepseek', + model: 'deepseek-v4-flash', + }, + permissionMode: 'bypass', + collaborationMode: 'agent', + orchestrationMode: 'default', + toolProfile: 'managed-coding-v1', + }, + content: { text: 'Modify the managed workspace.' }, + }, + context, + ); + assert.equal(managedOutcome.ok, true); + if (!managedOutcome.ok || managedOutcome.result.kind !== 'settled') return; + assert.equal(managedOutcome.result.status, 'failed'); + assert.match( + managedOutcome.result.failureReason ?? '', + /managed_workspace_profile_unavailable/u, + ); + assert.equal(provider.requests.length, requestCountBeforeManagedAdmission); } finally { try { await composition?.close(); diff --git a/packages/runtime-host/src/__tests__/fixtures/gitoxide-managed-session-owner-crash-child.ts b/packages/runtime-host/src/__tests__/fixtures/gitoxide-managed-session-owner-crash-child.ts new file mode 100644 index 00000000000..531adcb2450 --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/gitoxide-managed-session-owner-crash-child.ts @@ -0,0 +1,84 @@ +/* + * 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 } from 'node:fs/promises'; +import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; +import { + discoverMarkedStorageRoot, + tryAcquireInteractiveRootOwner, +} from '@maka/storage/root-authority'; +import { + admitGitoxideHelperArtifactInternal, + issueGitoxideHelperReleaseArtifactClaimInternal, +} from '../../server/gitoxide-helper-artifact-authority-internal.js'; +import { openGitoxideManagedSessionOwnerInternal } from '../../server/gitoxide-managed-session-owner-internal.js'; + +interface Fixture { + readonly storageRoot: string; + readonly sourceRoot: string; + readonly sessionId: string; + readonly helperPath: string; +} + +const fixturePath = process.argv[2]; +if (!fixturePath) throw new Error('Missing managed session crash fixture path'); +const fixture = JSON.parse(await readFile(fixturePath, 'utf8')) as Fixture; +const rootCapability = await discoverMarkedStorageRoot({ path: fixture.storageRoot }); +if (rootCapability.kind !== 'interactive') throw new Error('Crash fixture root kind is invalid'); +const rootOwner = await tryAcquireInteractiveRootOwner(rootCapability); +if (!rootOwner) throw new Error('Crash fixture could not acquire the storage root'); +const stores = await openInteractiveExecutionStoresForWrite(rootOwner.lease); +const helperPath = await realpath(fixture.helperPath); +const helperBytes = await readFile(helperPath); +const helperInfo = await stat(helperPath); +const releaseOwnerToken = {}; +const invocationOwnerToken = {}; +const helperCapability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim: issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath: helperPath, + expectedSha256: `sha256:${createHash('sha256').update(helperBytes).digest('hex')}`, + expectedBytes: helperInfo.size, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + supportedOperations: [ + 'inspect_repository', + 'import_source_head', + 'create_candidate', + 'promote_candidate', + 'observe_accepted_ref', + 'read_tree_file', + ], + }), +}); +await openGitoxideManagedSessionOwnerInternal({ + storageRootLease: rootOwner.lease, + stores, + invocationOwnerToken, + helperCapability, + sourceRoot: fixture.sourceRoot, + sessionId: fixture.sessionId, + failpoint(point) { + if (point === 'after_repository_import') process.exit(74); + }, +}); +throw new Error('Crash fixture did not stop after repository import'); diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-session-owner-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-session-owner-internal.test.ts new file mode 100644 index 00000000000..7f908852da9 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-session-owner-internal.test.ts @@ -0,0 +1,240 @@ +/* + * 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 { once } from 'node:events'; +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 { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; +import { + admitGitoxideHelperArtifactInternal, + type GitoxideHelperInvocationCapability, + issueGitoxideHelperReleaseArtifactClaimInternal, +} from '../server/gitoxide-helper-artifact-authority-internal.js'; +import { openGitoxideManagedSessionOwnerInternal } from '../server/gitoxide-managed-session-owner-internal.js'; + +test('opens one durable Gitoxide baseline and reuses it for the same session', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real session owner test'); + return; + } + const sourceRoot = await createRepository(t); + await writeFile(join(sourceRoot, 'notes.txt'), 'before\n', 'utf8'); + git(sourceRoot, ['add', 'notes.txt']); + commit(sourceRoot, 'baseline'); + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-session-owner-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const rootCapability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(rootCapability); + assert.ok(rootOwner); + t.after(() => rootOwner.close()); + const stores = await openInteractiveExecutionStoresForWrite(rootOwner.lease); + t.after(() => stores.sessionStore.close?.()); + + const first = await openGitoxideManagedSessionOwnerInternal({ + storageRootLease: rootOwner.lease, + stores, + ...helper, + sourceRoot, + sessionId: 'session-managed-owner', + }); + const retry = await openGitoxideManagedSessionOwnerInternal({ + storageRootLease: rootOwner.lease, + stores, + ...helper, + sourceRoot, + sessionId: 'session-managed-owner', + }); + assert.equal(retry.repositoryPath, first.repositoryPath); + assert.equal(retry.workspaceEpochId, first.workspaceEpochId); + const admission = await retry.writeEdit.admitManagedMutation({ + operationId: 'operation-session-read', + toolName: 'Write', + persistedArgs: { path: 'notes.txt', content: 'after\n' }, + abortSignal: new AbortController().signal, + }); + assert.deepEqual(admission.immutableBase, { content: 'before\n' }); +}); + +test('fails closed when the source advances after its managed epoch opens', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the source drift test'); + return; + } + const sourceRoot = await createRepository(t); + await writeFile(join(sourceRoot, 'notes.txt'), 'before\n', 'utf8'); + git(sourceRoot, ['add', 'notes.txt']); + commit(sourceRoot, 'baseline'); + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-session-drift-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const rootCapability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(rootCapability); + assert.ok(rootOwner); + t.after(() => rootOwner.close()); + const stores = await openInteractiveExecutionStoresForWrite(rootOwner.lease); + t.after(() => stores.sessionStore.close?.()); + const common = { + storageRootLease: rootOwner.lease, + stores, + ...helper, + sourceRoot, + sessionId: 'session-managed-drift', + } as const; + await openGitoxideManagedSessionOwnerInternal(common); + await writeFile(join(sourceRoot, 'notes.txt'), 'source advanced\n', 'utf8'); + git(sourceRoot, ['add', 'notes.txt']); + commit(sourceRoot, 'advance'); + await assert.rejects( + openGitoxideManagedSessionOwnerInternal(common), + /source or durable epoch has drifted/i, + ); +}); + +test('retries an exact import after the publishing process exits before baseline commit', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the baseline crash test'); + return; + } + const sourceRoot = await createRepository(t); + await writeFile(join(sourceRoot, 'notes.txt'), 'before\n', 'utf8'); + git(sourceRoot, ['add', 'notes.txt']); + commit(sourceRoot, 'baseline'); + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-session-crash-'))); + t.after(() => rm(root, { recursive: true, force: true })); + await resolveStorageRoot({ path: root, kind: 'interactive' }); + const fixturePath = join(root, 'managed-session-crash-fixture.json'); + await writeFile( + fixturePath, + `${JSON.stringify({ + storageRoot: root, + sourceRoot, + sessionId: 'session-managed-crash', + helperPath: helper.helperPath, + })}\n`, + 'utf8', + ); + const child = spawn( + process.execPath, + [ + join(import.meta.dirname, 'fixtures', 'gitoxide-managed-session-owner-crash-child.js'), + fixturePath, + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + const stderr: Buffer[] = []; + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + const [exitCode] = (await once(child, 'exit')) as [number | null]; + assert.equal(exitCode, 74, Buffer.concat(stderr).toString('utf8')); + + const rootCapability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(rootCapability); + assert.ok(rootOwner); + t.after(() => rootOwner.close()); + const stores = await openInteractiveExecutionStoresForWrite(rootOwner.lease); + t.after(() => stores.sessionStore.close?.()); + const recovered = await openGitoxideManagedSessionOwnerInternal({ + storageRootLease: rootOwner.lease, + stores, + invocationOwnerToken: helper.invocationOwnerToken, + helperCapability: helper.helperCapability, + sourceRoot, + sessionId: 'session-managed-crash', + }); + const admission = await recovered.writeEdit.admitManagedMutation({ + operationId: 'operation-after-baseline-recovery', + toolName: 'Write', + persistedArgs: { path: 'notes.txt', content: 'after\n' }, + abortSignal: new AbortController().signal, + }); + assert.deepEqual(admission.immutableBase, { content: 'before\n' }); +}); + +async function admittedHelper(): Promise< + | { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly helperPath: string; + } + | undefined +> { + 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, + supportedOperations: [ + 'inspect_repository', + 'import_source_head', + 'create_candidate', + 'promote_candidate', + 'observe_accepted_ref', + 'read_tree_file', + ], + }); + return { + invocationOwnerToken, + helperPath, + helperCapability: await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }), + }; +} + +async function createRepository(t: TestContext): Promise { + const path = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-session-source-'))); + t.after(() => rm(path, { recursive: true, force: true })); + git(path, ['init', '--quiet', '--object-format=sha1']); + return path; +} + +function commit(cwd: string, message: string): void { + git(cwd, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + message, + ]); +} + +function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim(); +} diff --git a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts index c062d6262ec..9d7cc387e3e 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 @@ -43,6 +43,13 @@ test('hosted execution tool profiles are durable Session creation inputs', () => content: { text: 'solve' }, }); assert.equal(decoded.session.toolProfile, 'headless-coding-v1'); + assert.equal( + decodeHostedExecutionStartInput({ + ...decoded, + session: { ...decoded.session, toolProfile: 'managed-coding-v1' }, + }).session.toolProfile, + 'managed-coding-v1', + ); assert.throws( () => decodeHostedExecutionStartInput({ @@ -139,3 +146,30 @@ test('the WorkHub coordination profile has conversational authority but zero too }; assert.deepEqual(projectHostedExecutionTools([productTool], 'workhub-coordination-v1'), []); }); + +test('the managed coding profile exposes only owner-settled Write and Edit', () => { + const profile = hostedExecutionRunProfile('managed-coding-v1'); + assert.ok(profile); + assert.deepEqual(profile.toolNames, ['Write', 'Edit']); + assert.equal(profile.memoryExtraction, false); + assert.match(profile.systemPrompt, /managed Git workspace/u); + assert.match(profile.systemPrompt, /Write and Edit/u); + + const tools = ['Read', 'Write', 'Edit', 'Bash'].map( + (name): MakaTool => ({ + name, + description: name, + parameters: z.object({}), + impl: async () => 'not used by managed mutation execution', + }), + ); + const selected = projectHostedExecutionTools(tools, 'managed-coding-v1'); + assert.deepEqual( + selected.map(({ name }) => name), + ['Write', 'Edit'], + ); + for (const tool of selected) { + assert.equal(tool.recoveryMode, 'reconcile'); + assert.equal(tool.durableExecutionProfile, 'managed_mutation_v1'); + } +}); 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 00000000000..90e6bec8ba4 --- /dev/null +++ b/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + GITOXIDE_HELPER_OPERATIONS_INTERNAL, + verifyGitoxideHelperArtifactForInvocationInternal, +} from '../server/gitoxide-helper-artifact-authority-internal.js'; +import { + PackagedGitoxideHelperError, + resolvePackagedGitoxideHelperInternal, +} from '../server/packaged-gitoxide-helper-internal.js'; + +test('turns the strict release manifest into an owner-bound helper capability', async () => { + const fixture = await createFixture(); + try { + const invocationOwnerToken = {}; + const capability = await resolvePackagedGitoxideHelperInternal({ + invocationOwnerToken, + resourcesRoot: fixture.root, + }); + const verified = await verifyGitoxideHelperArtifactForInvocationInternal( + invocationOwnerToken, + capability, + ); + assert.equal(verified.executablePath, fixture.executablePath); + assert.deepEqual(verified.supportedOperations, GITOXIDE_HELPER_OPERATIONS_INTERNAL); + } finally { + await fixture.cleanup(); + } +}); + +test('fails closed for helper tamper and operation-list drift', async () => { + const tampered = await createFixture(); + try { + await writeFile(tampered.executablePath, 'tampered'); + await assert.rejects( + resolvePackagedGitoxideHelperInternal({ + invocationOwnerToken: {}, + resourcesRoot: tampered.root, + }), + (error: unknown) => + error instanceof PackagedGitoxideHelperError && + error.code === 'packaged_gitoxide_helper_integrity_mismatch', + ); + } finally { + await tampered.cleanup(); + } + + const narrowed = await createFixture(); + try { + const manifestPath = join(narrowed.root, 'gitoxide-helper.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + manifest.supportedOperations = ['inspect_repository']; + await writeFile(manifestPath, JSON.stringify(manifest)); + await assert.rejects( + resolvePackagedGitoxideHelperInternal({ + invocationOwnerToken: {}, + resourcesRoot: narrowed.root, + }), + (error: unknown) => + error instanceof PackagedGitoxideHelperError && + error.code === 'packaged_gitoxide_helper_manifest_invalid', + ); + } finally { + await narrowed.cleanup(); + } +}); + +async function createFixture() { + 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')}`, + supportedOperations: GITOXIDE_HELPER_OPERATIONS_INTERNAL, + distributionReady: true, + })}\n`, + ); + return { + root, + executablePath, + cleanup: () => rm(root, { recursive: true, force: true }), + }; +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index ac1b5e85f73..94757ce4d63 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -152,6 +152,11 @@ import { HostNetworkProxyCoordinator } from './network-proxy-coordinator.js'; import { HostOAuthExecutionAuthority } from './oauth-execution-authority.js'; import { HostOAuthCoordinator, type HostOAuthCoordinatorInput } from './oauth-coordinator.js'; import { HostPlanCoordinator } from './plan-coordinator.js'; +import { openGitoxideManagedSessionOwnerInternal } from './gitoxide-managed-session-owner-internal.js'; +import { + PackagedGitoxideHelperError, + resolvePackagedGitoxideHelperInternal, +} from './packaged-gitoxide-helper-internal.js'; import { HostProjectDirectoryAuthority, type PublishedProjectDirectoryRoot, @@ -260,6 +265,18 @@ export async function createExecutionRuntimeHostComposition( ); } const stores = storage.execution; + const gitoxideInvocationOwnerToken = {}; + const gitoxideHelperCapability = await resolvePackagedGitoxideHelperInternal({ + invocationOwnerToken: gitoxideInvocationOwnerToken, + }).catch((error: unknown) => { + if ( + error instanceof PackagedGitoxideHelperError && + error.code === 'packaged_gitoxide_helper_unavailable' + ) { + return undefined; + } + throw error; + }); let graphControlStore: ReturnType | undefined; let graphClient: HostAgentGraphCoordinator | undefined; let sessionEffects: HostSessionEffectCoordinator | undefined; @@ -674,8 +691,27 @@ export async function createExecutionRuntimeHostComposition( backends.register( 'ai-sdk', dependencies.primaryBackendFactory ?? - ((backendContext) => - createHostAiSdkBackend({ + (async (backendContext) => { + const managedSession = + backendContext.header.toolProfile === 'managed-coding-v1' + ? await (async () => { + if (!gitoxideHelperCapability) { + throw new Error( + 'managed_workspace_profile_unavailable: packaged Gitoxide helper authority is unavailable', + ); + } + return openGitoxideManagedSessionOwnerInternal({ + storageRootLease: context.owner.lease, + stores, + invocationOwnerToken: gitoxideInvocationOwnerToken, + helperCapability: gitoxideHelperCapability, + sourceRoot: backendContext.header.cwd, + sessionId: backendContext.sessionId, + abortSignal: backendContext.abortSignal, + }); + })() + : undefined; + return createHostAiSdkBackend({ context: backendContext, runtimePolicy: runtimePolicyStores, oauthCredentials, @@ -715,8 +751,12 @@ export async function createExecutionRuntimeHostComposition( backendContext.sessionId, ), runtimeCommitSink: stores.runtimeEventStore, + ...(managedSession + ? { admitManagedMutation: managedSession.writeEdit.admitManagedMutation } + : {}), requestDrain: context.requestDrain, - })), + }); + }), ); const runtimeAuthority: RuntimeHostedRootAuthority = { bindRun: (identity) => messages.bindRun(identity), diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index f9659c95230..5606be5ae83 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -46,6 +46,7 @@ import { stableHash, toolCatalogHash } from '@maka/runtime/request-shape'; import { toolAvailabilityHash } from '@maka/runtime/tool-availability'; import { type BackendFactoryContext } from '@maka/runtime/session-manager'; import { type RuntimeCommitSink } from '@maka/runtime/runtime-commit-sink'; +import type { ToolRuntimeInput } from '@maka/runtime/tool-runtime'; import type { SandboxDiagnosticsProvider } from '@maka/runtime/sandbox'; import { createAttachmentByteReader, @@ -81,6 +82,7 @@ export interface HostAiSdkBackendInput { readonly usage: HostExecutionUsageAuthority; readonly requestDrain: () => void; readonly runtimeCommitSink?: RuntimeCommitSink; + readonly admitManagedMutation?: ToolRuntimeInput['admitManagedMutation']; readonly childAgents?: HostChildAgentBackendCapabilities; readonly createFetchTransport?: (proxy: ProxiedFetchProxy | null) => ProxiedFetchTransport; } @@ -449,6 +451,7 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom assertModelCallAccountingReady, recordToolInvocation: (event) => recordToolInvocation({ repo: telemetry }, event), ...(input.runtimeCommitSink ? { runtimeCommitSink: input.runtimeCommitSink } : {}), + ...(input.admitManagedMutation ? { admitManagedMutation: input.admitManagedMutation } : {}), ...(providerRequestCapture ? { recordProviderRequestCapture: providerRequestCapture, diff --git a/packages/runtime-host/src/server/gitoxide-managed-session-owner-internal.ts b/packages/runtime-host/src/server/gitoxide-managed-session-owner-internal.ts new file mode 100644 index 00000000000..b78d7c51df0 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-managed-session-owner-internal.ts @@ -0,0 +1,229 @@ +/* + * 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 { mkdir, realpath } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST } from '@maka/core/runtime-event'; +import { + WORKSPACE_MATERIALIZATION_SEMANTICS_V1, + type WorkspaceBaselineAuthorityInput, +} from '@maka/core/workspace-version-authority'; +import type { InteractiveExecutionStoresWriter } from '@maka/storage/execution-stores'; +import { + issueExecutionStoresWorkspaceBaselineAuthorityInternal, + requireExecutionStoresWorkspaceBaselineAuthorityInternal, +} 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 { + createGitoxideManagedWriteEditOwnerInternal, + type GitoxideManagedWriteEditOwnerInternal, +} from './gitoxide-managed-write-edit-owner-internal.js'; +import { + admitGitoxideRepositoryInternal, + importAdmittedGitoxideRepositoryInternal, + requireGitoxideRepositoryAdmissionInternal, +} from './gitoxide-repository-admission-authority-internal.js'; + +const MANAGED_REPOSITORY_DIRECTORY = 'gitoxide-managed-repositories'; + +export interface GitoxideManagedSessionOwnerInternal { + readonly repositoryPath: string; + readonly repositoryId: string; + readonly workspaceId: string; + readonly workspaceEpochId: string; + readonly writeEdit: GitoxideManagedWriteEditOwnerInternal; +} + +export type GitoxideManagedSessionOwnerFailpoint = 'after_repository_import'; + +export async function openGitoxideManagedSessionOwnerInternal(input: { + readonly storageRootLease: StorageRootLease<'interactive', 'write'>; + readonly stores: InteractiveExecutionStoresWriter; + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly sourceRoot: string; + readonly sessionId: string; + readonly abortSignal?: AbortSignal; + readonly failpoint?: (point: GitoxideManagedSessionOwnerFailpoint) => void | Promise; +}): Promise { + input.abortSignal?.throwIfAborted(); + const [storageRoot, sourceRoot] = await Promise.all([ + runWithStorageRootLease(input.storageRootLease, 'interactive', 'write', async (root) => root), + realpath(input.sourceRoot), + ]); + const identity = deriveManagedSessionIdentity(sourceRoot, input.sessionId); + const repositoryPath = join( + storageRoot, + MANAGED_REPOSITORY_DIRECTORY, + identity.workspaceEpochId, + 'repository.git', + ); + await mkdir(dirname(repositoryPath), { recursive: true }); + + const admissionOwnerToken = {}; + const sourceAdmission = await admitGitoxideRepositoryInternal({ + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + admissionOwnerToken, + repositoryPath: sourceRoot, + ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), + }); + if (sourceAdmission.kind !== 'accepted') { + throw new Error(`Gitoxide managed session rejected source: ${sourceAdmission.reason}`); + } + const source = requireGitoxideRepositoryAdmissionInternal( + admissionOwnerToken, + sourceAdmission.capability, + ); + const materializationProfileDigest = sha256( + `maka-gitoxide-materialization-v3\0${source.helperArtifactSha256}\0`, + ); + const policyHash = sha256( + `maka-managed-write-edit-policy-v1\0${materializationProfileDigest}\0${MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST}\0`, + ); + const baselineOwnerToken = {}; + const verifiedBaselines = new WeakMap(); + const baselineCapability = issueExecutionStoresWorkspaceBaselineAuthorityInternal({ + ownerToken: baselineOwnerToken, + stores: input.stores, + verifyBaseline(proof) { + const baseline = verifiedBaselines.get(proof); + if (!baseline) throw new Error('Gitoxide managed session baseline proof is invalid'); + return baseline; + }, + }); + const baselineAuthority = requireExecutionStoresWorkspaceBaselineAuthorityInternal( + baselineOwnerToken, + baselineCapability, + ); + const existingEpoch = await baselineAuthority.readEpoch( + identity.workspaceId, + identity.workspaceEpochId, + ); + const existingHead = await baselineAuthority.readHead( + identity.workspaceId, + identity.workspaceEpochId, + ); + const existingVersion = existingHead + ? await baselineAuthority.readVersion(existingHead.workspaceVersionId) + : undefined; + if (existingEpoch || existingHead) { + if ( + !existingEpoch || + !existingHead || + existingEpoch.repositoryId !== identity.repositoryId || + existingEpoch.workspaceInstanceId !== identity.workspaceInstanceId || + existingEpoch.sourceCommitOid !== source.headCommitOid || + existingEpoch.sourceTreeOid !== source.headTreeOid || + existingEpoch.objectFormat !== 'sha1' || + existingEpoch.materializationProfileDigest !== materializationProfileDigest || + existingEpoch.policyHash !== policyHash || + !existingVersion || + existingVersion.repositoryId !== identity.repositoryId || + existingVersion.workspaceId !== identity.workspaceId || + existingVersion.workspaceEpochId !== identity.workspaceEpochId || + existingVersion.commitOid !== existingHead.commitOid || + existingVersion.treeOid !== existingHead.treeOid || + existingVersion.policyHash !== policyHash + ) { + throw new Error('Gitoxide managed session source or durable epoch has drifted'); + } + } else { + const acceptedRepositoryOwnerToken = {}; + const imported = await importAdmittedGitoxideRepositoryInternal({ + admissionOwnerToken, + repositoryCapability: sourceAdmission.capability, + acceptedRepositoryOwnerToken, + destinationRepositoryPath: repositoryPath, + ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), + }); + const baseline: WorkspaceBaselineAuthorityInput = Object.freeze({ + epochOpenedEventId: `workspace-epoch-${identity.digest}`, + baselineAcceptedEventId: `workspace-baseline-${identity.digest}`, + committedAt: 0, + epoch: Object.freeze({ + repositoryId: identity.repositoryId, + workspaceId: identity.workspaceId, + workspaceEpochId: identity.workspaceEpochId, + workspaceInstanceId: identity.workspaceInstanceId, + mode: 'managed_worktree' as const, + objectFormat: 'sha1' as const, + sourceCommitOid: imported.sourceHeadCommitOid, + sourceTreeOid: imported.sourceTreeOid, + materializationProfileDigest, + materializationSemantics: WORKSPACE_MATERIALIZATION_SEMANTICS_V1, + policyHash, + }), + baseline: Object.freeze({ + workspaceVersionId: identity.workspaceVersionId, + commitOid: imported.baselineCommitOid, + treeOid: imported.baselineTreeOid, + treeDeltaDigest: sha256(`maka-gitoxide-baseline-tree-v1\0${imported.baselineTreeOid}\0`), + changedFileCount: imported.filesImported, + deletedFileCount: 0 as const, + }), + }); + verifiedBaselines.set(imported, baseline); + await input.failpoint?.('after_repository_import'); + await baselineAuthority.commitBaseline(imported); + } + + const writeEdit = createGitoxideManagedWriteEditOwnerInternal({ + storageRootLease: input.storageRootLease, + stores: input.stores, + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + repositoryPath, + workspaceId: identity.workspaceId, + workspaceEpochId: identity.workspaceEpochId, + }); + await writeEdit.reconcileAcceptedProjection(input.abortSignal); + return Object.freeze({ + repositoryPath, + repositoryId: identity.repositoryId, + workspaceId: identity.workspaceId, + workspaceEpochId: identity.workspaceEpochId, + writeEdit, + }); +} + +function deriveManagedSessionIdentity(sourceRoot: string, sessionId: string) { + if (!sessionId.trim()) throw new Error('Gitoxide managed session identity is invalid'); + const digest = createHash('sha256') + .update('maka-gitoxide-managed-session-v1\0', 'utf8') + .update(sourceRoot, 'utf8') + .update('\0') + .update(sessionId, 'utf8') + .digest('hex') + .slice(0, 32); + return Object.freeze({ + digest, + repositoryId: `repository_${digest}`, + workspaceId: `workspace_${digest}`, + workspaceEpochId: `epoch_${digest}`, + workspaceInstanceId: `instance_${digest}`, + workspaceVersionId: `version_${digest}`, + }); +} + +function sha256(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; +} 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 5c497f2e409..1e6593ca2ce 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,14 @@ const HEADLESS_CODING_V1_TOOL_NAMES = [ 'apply_patch', ] as const; +const MANAGED_CODING_V1_TOOL_NAMES = ['Write', 'Edit'] as const; +const MANAGED_CODING_V1_SYSTEM_PROMPT = [ + 'Modify the managed Git workspace with Write and Edit.', + 'These tools transform immutable accepted Git content and publish an owner-verified successor.', + 'No shell, attached-workspace read, or unmanaged filesystem authority is available in this profile.', + 'Stop when the requested changes are complete.', +].join('\n'); + 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.', @@ -73,6 +81,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, + }; + } if (profile === 'workhub-coordination-v1') { return { toolNames: [], @@ -96,13 +111,20 @@ export function projectHostedExecutionTools( if (missing.length > 0) { throw new Error(`Hosted tool profile is unavailable: ${missing.join(', ')}`); } - return (selected as MakaTool[]).map((tool) => - tool.name === 'Bash' + return (selected as MakaTool[]).map((tool) => { + if (profile === 'managed-coding-v1') { + return { + ...tool, + recoveryMode: 'reconcile', + durableExecutionProfile: 'managed_mutation_v1', + }; + } + return tool.name === 'Bash' ? { ...tool, description: HEADLESS_CODING_V1_BASH_DESCRIPTION, parameters: HEADLESS_CODING_V1_BASH_PARAMETERS, } - : tool, - ); + : tool; + }); } 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 00000000000..3d85028a540 --- /dev/null +++ b/packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts @@ -0,0 +1,209 @@ +/* + * 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, + GITOXIDE_HELPER_OPERATIONS_INTERNAL, + GitoxideHelperArtifactAuthorityError, + issueGitoxideHelperReleaseArtifactClaimInternal, + type GitoxideHelperInvocationCapability, + type GitoxideHelperOperationInternal, +} from './gitoxide-helper-artifact-authority-internal.js'; + +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' }); +const MANIFEST_KEYS = [ + 'arch', + 'bytes', + 'distributionReady', + 'executableRelativePath', + 'platform', + 'protocol', + 'protocolVersion', + 'provider', + 'schemaVersion', + 'sha256', + 'supportedOperations', +] as const; + +export type PackagedGitoxideHelperErrorCode = + | 'packaged_gitoxide_helper_unavailable' + | 'packaged_gitoxide_helper_manifest_invalid' + | 'packaged_gitoxide_helper_platform_mismatch' + | 'packaged_gitoxide_helper_integrity_mismatch'; + +export class PackagedGitoxideHelperError extends Error { + constructor( + readonly code: PackagedGitoxideHelperErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'PackagedGitoxideHelperError'; + } +} + +export async function resolvePackagedGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly resourcesRoot?: string; +}): Promise { + try { + const resourcesRoot = normalize( + await realpath(input.resourcesRoot ?? requirePackagedProcessResourcesRoot()), + ); + const manifestPath = normalize(await realpath(join(resourcesRoot, 'gitoxide-helper.json'))); + assertWithinRoot(resourcesRoot, manifestPath, 'Gitoxide helper manifest'); + const manifestInfo = await lstat(manifestPath); + if ( + !manifestInfo.isFile() || + manifestInfo.isSymbolicLink() || + manifestInfo.size > MAX_MANIFEST_BYTES + ) { + throw invalidManifest('Gitoxide helper manifest must be a bounded regular file'); + } + const manifest = decodeManifest(parseManifest(await readFile(manifestPath, 'utf8'))); + if (manifest.platform !== process.platform || manifest.arch !== process.arch) { + throw new PackagedGitoxideHelperError( + 'packaged_gitoxide_helper_platform_mismatch', + `Packaged Gitoxide helper targets ${manifest.platform}/${manifest.arch}, not ${process.platform}/${process.arch}`, + ); + } + const executablePath = normalize( + await realpath(join(resourcesRoot, ...manifest.executableRelativePath.split('/'))), + ); + assertWithinRoot(resourcesRoot, executablePath, 'Gitoxide helper executable'); + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(packagedReleaseOwnerToken, { + executablePath, + expectedSha256: manifest.sha256, + expectedBytes: manifest.bytes, + platform: manifest.platform, + arch: manifest.arch, + protocolVersion: manifest.protocolVersion, + supportedOperations: manifest.supportedOperations, + }); + return await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken: packagedReleaseOwnerToken, + invocationOwnerToken: input.invocationOwnerToken, + claim, + }); + } catch (error) { + if (error instanceof PackagedGitoxideHelperError) throw error; + if (error instanceof GitoxideHelperArtifactAuthorityError) { + throw new PackagedGitoxideHelperError( + error.code === 'gitoxide_helper_artifact_identity_mismatch' + ? 'packaged_gitoxide_helper_integrity_mismatch' + : 'packaged_gitoxide_helper_unavailable', + 'Packaged Gitoxide helper failed release admission', + { cause: error }, + ); + } + throw new PackagedGitoxideHelperError( + 'packaged_gitoxide_helper_unavailable', + 'Packaged Gitoxide helper is unavailable', + { cause: error }, + ); + } +} + +function requirePackagedProcessResourcesRoot(): string { + const value = (process as NodeJS.Process & { readonly resourcesPath?: unknown }).resourcesPath; + if (typeof value !== 'string' || value.length === 0 || !isAbsolute(value)) { + throw new PackagedGitoxideHelperError( + 'packaged_gitoxide_helper_unavailable', + 'Packaged process resources root is unavailable', + ); + } + return value; +} + +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 supportedOperations: readonly GitoxideHelperOperationInternal[]; + 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' || + !['win32', 'darwin', 'linux'].includes(value.platform as string) || + 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) || + !isExactOperationList(value.supportedOperations) || + value.distributionReady !== true + ) { + throw invalidManifest('Gitoxide helper manifest is invalid'); + } + return value as unknown as PackagedGitoxideHelperManifestV1; +} + +function isExactOperationList(value: unknown): value is readonly GitoxideHelperOperationInternal[] { + return ( + Array.isArray(value) && + value.length === GITOXIDE_HELPER_OPERATIONS_INTERNAL.length && + value.every((operation, index) => operation === GITOXIDE_HELPER_OPERATIONS_INTERNAL[index]) + ); +} + +function parseManifest(value: string): unknown { + try { + return JSON.parse(value); + } catch (error) { + throw invalidManifest(`Gitoxide helper manifest is not valid JSON: ${String(error)}`); + } +} + +function assertWithinRoot(root: string, target: string, label: string): void { + const rel = relative(root, target); + if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) return; + throw invalidManifest(`${label} escapes the packaged resources root`); +} + +function invalidManifest(message: string): PackagedGitoxideHelperError { + return new PackagedGitoxideHelperError('packaged_gitoxide_helper_manifest_invalid', message); +} diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index f298b8c3b7f..337c4e8aec9 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -760,6 +760,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { recordToolInvocation?: ToolTelemetryRecorder; /** Optional Phase 2 SQLite T1/T2 boundary for real tool execution. */ runtimeCommitSink?: RuntimeCommitSink; + /** Owner-issued admission for managed Write/Edit execution. */ + admitManagedMutation?: ToolRuntimeInput['admitManagedMutation']; /** Durable session-lifetime cumulative usage checkpoint after each completed provider step. */ recordUsageCheckpoint?: ( usage: NormalizedAiSdkUsage & { costUsd?: number }, @@ -1349,6 +1351,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/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts b/packages/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts index 795c42fb60f..7595a9f4bc5 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 @@ -25,7 +25,9 @@ import test from 'node:test'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { openInteractiveExecutionStoresForWrite } from '../execution-stores.js'; import { + issueExecutionStoresWorkspaceBaselineAuthorityInternal, issueExecutionStoresWorkspaceMutationAuthorityInternal, + requireExecutionStoresWorkspaceBaselineAuthorityInternal, requireExecutionStoresWorkspaceMutationAuthorityInternal, } from '../execution-stores-workspace-authority-internal.js'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; @@ -232,3 +234,35 @@ test('rejects a no-effect proof issued by another execution store', async () => ]); } }); + +test('keeps baseline descriptors behind an owner-bound imported-repository proof', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-execution-workspace-baseline-authority-')); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + if (!rootOwner) return; + const stores = await openInteractiveExecutionStoresForWrite(rootOwner.lease); + try { + const ownerToken = {}; + const baselineCapability = issueExecutionStoresWorkspaceBaselineAuthorityInternal({ + ownerToken, + stores, + verifyBaseline: () => { + throw new Error('unknown imported repository proof'); + }, + }); + assert.throws( + () => requireExecutionStoresWorkspaceBaselineAuthorityInternal({}, baselineCapability), + /capability is invalid/i, + ); + const authority = requireExecutionStoresWorkspaceBaselineAuthorityInternal( + ownerToken, + baselineCapability, + ); + await assert.rejects(authority.commitBaseline({}), /unknown imported repository proof/i); + } finally { + await stores.sessionStore.close?.(); + await rootOwner.close(); + await rm(root, { 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 index 97f0bae6f8e..ba4f55b7aad 100644 --- a/packages/storage/src/execution-stores-workspace-authority-internal.ts +++ b/packages/storage/src/execution-stores-workspace-authority-internal.ts @@ -49,6 +49,23 @@ export interface ExecutionStoresWorkspaceMutationAuthorityCapabilityInternal { readonly kind: 'execution_stores_workspace_mutation_authority_v1'; } +export interface ExecutionStoresWorkspaceBaselineAuthorityCapabilityInternal { + readonly kind: 'execution_stores_workspace_baseline_authority_v1'; +} + +export interface ExecutionStoresWorkspaceBaselineAuthorityInternal { + commitBaseline(importedRepositoryProof: object): Promise; + readEpoch( + workspaceId: string, + workspaceEpochId: string, + ): Promise; + readHead( + workspaceId: string, + workspaceEpochId: string, + ): Promise; + readVersion(workspaceVersionId: string): Promise; +} + export interface WorkspaceSuccessorProjectionCapabilityInternal { readonly kind: 'workspace_successor_projection_capability_v1'; } @@ -96,9 +113,15 @@ interface NoEffectCapabilityRecord extends AuthoritySource { readonly claim: ManagedMutationNoEffectClaimV1; } +interface BaselineAuthorityCapabilityRecord extends AuthoritySource { + readonly ownerToken: object; + readonly verifyBaseline: (importedRepositoryProof: object) => WorkspaceBaselineAuthorityInput; +} + const sources = new WeakMap(); const capabilities = new WeakMap(); const noEffectCapabilities = new WeakMap(); +const baselineCapabilities = new WeakMap(); const projectionCapabilities = new WeakMap< object, { @@ -160,6 +183,49 @@ export function issueExecutionStoresWorkspaceMutationAuthorityInternal(input: { return capability; } +export function issueExecutionStoresWorkspaceBaselineAuthorityInternal(input: { + readonly ownerToken: object; + readonly stores: object; + readonly verifyBaseline: (importedRepositoryProof: object) => WorkspaceBaselineAuthorityInput; +}): ExecutionStoresWorkspaceBaselineAuthorityCapabilityInternal { + const source = sources.get(input.stores); + if (!source) throw new Error('Execution stores workspace baseline source is unavailable'); + adoptWorkspaceBaselineAuthorityStoreRootInternal(source.store, source.rootId); + const capability = Object.freeze({ + kind: 'execution_stores_workspace_baseline_authority_v1' as const, + }); + baselineCapabilities.set( + capability, + Object.freeze({ + ownerToken: input.ownerToken, + store: source.store, + rootId: source.rootId, + verifyBaseline: input.verifyBaseline, + }), + ); + return capability; +} + +export function requireExecutionStoresWorkspaceBaselineAuthorityInternal( + ownerToken: object, + capability: ExecutionStoresWorkspaceBaselineAuthorityCapabilityInternal, +): ExecutionStoresWorkspaceBaselineAuthorityInternal { + const record = baselineCapabilities.get(capability); + if (!record || record.ownerToken !== ownerToken) { + throw new Error('Execution stores workspace baseline authority capability is invalid'); + } + return Object.freeze({ + commitBaseline: async (importedRepositoryProof: object) => + commitWorkspaceBaselineInternal(record.store, record.verifyBaseline(importedRepositoryProof)), + readEpoch: (workspaceId: string, workspaceEpochId: string) => + readWorkspaceEpochInternal(record.store, workspaceId, workspaceEpochId), + readHead: (workspaceId: string, workspaceEpochId: string) => + readWorkspaceHeadInternal(record.store, workspaceId, workspaceEpochId), + readVersion: (workspaceVersionId: string) => + readWorkspaceVersionInternal(record.store, workspaceVersionId), + }); +} + export function requireExecutionStoresWorkspaceMutationAuthorityInternal( ownerToken: object, capability: ExecutionStoresWorkspaceMutationAuthorityCapabilityInternal, diff --git a/scripts/generate-gitoxide-helper-notices.mjs b/scripts/generate-gitoxide-helper-notices.mjs new file mode 100644 index 00000000000..3507850c238 --- /dev/null +++ b/scripts/generate-gitoxide-helper-notices.mjs @@ -0,0 +1,133 @@ +/* + * 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 { readdirSync, readFileSync, mkdirSync, 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', maxBuffer: 64 * 1024 * 1024 }, + ), +); +const rootPackage = metadata.packages.find((pkg) => pkg.name === 'maka-gitoxide-helper'); +if (!rootPackage || !metadata.resolve) throw new Error('Gitoxide Cargo metadata is incomplete'); +const nodes = new Map(metadata.resolve.nodes.map((node) => [node.id, node])); +const packageById = new Map(metadata.packages.map((pkg) => [pkg.id, pkg])); +const productionIds = new Set(); +const pending = [rootPackage.id]; +while (pending.length > 0) { + const id = pending.pop(); + if (productionIds.has(id)) continue; + productionIds.add(id); + const node = nodes.get(id); + if (!node) throw new Error(`Missing Cargo resolve node: ${id}`); + for (const dep of node.deps) { + if (dep.dep_kinds.some(({ kind }) => kind !== 'dev')) pending.push(dep.pkg); + } +} + +const sections = [...productionIds] + .filter((id) => id !== rootPackage.id) + .map((id) => packageById.get(id)) + .sort((left, right) => + `${left.name}@${left.version}`.localeCompare(`${right.name}@${right.version}`, 'en'), + ) + .map((pkg) => { + if (!pkg?.license) throw new Error(`${pkg?.name ?? 'unknown'}: missing SPDX license metadata`); + const directory = dirname(pkg.manifest_path); + const licenseFiles = readdirSync(directory, { withFileTypes: true }) + .filter( + (entry) => entry.isFile() && /^(licen[cs]e|copying|notice)(?:[._-].*)?$/iu.test(entry.name), + ) + .map((entry) => entry.name) + .sort(); + const source = pkg.repository ?? pkg.homepage ?? pkg.source ?? 'unknown'; + const notices = + licenseFiles.length > 0 + ? licenseFiles.map((name) => ({ name, text: readFileSync(join(directory, name), 'utf8') })) + : [declaredLicenseFallback(pkg, source)]; + return [ + `${pkg.name}@${pkg.version}`, + `SPDX license: ${pkg.license}`, + `Source: ${source}`, + ...notices.flatMap(({ name, text }) => [ + '', + `----- ${name} -----`, + text.replace(/\r\n?/gu, '\n').trimEnd(), + ]), + ].join('\n'); + }); + +const output = `Maka Gitoxide helper Cargo dependency notices +================================================ + +Generated from the exact locked production dependency graph. +Manifest: ${relative(repoRoot, manifestPath).replaceAll('\\', '/')} +Cargo.lock SHA-256: ${createHash('sha256').update(readFileSync(lockPath)).digest('hex')} + +${sections.join('\n\n================================================\n\n')} +`; +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, output); +console.log(`[gitoxide-helper-notices] wrote ${outputPath}`); + +function declaredLicenseFallback(pkg, source) { + if (/Apache-2\.0/u.test(pkg.license)) { + return { name: 'Apache-2.0.txt', text: readFileSync(join(repoRoot, 'LICENSE'), 'utf8') }; + } + if (/MIT/u.test(pkg.license)) { + return { + name: 'MIT.txt', + text: `Copyright notice: not included in the published crate +Cargo authors: ${pkg.authors.length > 0 ? pkg.authors.join(', ') : 'not included'} +Source: ${source} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.`, + }; + } + throw new Error(`${pkg.name}@${pkg.version}: packaged crate has no license text`); +} diff --git a/scripts/package-macos-arm64.mjs b/scripts/package-macos-arm64.mjs index 47df4b0b5bc..5d3352e0a23 100644 --- a/scripts/package-macos-arm64.mjs +++ b/scripts/package-macos-arm64.mjs @@ -94,6 +94,9 @@ export async function packageMacosArm64({ await run('npm', ['run', 'clean']); await run('npm', ['run', 'build']); await run('npm', ['run', 'build:runtime-host-peer']); + await run('npm', ['run', 'build:gitoxide-helper']); + await run('npm', ['run', 'prepare:gitoxide-helper']); + await run('npm', ['run', 'generate:gitoxide-helper-notices']); await run('npm', ['run', 'check:runtime-host-peer-notices']); await run('npm', ['run', 'check:release']); await remove(releaseDirectory, { recursive: true, force: true }); diff --git a/scripts/package-windows-x64.mjs b/scripts/package-windows-x64.mjs index 6ad99bf0c39..daf53b6b68a 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', 'clean']); await run('npm', ['run', 'build']); await run('npm', ['run', 'build:runtime-host-peer']); + await run('npm', ['run', 'build:gitoxide-helper']); + await run('npm', ['run', 'prepare:gitoxide-helper']); + await run('npm', ['run', 'generate:gitoxide-helper-notices']); await run('npm', ['run', 'check:runtime-host-peer-notices']); await run('cargo', ['build', '--manifest-path', sandboxManifestPath, '--release', '--locked']); await run('npm', ['run', 'check:windows-cargo-notices']); diff --git a/scripts/prepare-gitoxide-helper.mjs b/scripts/prepare-gitoxide-helper.mjs new file mode 100644 index 00000000000..256695a8b7f --- /dev/null +++ b/scripts/prepare-gitoxide-helper.mjs @@ -0,0 +1,122 @@ +/* + * 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 const GITOXIDE_HELPER_RELEASE_OPERATIONS = Object.freeze([ + 'inspect_repository', + 'import_source_head', + 'create_candidate', + 'promote_candidate', + 'observe_accepted_ref', + 'read_tree_file', +]); + +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); + await mkdir(outputRoot, { recursive: true }); + await writeFile( + manifestTempPath, + `${JSON.stringify( + { + schemaVersion: 1, + protocol: 'maka_gitoxide_helper_release_v1', + provider: 'maka/gitoxide-helper', + platform, + arch, + protocolVersion: 1, + executableRelativePath: `gitoxide/${executableName}`, + bytes: copiedInfo.size, + sha256, + supportedOperations: GITOXIDE_HELPER_RELEASE_OPERATIONS, + distributionReady: true, + }, + 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 executableName = + process.platform === 'win32' ? 'maka-gitoxide-helper.exe' : 'maka-gitoxide-helper'; + const result = await prepareGitoxideHelper({ + sourceExecutablePath: join( + repoRoot, + 'native', + 'gitoxide-helper', + 'target', + 'release', + executableName, + ), + outputRoot: join(repoRoot, 'apps', 'desktop', '.generated', 'gitoxide-helper'), + platform: process.platform, + arch: process.arch, + }); + const manifest = JSON.parse(await readFile(result.manifestPath, 'utf8')); + console.log( + `[gitoxide-helper] prepared ${manifest.executableRelativePath} (${manifest.bytes} bytes)`, + ); +} diff --git a/scripts/prepare-gitoxide-helper.test.mjs b/scripts/prepare-gitoxide-helper.test.mjs new file mode 100644 index 00000000000..561da3ced20 --- /dev/null +++ b/scripts/prepare-gitoxide-helper.test.mjs @@ -0,0 +1,65 @@ +/* + * 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, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + GITOXIDE_HELPER_RELEASE_OPERATIONS, + prepareGitoxideHelper, +} from './prepare-gitoxide-helper.mjs'; + +test('prepares an exact helper and strict release manifest', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-gitoxide-package-')); + try { + const source = join(root, process.platform === 'win32' ? 'helper.exe' : 'helper'); + await writeFile(source, 'exact-helper-bytes'); + const result = await prepareGitoxideHelper({ + sourceExecutablePath: source, + outputRoot: join(root, 'resources'), + platform: process.platform, + arch: process.arch, + }); + assert.equal(await readFile(result.executablePath, 'utf8'), 'exact-helper-bytes'); + const manifest = JSON.parse(await readFile(result.manifestPath, 'utf8')); + assert.equal(manifest.sha256, result.sha256); + assert.deepEqual(manifest.supportedOperations, GITOXIDE_HELPER_RELEASE_OPERATIONS); + assert.equal(manifest.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 04023366bc6..e2a860c38c2 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -208,7 +208,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( @@ -221,6 +223,28 @@ test('Desktop packaging does not distribute the retired bundled Git runtime', () ); }); +test('Desktop packaging includes only the admitted Gitoxide helper artifact', () => { + const resources = desktopBuilderConfig.extraResources.map(({ from, to }) => ({ from, to })); + assert.ok( + resources.some( + ({ from, to }) => from === '.generated/gitoxide-helper/gitoxide' && to === 'gitoxide', + ), + ); + assert.ok( + resources.some( + ({ from, to }) => + from === '.generated/gitoxide-helper/gitoxide-helper.json' && to === 'gitoxide-helper.json', + ), + ); + assert.ok( + resources.some( + ({ from, to }) => + from === '.generated/gitoxide-helper/THIRD_PARTY_NOTICES.txt' && + to === 'licenses/gitoxide-helper/THIRD_PARTY_NOTICES.txt', + ), + ); +}); + test('macOS DMG ships correctly sized background assets', async () => { const backgroundDirectory = join(repoRoot, 'apps', 'desktop', 'build'); const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index bdb84e538b2..732f29c0e99 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -1007,6 +1007,9 @@ export async function assertPackagedResources( // require the bundled distribution and its compliance files; keep that // baseline explicit instead of judging old bytes by today's absence rule. bundledGitContract = 'forbidden', + // Current releases replace the retired Git distribution with one bounded, + // short-lived Gitoxide helper. Historical upgrade baselines predate it. + requireGitoxideHelper = bundledGitContract === 'forbidden', // The upgrade-lifecycle check runs this against a previously released // build, which predates the disclaimer being packaged. Requiring it there // would fail a release that was correct when it shipped. @@ -1043,6 +1046,16 @@ export async function assertPackagedResources( join('licenses', 'git', 'NOTICE.txt'), ] : []), + ...(requireGitoxideHelper + ? [ + join( + 'gitoxide', + process.platform === 'win32' ? 'maka-gitoxide-helper.exe' : 'maka-gitoxide-helper', + ), + 'gitoxide-helper.json', + join('licenses', 'gitoxide-helper', 'THIRD_PARTY_NOTICES.txt'), + ] + : []), ...(requireCanonicalIcon ? [join('assets', 'icon.png')] : []), join('workers', 'filesystem-worker.js'), ...(requireDirectPeerArtifact