diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index a41fa6a7ec..a0dde6bd87 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -24,6 +24,8 @@ on: - 'native/gitoxide-helper/**' - 'packages/runtime-host/src/server/gitoxide-helper-*.ts' - 'packages/runtime-host/src/__tests__/gitoxide-helper-*.test.ts' + - 'packages/runtime-host/src/server/gitoxide-managed-inspection.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts' - 'packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts' - 'packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts' - 'scripts/prepare-gitoxide-helper*' @@ -38,6 +40,8 @@ on: - 'native/gitoxide-helper/**' - 'packages/runtime-host/src/server/gitoxide-helper-*.ts' - 'packages/runtime-host/src/__tests__/gitoxide-helper-*.test.ts' + - 'packages/runtime-host/src/server/gitoxide-managed-inspection.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts' - 'packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts' - 'packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts' - 'scripts/prepare-gitoxide-helper*' @@ -98,3 +102,4 @@ jobs: packages/runtime-host/dist/__tests__/gitoxide-helper-artifact-authority-internal.test.js packages/runtime-host/dist/__tests__/gitoxide-helper-invocation-internal.test.js packages/runtime-host/dist/__tests__/gitoxide-repository-admission-authority-internal.test.js + packages/runtime-host/dist/__tests__/gitoxide-managed-inspection.test.js diff --git a/docs/architecture/gitoxide-managed-inspection-product-composition-v1.zh-CN.md b/docs/architecture/gitoxide-managed-inspection-product-composition-v1.zh-CN.md new file mode 100644 index 0000000000..4a412683f6 --- /dev/null +++ b/docs/architecture/gitoxide-managed-inspection-product-composition-v1.zh-CN.md @@ -0,0 +1,88 @@ + + +# Gitoxide managed inspection 产品组合 v1 + +状态:M1.3 integration Draft。前置数据面合并后从最新 `main` 重建最终 PR。 + +## 主要不变量 + +> `ManagedWorkspaceInspect` 的源码视图只能来自 exact accepted Git tree 的 fresh projection;依赖视图只能来自同一 accepted tree 中 `package.json`、`package-lock.json` 所确定的 attested npm environment lease。任何一步不可用时都必须在工具调用发布结果前失败,禁止回退 attached checkout、系统 Git、`PATH` Git 或 source checkout 的 `node_modules`。 + +Owner 划分: + +- packaged release owner:Gitoxide helper 与 npm runtime artifact; +- Gitoxide admission/import owner:source HEAD 与 immutable managed tree; +- dependency authority:manifest/lockfile identity、publication receipt 与 active lease; +- filesystem worker:单次只读 `Read/Glob`; +- Runtime Host composition:工具可见性、操作路由、取消、drain 与最终清理。 + +## 调用顺序 + +```text +canonical source cwd + -> Gitoxide admission(冻结 source HEAD/tree) + -> fresh Maka-owned bare import + -> exact tree read: package.json + package-lock.json + -> dependency identity + attested npm lease + -> fresh accepted-tree projection + -> Read/Glob routed to projection or leased node_modules + -> projection re-observation + -> bounded provider result + -> release lease + remove ephemeral import/projection +``` + +`node_modules` 路由发生在完整 canonical path 校验之后。含空段、`.`、`..`、反斜杠、盘符或绝对路径的输入在任何 Git/npm 副作用前拒绝;Windows 的 `NODE_MODULES` 等大小写别名同样进入 dependency lease,不能落回 projection。 + +## 原子边界、失败与回滚 + +本切片不声称跨 Git/npm/filesystem 的单一事务,也不写 managed mutation T1。每次调用使用 fresh 随机 import/projection root;只有 dependency authority receipt 是可复用 durable artifact。 + +| 状态 | 处理 | +| --- | --- | +| helper/npm/worker 缺失 | 工具不进入 Host tool surface;普通 Host 功能继续可用 | +| source HEAD 在 admission/import 间变化 | import 前 fail closed | +| package manifest/lockfile 缺失或不合法 | 不启动 read worker,不回退 source bytes | +| provisioning/worker 取消 | signal 贯穿 helper、npm authority 与 worker | +| projection drift | 丢弃结果;不向 provider 发布 | +| 进程崩溃 | 不自动 replay;下一次显式调用使用 fresh root,旧 staging 等待后续 GC | + +工具的 recovery mode 固定为 `never_auto_retry`。这是有意的产品边界:M1.3 证明可用的隔离读取,不冒充 M2 尚未完成的 durable Write/Edit 或 crash replay。 + +## 权限与产品入口 + +- 工具类别为 `custom_tool`,因此不会进入默认只读 Plan Mode; +- 工具可能联网下载依赖并写最多由 dependency authority 限制的 cache; +- 只有 Electron packaged Runtime Host 同时解析到严格 Gitoxide/npm manifest 和 sandboxed filesystem worker 时才暴露工具; +- CLI、开发态 Electron、缺少资源或完整性校验失败时都不会发现系统 Git 或静默降级。 + +外层已签名应用包是 v1 release trust root;本合同不抵抗能够改写整个已安装应用及其 manifest 的同用户恶意进程。 + +## 平台矩阵 + +Linux、macOS、Windows 使用相同 helper 协议、tree read、dependency identity 与路由规则。三平台 Gitoxide CI 执行真实 helper 的产品组合测试。Windows sandbox 目前只承诺 `Read/Glob`;本工具 v1 不暴露 Grep。 + +## 后续 M2 + +数据面和本产品入口稳定后: + +1. 从最新 `main` 刷新 M2.1 accepted-head SQLite authority; +2. 从最新 `main` 刷新 M2.3 durable reservation 与 Runtime settlement; +3. 再用 Gitoxide successor/ref CAS 重建 M2.2 candidate owner; +4. 最后重建 M2.4 Write/Edit consumer。M2.4 不得恢复 Git CLI worktree rotation,也不得把 M1.3 的 ephemeral staging 当 canonical truth。 diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts new file mode 100644 index 0000000000..dff6a022fb --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts @@ -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 { + const executablePath = process.env.MAKA_GITOXIDE_HELPER_PATH; + if (!executablePath) return undefined; + const canonicalPath = await realpath(executablePath); + const bytes = await readFile(canonicalPath); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath: canonicalPath, + expectedSha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + expectedBytes: (await stat(canonicalPath)).size, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + return { + invocationOwnerToken, + helperCapability: await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }), + }; +} + +function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + GIT_CONFIG_NOSYSTEM: '1', + HOME: join(cwd, '.home'), + GIT_CONFIG_GLOBAL: join(cwd, '.missing-global-config'), + GIT_CONFIG_COUNT: '0', + GIT_TERMINAL_PROMPT: '0', + }, + }).trim(); +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 0a5d199a9e..518cc524da 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -176,6 +176,10 @@ import { RuntimeHostWorkspaceExecutionError, type RuntimeHostWorkspaceExecutionComposition, } from './workspace-execution-composition.js'; +import { + tryOpenPackagedGitoxideManagedInspectionComposition, + type GitoxideManagedInspectionComposition, +} from './gitoxide-managed-inspection.js'; export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition { readonly workspaceExecution: RuntimeHostWorkspaceExecutionComposition; @@ -229,6 +233,7 @@ export async function createExecutionRuntimeHostComposition( let unsubscribeTranscriptChanges: (() => void) | undefined; let unsubscribeUsageChanges: (() => void) | undefined; let workspaceExecution: RuntimeHostWorkspaceExecutionComposition | undefined; + let gitoxideManagedInspection: GitoxideManagedInspectionComposition | undefined; let goalExecutions: HostGoalExecutionCoordinator | undefined; try { const openedProjectCatalog = storage.projectCatalog; @@ -311,6 +316,14 @@ export async function createExecutionRuntimeHostComposition( const managedFilesystemWorker = filesystemWorker ? adaptManagedWorkspaceFilesystemWorker(filesystemWorker) : undefined; + gitoxideManagedInspection = await tryOpenPackagedGitoxideManagedInspectionComposition({ + storageRoot: context.owner.capability.canonicalPath, + ...(managedFilesystemWorker ? { filesystemWorker: managedFilesystemWorker } : {}), + onUnavailable: (error) => + console.warn( + `[runtime-host] Gitoxide managed inspection unavailable: ${generalizedErrorMessage(error)}`, + ), + }); workspaceExecution = createRuntimeHostWorkspaceExecutionComposition({ ...(managedFilesystemWorker ? { filesystemWorker: managedFilesystemWorker } : {}), }); @@ -361,6 +374,7 @@ export async function createExecutionRuntimeHostComposition( const hostTools = [ createHostWebSearchToolFromService(webSearchService), createHostWebFetchToolFromService(webFetchService), + ...(gitoxideManagedInspection ? [gitoxideManagedInspection.tool] : []), ...runtimePolicy.modelTools, ]; const childAgentTools = createHostChildAgentToolComposition({ @@ -1479,6 +1493,7 @@ export async function createExecutionRuntimeHostComposition( }, drain: [ () => rootCoordinator?.beginDrain(), + () => gitoxideManagedInspection?.beginDrain(), () => workspaceExecution?.beginDrain(), () => runtimeResources?.beginDrain(), () => messages.beginDrain(), @@ -1492,6 +1507,7 @@ export async function createExecutionRuntimeHostComposition( await rootCloseTask; }, () => runtimeResources?.close(), + () => gitoxideManagedInspection?.close(), () => workspaceExecution?.close(), () => sessionEffects?.close(), () => messages.close(), @@ -1591,6 +1607,12 @@ export async function createExecutionRuntimeHostComposition( } catch (error) { const errors: unknown[] = [error]; goalExecutions?.beginDrain(); + gitoxideManagedInspection?.beginDrain(); + try { + await gitoxideManagedInspection?.close(); + } catch (closeError) { + errors.push(closeError); + } try { await workspaceExecution?.close(); } catch (closeError) { diff --git a/packages/runtime-host/src/server/gitoxide-managed-inspection.ts b/packages/runtime-host/src/server/gitoxide-managed-inspection.ts new file mode 100644 index 0000000000..e1e6f9b85e --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-managed-inspection.ts @@ -0,0 +1,501 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { isAbsolute, join, posix, relative } from 'node:path'; +import { z } from 'zod'; +import { createReadOnlyPermissionProfile } from '@maka/core/permission-profile'; +import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; +import type { MakaTool } from '@maka/runtime/tool-runtime'; +import { + computeManagedDependencyEnvironmentIdentity, + createManagedDependencyEnvironmentAuthority, + createManagedDependencyEnvironmentProducerCapability, + type ManagedDependencyEnvironmentAuthority, + type ManagedDependencyEnvironmentProducerInput, +} from '@maka/storage/managed-dependency-environment'; +import type { + ManagedWorkspaceFilesystemWorker, + ManagedWorkspaceReadOnlyOperation, + ManagedWorkspaceReadOnlyResult, +} from '@maka/storage/managed-workspace-owner'; +import { resolveBundledNpmRuntime } from './bundled-npm-runtime.js'; +import { + runManagedNpmDependencyProvision, + type ManagedNpmRuntimeCapability, +} from './managed-dependency-producer-process.js'; +import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artifact-authority-internal.js'; +import { resolvePackagedGitoxideHelperInternal } from './packaged-gitoxide-helper-internal.js'; +import { + admitGitoxideRepositoryInternal, + importAdmittedGitoxideRepositoryInternal, + materializeGitoxideProjectionInternal, + observeGitoxideProjectionInternal, + readGitoxideTreeFileInternal, +} from './gitoxide-repository-admission-authority-internal.js'; + +const MAX_PATH_CHARS = 4_096; +const MAX_GLOB_PATTERN_CHARS = 4_096; +const MAX_RESULT_BYTES = 64 * 1024; +const MAX_GLOB_RESULTS = 256; +const BASELINE_REF = 'refs/maka/accepted'; + +const boundedPath = z.string().min(1).max(MAX_PATH_CHARS); +const managedInspectionInputSchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('read'), + path: boundedPath, + offset: z.number().int().nonnegative().optional(), + limit: z.number().int().positive().max(MAX_GLOB_RESULTS).optional(), + }) + .strict(), + z + .object({ + kind: z.literal('glob'), + path: boundedPath, + pattern: z.string().min(1).max(MAX_GLOB_PATTERN_CHARS), + limit: z.number().int().positive().max(MAX_GLOB_RESULTS).optional(), + }) + .strict(), +]); + +export type GitoxideManagedInspectionInput = z.infer; + +export interface GitoxideManagedInspectionResult { + readonly kind: 'gitoxide_managed_inspection_v1'; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; + /** Present only when this operation actually consumed the dependency environment. */ + readonly dependencyEnvironmentId?: `sha256:${string}`; + readonly result: ManagedWorkspaceReadOnlyResult; +} + +export interface GitoxideManagedInspectionComposition { + readonly state: 'ready' | 'draining' | 'closed'; + readonly tool: MakaTool; + beginDrain(): void; + close(): Promise; +} + +export interface CreateGitoxideManagedInspectionCompositionInput { + readonly storageRoot: string; + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly npmRuntime: ManagedNpmRuntimeCapability; + readonly dependencyAuthority: ManagedDependencyEnvironmentAuthority; + readonly filesystemWorker: ManagedWorkspaceFilesystemWorker; +} + +export async function createGitoxideManagedInspectionComposition( + input: CreateGitoxideManagedInspectionCompositionInput, +): Promise { + const storageRoot = await realpath(input.storageRoot); + const stagingRoot = join(storageRoot, 'managed-workspaces', 'gitoxide-inspection-staging'); + await mkdir(stagingRoot, { recursive: true }); + const canonicalStagingRoot = await realpath(stagingRoot); + assertWithin(storageRoot, canonicalStagingRoot, 'Gitoxide inspection staging root'); + + const invocationOwnerToken = input.invocationOwnerToken; + const admissionOwnerToken = {}; + const managedRepositoryOwnerToken = {}; + const projectionOwnerToken = {}; + let state: GitoxideManagedInspectionComposition['state'] = 'ready'; + let activeOperations = 0; + const drainWaiters = new Set<() => void>(); + let closeTask: Promise | undefined; + + const execute = async ( + operation: GitoxideManagedInspectionInput, + sourceCwd: string, + abortSignal: AbortSignal, + ): Promise => { + if (state !== 'ready') throw new Error(`Gitoxide managed inspection is ${state}`); + const route = routeInspectionOperation(operation); + abortSignal.throwIfAborted(); + activeOperations += 1; + let operationRoot: string | undefined; + let dependencyLease: Awaited> | undefined; + let primaryError: unknown; + try { + const sourceRoot = await realpath(sourceCwd); + abortSignal.throwIfAborted(); + operationRoot = await mkdtemp(join(canonicalStagingRoot, 'inspection-')); + const repositoryPath = join(operationRoot, 'repository.git'); + const projectionPath = join(operationRoot, 'projection'); + const admitted = await admitGitoxideRepositoryInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + admissionOwnerToken, + repositoryPath: sourceRoot, + abortSignal, + }); + if (admitted.kind !== 'accepted') { + throw new Error(`Gitoxide rejected the source repository: ${admitted.reason}`); + } + const imported = await importAdmittedGitoxideRepositoryInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + admissionOwnerToken, + repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, + destinationRepositoryPath: repositoryPath, + baselineRef: BASELINE_REF, + abortSignal, + }); + let rawResult: ManagedWorkspaceReadOnlyResult; + let dependencyEnvironmentId: `sha256:${string}` | undefined; + if (route.root === 'source_tree') { + if (operation.kind !== 'read') throw new Error('Invalid source-tree inspection route'); + const file = await readGitoxideTreeFileInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: route.workerOperation.path, + abortSignal, + }); + rawResult = Object.freeze({ + kind: 'read' as const, + content: sliceReadContent(file.content, operation.offset, operation.limit), + }); + } else if (route.root === 'projection') { + const projection = await materializeGitoxideProjectionInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + projectionOwnerToken, + destinationPath: projectionPath, + abortSignal, + }); + rawResult = await input.filesystemWorker.execute({ + operation: route.workerOperation, + cwd: projection.destinationPath, + executionBoundary: route.executionBoundary, + abortSignal, + }); + const observation = await observeGitoxideProjectionInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + projectionOwnerToken, + projectionCapability: projection.projectionCapability, + abortSignal, + }); + if (observation.kind !== 'projection_observed') { + throw new Error( + `Gitoxide projection drifted at ${observation.path}: ${observation.reason}`, + ); + } + } else { + // These reads are intentionally sequential. A rejected child cannot outlive + // the operation and race cleanup of the shared managed repository. + const manifest = await readGitoxideTreeFileInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'package.json', + abortSignal, + }); + const lockfile = await readGitoxideTreeFileInternal({ + invocationOwnerToken, + helperCapability: input.helperCapability, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'package-lock.json', + abortSignal, + }); + const manifestBytes = Buffer.from(manifest.content, 'utf8'); + const lockfileBytes = Buffer.from(lockfile.content, 'utf8'); + const producerCapability = createManagedDependencyEnvironmentProducerCapability( + input.npmRuntime.runtimeIdentitySha256, + ); + const dependencyIdentity = computeManagedDependencyEnvironmentIdentity({ + manifestPath: manifest.path, + manifestBytes, + lockfilePath: lockfile.path, + lockfileBytes, + packageManagerName: 'npm', + packageManagerVersion: input.npmRuntime.npmVersion, + nodeVersion: input.npmRuntime.nodeVersion, + nodeAbi: input.npmRuntime.nodeAbi, + platform: input.npmRuntime.platform, + arch: input.npmRuntime.arch, + producerRuntimeIdentitySha256: producerCapability.runtimeIdentitySha256, + producerPolicyIdentitySha256: producerCapability.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1', + }); + dependencyLease = await input.dependencyAuthority.acquire(dependencyIdentity, { + manifestBytes, + lockfileBytes, + abortSignal, + }); + abortSignal.throwIfAborted(); + dependencyEnvironmentId = dependencyLease.environmentId; + rawResult = await input.filesystemWorker.execute({ + operation: route.workerOperation, + cwd: dependencyLease.dependencyRoot, + executionBoundary: route.executionBoundary, + abortSignal, + }); + } + const result = remapInspectionResult(route, rawResult); + const response = Object.freeze({ + kind: 'gitoxide_managed_inspection_v1' as const, + acceptedCommitOid: imported.baselineCommitOid, + acceptedTreeOid: imported.baselineTreeOid, + ...(dependencyEnvironmentId ? { dependencyEnvironmentId } : {}), + result, + }); + if (Buffer.byteLength(JSON.stringify(response), 'utf8') > MAX_RESULT_BYTES) { + throw new Error('Managed inspection result exceeds its response limit; narrow the request'); + } + return response; + } catch (error) { + primaryError = error; + throw error; + } finally { + const cleanupErrors: unknown[] = []; + try { + await dependencyLease?.release(); + } catch (error) { + cleanupErrors.push(error); + } + try { + if (operationRoot) await rm(operationRoot, { recursive: true, force: true }); + } catch (error) { + cleanupErrors.push(error); + } + activeOperations -= 1; + if (activeOperations === 0) { + for (const resolveWaiter of drainWaiters) resolveWaiter(); + drainWaiters.clear(); + } + if (primaryError instanceof Error && cleanupErrors.length > 0) { + const cleanupCause = new AggregateError( + cleanupErrors, + 'Gitoxide managed inspection cleanup also failed', + ); + if (primaryError.cause === undefined) { + Object.defineProperty(primaryError, 'cause', { + configurable: true, + value: cleanupCause, + }); + } + } + if (primaryError === undefined && cleanupErrors.length === 1) throw cleanupErrors[0]; + if (primaryError === undefined && cleanupErrors.length > 1) { + throw new AggregateError(cleanupErrors, 'Gitoxide managed inspection cleanup failed'); + } + } + }; + + const tool: MakaTool = { + name: 'ManagedWorkspaceInspect', + displayName: 'Inspect isolated workspace', + description: + 'Read or glob a project through a fresh Maka-owned Gitoxide projection and its attested npm dependency environment. ' + + 'This operation may provision dependencies and is intentionally unavailable in read-only Plan Mode.', + parameters: managedInspectionInputSchema, + categoryHint: 'custom_tool', + recoveryMode: 'never_auto_retry', + executionSemantics: 'exclusive_step', + impl: async (operation, context) => execute(operation, context.cwd, context.abortSignal), + }; + + return Object.freeze({ + get state() { + return state; + }, + tool, + beginDrain() { + if (state === 'ready') state = 'draining'; + }, + close() { + closeTask ??= (async () => { + if (state === 'ready') state = 'draining'; + if (activeOperations > 0) { + await new Promise((resolveWaiter) => drainWaiters.add(resolveWaiter)); + } + await input.dependencyAuthority.close(); + state = 'closed'; + })(); + return closeTask; + }, + }); +} + +export async function tryOpenPackagedGitoxideManagedInspectionComposition(input: { + readonly storageRoot: string; + readonly filesystemWorker?: ManagedWorkspaceFilesystemWorker; + readonly onUnavailable?: (error: unknown) => void; +}): Promise { + const resourcesRoot = runtimeHostPackagedResourcesRoot(); + if (!resourcesRoot || !input.filesystemWorker) return undefined; + const invocationOwnerToken = {}; + try { + const helperCapability = await resolvePackagedGitoxideHelperInternal({ + invocationOwnerToken, + }); + const npmRuntime = await resolveBundledNpmRuntime({ resourcesRoot }); + const producerCapability = createManagedDependencyEnvironmentProducerCapability( + npmRuntime.runtimeIdentitySha256, + ); + const dependencyAuthority = await createManagedDependencyEnvironmentAuthority({ + storageRoot: input.storageRoot, + producer: Object.freeze({ + capability: producerCapability, + packageManagerName: 'npm' as const, + packageManagerVersion: npmRuntime.npmVersion, + nodeRuntime: Object.freeze({ + version: npmRuntime.nodeVersion, + abi: npmRuntime.nodeAbi, + platform: npmRuntime.platform, + arch: npmRuntime.arch, + }), + provision: async (producerInput: ManagedDependencyEnvironmentProducerInput) => + runManagedNpmDependencyProvision({ producerInput, runtime: npmRuntime }), + }), + }); + try { + return await createGitoxideManagedInspectionComposition({ + storageRoot: input.storageRoot, + invocationOwnerToken, + helperCapability, + npmRuntime, + dependencyAuthority, + filesystemWorker: input.filesystemWorker, + }); + } catch (error) { + await dependencyAuthority.close().catch(() => undefined); + throw error; + } + } catch (error) { + input.onUnavailable?.(error); + return undefined; + } +} + +function runtimeHostPackagedResourcesRoot(): string | undefined { + if (!process.versions.electron) return undefined; + const resourcesPath = (process as NodeJS.Process & { readonly resourcesPath?: string }) + .resourcesPath; + return typeof resourcesPath === 'string' && isAbsolute(resourcesPath) ? resourcesPath : undefined; +} + +interface InspectionRoute { + readonly root: 'source_tree' | 'projection' | 'dependency'; + readonly logicalPath: string; + readonly workerOperation: ManagedWorkspaceReadOnlyOperation; + readonly executionBoundary: Parameters< + ManagedWorkspaceFilesystemWorker['execute'] + >[0]['executionBoundary']; +} + +function routeInspectionOperation(operation: GitoxideManagedInspectionInput): InspectionRoute { + const canonicalPath = canonicalProjectPath(operation.path); + const segments = canonicalPath === '.' ? [] : canonicalPath.split('/'); + const dependencyRoot = + segments.length > 0 && + (process.platform === 'win32' + ? segments[0]?.toLowerCase() === 'node_modules' + : segments[0] === 'node_modules'); + const logicalPath = dependencyRoot + ? ['node_modules', ...segments.slice(1)].join('/') + : canonicalPath; + const workerPath = dependencyRoot ? segments.slice(1).join('/') || '.' : canonicalPath; + return Object.freeze({ + root: dependencyRoot ? 'dependency' : operation.kind === 'read' ? 'source_tree' : 'projection', + logicalPath, + workerOperation: Object.freeze({ ...operation, path: workerPath }), + executionBoundary: createReadOnlyBoundary(), + }); +} + +function sliceReadContent(content: string, offset?: number, limit?: number): string { + if (offset === undefined && limit === undefined) return content; + const lines = content.split('\n'); + const start = offset ?? 0; + const end = limit === undefined ? lines.length : start + limit; + return lines.slice(start, end).join('\n'); +} + +function canonicalProjectPath(value: string): string { + if ( + value.includes('\\') || + value.includes('\0') || + value.startsWith('/') || + /^[a-zA-Z]:/u.test(value) + ) { + throw new TypeError('Managed inspection path must be a canonical project-relative path'); + } + if (value === '.') return value; + const segments = value.split('/'); + if (segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) { + throw new TypeError('Managed inspection path must not contain empty, dot, or dot-dot segments'); + } + const normalized = posix.normalize(value); + if (normalized !== value) { + throw new TypeError('Managed inspection path must already be canonical'); + } + return normalized; +} + +function remapInspectionResult( + route: InspectionRoute, + result: ManagedWorkspaceReadOnlyResult, +): ManagedWorkspaceReadOnlyResult { + if (result.kind !== 'glob') return result; + return Object.freeze({ + kind: 'glob' as const, + files: Object.freeze( + result.files.map((file) => { + const relativeFile = canonicalWorkerResultPath(file); + return route.logicalPath === '.' + ? relativeFile + : posix.join(route.logicalPath, relativeFile); + }), + ), + }); +} + +function canonicalWorkerResultPath(value: string): string { + const normalized = value.replaceAll('\\', '/'); + if (normalized.length === 0 || normalized.startsWith('/') || /^[a-zA-Z]:/u.test(normalized)) { + throw new Error('Filesystem worker returned a non-relative glob path'); + } + const segments = normalized.split('/'); + if (segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) { + throw new Error('Filesystem worker returned a non-canonical glob path'); + } + return posix.normalize(normalized); +} + +function createReadOnlyBoundary(): Parameters< + ManagedWorkspaceFilesystemWorker['execute'] +>[0]['executionBoundary'] { + return createManagedExecutionBoundary(createReadOnlyPermissionProfile(), 0); +} + +function assertWithin(root: string, target: string, label: string): void { + const rel = relative(root, target); + if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) return; + throw new Error(`${label} escapes the Runtime Host storage root`); +}