Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/gitoxide-helper-admission.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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*'
Expand All @@ -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*'
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<!--
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.
-->

# 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。
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtemp, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { selectCollaborationTools } from '@maka/runtime/plan-mode';
import type { MakaToolContext } from '@maka/runtime/tool-runtime';
import type {
AcquireManagedDependencyEnvironmentInput,
ManagedDependencyEnvironmentAuthority,
ManagedDependencyEnvironmentIdentityV1,
} from '@maka/storage/managed-dependency-environment';
import type { ManagedWorkspaceFilesystemWorker } from '@maka/storage/managed-workspace-owner';
import {
admitGitoxideHelperArtifactInternal,
issueGitoxideHelperReleaseArtifactClaimInternal,
type GitoxideHelperInvocationCapability,
} from '../server/gitoxide-helper-artifact-authority-internal.js';
import { createGitoxideManagedInspectionComposition } from '../server/gitoxide-managed-inspection.js';

const fakeNpmRuntime = Object.freeze({
npmVersion: '12.0.2' as const,
nodeVersion: '24.15.0',
nodeAbi: '137',
platform: process.platform,
arch: process.arch,
resourcesRoot: join(tmpdir(), 'not-used-packaged-resources'),
nodeExecutablePath: process.execPath,
npmRuntimeRoot: join(tmpdir(), 'not-used-npm-runtime'),
npmCliPath: join(tmpdir(), 'not-used-npm-cli.js'),
runtimeIdentitySha256: `sha256:${'1'.repeat(64)}` as const,
});

test('keeps provisioning-backed managed inspection out of read-only Plan Mode', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-gitoxide-inspection-plan-'));
t.after(() => rm(root, { recursive: true, force: true }));
const composition = await createGitoxideManagedInspectionComposition({
storageRoot: root,
invocationOwnerToken: {},
helperCapability: Object.freeze({
kind: 'gitoxide_helper_invocation_capability_v1' as const,
}),
npmRuntime: fakeNpmRuntime,
dependencyAuthority: inertDependencyAuthority(),
filesystemWorker: rejectingFilesystemWorker(),
});
t.after(() => composition.close());

assert.equal(composition.tool.categoryHint, 'custom_tool');
assert.equal(composition.tool.recoveryMode, 'never_auto_retry');
assert.deepEqual(
selectCollaborationTools({
mode: 'plan',
tools: [composition.tool],
hasActiveExecution: false,
}),
[],
);
await assert.rejects(
Promise.resolve(
composition.tool.impl(
{ kind: 'read', path: 'foo/../node_modules/escape.js' },
toolContext(root),
),
),
/dot-dot/u,
);
});

test('reads source and dependency files through the real Gitoxide product data plane', async (t) => {
const admittedHelper = await admitRealHelper();
if (!admittedHelper) {
t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the product composition test');
return;
}
const root = await mkdtemp(join(tmpdir(), 'maka-gitoxide-inspection-product-'));
t.after(() => rm(root, { recursive: true, force: true }));
const sourceRoot = join(root, 'source');
const dependencyRoot = join(root, 'leased', 'node_modules');
await Promise.all([
mkdir(join(sourceRoot, 'src'), { recursive: true }),
mkdir(join(dependencyRoot, 'fixture-package'), { recursive: true }),
]);
const manifestText = '{"name":"fixture","version":"1.0.0"}\n';
const lockfileText = '{"name":"fixture","version":"1.0.0","lockfileVersion":3,"packages":{}}\n';
await Promise.all([
writeFile(join(sourceRoot, 'package.json'), manifestText),
writeFile(join(sourceRoot, 'package-lock.json'), lockfileText),
writeFile(join(sourceRoot, 'src', 'index.ts'), 'export const answer = 42;\n'),
writeFile(
join(dependencyRoot, 'fixture-package', 'package.json'),
'{"name":"fixture-package"}\n',
),
]);
git(sourceRoot, ['init', '--quiet']);
git(sourceRoot, ['add', '.']);
git(sourceRoot, [
'-c',
'user.name=Maka Test',
'-c',
'user.email=maka@example.invalid',
'commit',
'--quiet',
'-m',
'fixture',
]);

const identities: ManagedDependencyEnvironmentIdentityV1[] = [];
let closed = false;
const dependencyAuthority: ManagedDependencyEnvironmentAuthority = Object.freeze({
async acquire(
identity: ManagedDependencyEnvironmentIdentityV1,
source: AcquireManagedDependencyEnvironmentInput,
) {
identities.push(identity);
assert.equal(Buffer.from(source.manifestBytes).toString('utf8'), manifestText);
assert.equal(Buffer.from(source.lockfileBytes).toString('utf8'), lockfileText);
return Object.freeze({
environmentId: identity.environmentId,
dependencyRoot,
async release() {},
});
},
async close() {
closed = true;
},
});
const seenCwds: string[] = [];
const filesystemWorker: ManagedWorkspaceFilesystemWorker = {
async execute(input) {
seenCwds.push(input.cwd);
assert.equal(input.operation.kind, 'read');
if (input.operation.kind !== 'read') throw new Error('unexpected operation');
return {
kind: 'read',
content: await readFile(join(input.cwd, input.operation.path), 'utf8'),
};
},
};
const composition = await createGitoxideManagedInspectionComposition({
storageRoot: root,
invocationOwnerToken: admittedHelper.invocationOwnerToken,
helperCapability: admittedHelper.helperCapability,
npmRuntime: fakeNpmRuntime,
dependencyAuthority,
filesystemWorker,
});

const source = await composition.tool.impl(
{ kind: 'read', path: 'src/index.ts' },
toolContext(sourceRoot),
);
assert.deepEqual(source.result, { kind: 'read', content: 'export const answer = 42;\n' });
assert.equal(source.dependencyEnvironmentId, undefined);
assert.equal(identities.length, 0);
assert.equal(seenCwds.length, 0);

const dependency = await composition.tool.impl(
{ kind: 'read', path: 'node_modules/fixture-package/package.json' },
toolContext(sourceRoot),
);
assert.deepEqual(dependency.result, {
kind: 'read',
content: '{"name":"fixture-package"}\n',
});
assert.equal(identities.length, 1);
assert.equal(seenCwds[0], dependencyRoot);
await composition.close();
assert.equal(closed, true);
});

function inertDependencyAuthority(): ManagedDependencyEnvironmentAuthority {
return Object.freeze({
async acquire() {
throw new Error('not used');
},
async close() {},
});
}

function rejectingFilesystemWorker(): ManagedWorkspaceFilesystemWorker {
return {
async execute() {
throw new Error('not used');
},
};
}

function toolContext(cwd: string): MakaToolContext {
return {
sessionId: 'session-managed-inspection',
turnId: 'turn-managed-inspection',
toolCallId: 'tool-managed-inspection',
cwd,
abortSignal: new AbortController().signal,
} as MakaToolContext;
}

interface AdmittedHelper {
readonly invocationOwnerToken: object;
readonly helperCapability: GitoxideHelperInvocationCapability;
}

async function admitRealHelper(): Promise<AdmittedHelper | undefined> {
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();
}
Loading
Loading