diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index c77616ba..cef0b0b3 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -81,7 +81,9 @@ Detached GoodBuddy Agent - Host 管理独占 Agent/Runtime 的包准备、更新和完整 payload 验证。已有项目的打开和切换 只更新本地项目选择。Workspace 和执行路径按需读取 Host current registry, 执行固定 `attach-or-bootstrap` 并复用当前 Agent 连接;同一进程内复用已确认 identity。 - 它们不取得安装包、不发布组件,也不通过 SFTP 重读或哈希 payload。 + 它们不取得安装包、不发布组件,也不通过 SFTP 重读或哈希 payload;已登记 Runtime + 的能力读取和 Prompt 启动只核对 registry 与 manifest 元数据,不再次执行、读取或检查 + OpenCode 二进制。 - Agent 的固定 attach/按需启动命令只读取 Host 管理已提交的 installation registry 和 与其 digest 匹配的有界 manifest 元数据,不在每次项目切换时重新验签或哈希完整 payload。 完整验签、payload 扫描和 registry 写入仍只发生在显式 Host 准备/更新流程。 diff --git a/src/agent-daemon/runtime-bundle-verifier.test.ts b/src/agent-daemon/runtime-bundle-verifier.test.ts index e0316cf2..a7db0cf1 100644 --- a/src/agent-daemon/runtime-bundle-verifier.test.ts +++ b/src/agent-daemon/runtime-bundle-verifier.test.ts @@ -213,11 +213,8 @@ describe('Remote Runtime bundle verifier', () => { Buffer.from('changed') ]) ) - await expect(capabilities()).resolves.toEqual([]) - expect(reportError).toHaveBeenCalledWith( - 'Verified Runtime unavailable: opencode', - expect.any(Error) - ) + await expect(capabilities()).resolves.toHaveLength(1) + expect(reportError).not.toHaveBeenCalled() }) it('adopts signed published metadata without rehashing unrelated payload', async () => { diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 1991e0c8..fd38a05c 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -678,6 +678,7 @@ async function defaultDetectBinary( return detectRuntimeBinary({ binaryPath: configuredPath, bundledPath, + validation: 'path-only', binaryNames: [runtime], label: 'OpenCode CLI' }) diff --git a/src/main/agent/runtime-discovery.test.ts b/src/main/agent/runtime-discovery.test.ts index a1163485..5171935b 100644 --- a/src/main/agent/runtime-discovery.test.ts +++ b/src/main/agent/runtime-discovery.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import { realpath } from 'node:fs/promises' -import { basename, dirname } from 'node:path' +import { basename, dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -93,6 +93,29 @@ describe('runtime discovery', () => { expect(detection.version).toMatch(/^\d+\.\d+\.\d+/u) }) + it('trusts a configured execution path without reading or launching it', async () => { + process.env.PATH = '' + process.env.Path = '' + const candidate = resolve( + dirname(fileURLToPath(import.meta.url)), + 'runtime-that-does-not-exist' + ) + + const detection = await detectRuntimeBinary({ + binaryPath: candidate, + validation: 'path-only', + binaryNames: ['binary-that-does-not-exist'], + label: 'Managed Runtime' + }) + + expect(detection).toEqual({ + available: true, + path: candidate, + source: 'configured', + detail: 'Managed Runtime 已就绪' + }) + }) + it('rejects relative configured paths without resolving them from cwd', async () => { process.env.PATH = '' process.env.Path = '' diff --git a/src/main/agent/runtime-discovery.ts b/src/main/agent/runtime-discovery.ts index 78dee9b1..f2ca8a15 100644 --- a/src/main/agent/runtime-discovery.ts +++ b/src/main/agent/runtime-discovery.ts @@ -25,6 +25,7 @@ const VERSION_TERMINATION_WAIT_MS = 500 export type RuntimeBinaryDiscoveryInput = { binaryPath: string bundledPath?: string + validation?: 'execute' | 'canonical-file' | 'path-only' bundledValidation?: 'execute' | 'canonical-file' bundledVersion?: string allowAutomaticDiscovery?: boolean @@ -333,11 +334,24 @@ export async function detectRuntimeBinary( if (!bundledPath) { return undefined } + if (input.validation === 'path-only') { + return isAbsolute(bundledPath) + ? availableDetection( + input.label, + normalize(bundledPath), + input.bundledVersion, + 'bundled' + ) + : undefined + } const canonicalPath = await canonicalFile(bundledPath) if (!canonicalPath) { return undefined } - if (input.bundledValidation === 'canonical-file') { + if ( + (input.bundledValidation ?? input.validation) === + 'canonical-file' + ) { return availableDetection( input.label, canonicalPath, @@ -359,10 +373,24 @@ export async function detectRuntimeBinary( if (configuredPath) { if (!isAbsolute(configuredPath)) { configuredPathProblem = 'relative' + } else if (input.validation === 'path-only') { + return availableDetection( + input.label, + normalize(configuredPath), + undefined, + 'configured' + ) } else { const canonicalPath = await canonicalFile(configuredPath) if (!canonicalPath) { configuredPathProblem = 'invalid' + } else if (input.validation === 'canonical-file') { + return availableDetection( + input.label, + canonicalPath, + undefined, + 'configured' + ) } else { const validation = await validateRuntimeVersion(canonicalPath) if (validation.valid) { @@ -391,6 +419,15 @@ export async function detectRuntimeBinary( continue } foundAutomaticCandidate = true + if ( + input.validation === 'canonical-file' || + input.validation === 'path-only' + ) { + return availableDetection( + input.label, + canonicalPath + ) + } const validation = await validateRuntimeVersion(canonicalPath) if (validation.valid) { return availableDetection( diff --git a/src/shared/node/runtime-bundle-verifier.ts b/src/shared/node/runtime-bundle-verifier.ts index bf04a4ef..3a258934 100644 --- a/src/shared/node/runtime-bundle-verifier.ts +++ b/src/shared/node/runtime-bundle-verifier.ts @@ -233,7 +233,7 @@ export async function verifyPublishedRuntimeBundle( /** * Reconstructs a Runtime identity that was fully verified when it was * registered. Normal capabilities and prompt launches use this bounded - * metadata path instead of hashing the complete Runtime payload again. + * metadata path instead of re-verifying or executing the Runtime binary. */ export async function loadRegisteredRuntimeBundle( bundleDirectoryInput: string, @@ -242,12 +242,50 @@ export async function loadRegisteredRuntimeBundle( const registered = runtimeRegistryEntrySchema.parse( options.registered ) - const verified = await verifyPublishedRuntimeBundle( - bundleDirectoryInput, + const bundleDirectory = assertAbsoluteManagedPath( + resolve(bundleDirectoryInput) + ) + const rootStat = await lstat(bundleDirectory) + assertOwnedDirectory( + rootStat, + 'Runtime bundle directory', + options.uid + ) + if ( + shouldEnforceMode(options) && + modeString(rootStat.mode) !== '0700' + ) { + throw new Error('Runtime bundle directory permissions must be 0700') + } + const manifestBytes = await readMetadataFile( + join(bundleDirectory, MANIFEST_FILE_NAME), + 'Runtime manifest', options ) - const { manifest, manifestDigest } = verified + let value: unknown + try { + value = JSON.parse(manifestBytes.toString('utf8')) + } catch (error) { + throw new Error('Runtime manifest is invalid JSON', { + cause: error + }) + } + const manifest = remoteRuntimeBundleManifestSchema.parse(value) + if (!canonicalRuntimeManifestBytes(manifest).equals(manifestBytes)) { + throw new Error( + 'Runtime manifest is not in canonical deterministic form' + ) + } + assertRuntimeManifestMatchesLock( + manifest, + remoteRuntimeLockSchema.parse(options.runtimeLock), + options.architecture + ) + const manifestDigest = + await digestRemoteRuntimeBundleManifest(manifest) if ( + basename(bundleDirectory) !== + registered.bundleDigest.slice('sha256:'.length) || manifest.runtimeId !== registered.runtimeId || manifest.runtimeVersion !== registered.runtimeVersion || manifest.architecture !== registered.architecture || @@ -265,7 +303,15 @@ export async function loadRegisteredRuntimeBundle( ) } - return verified + return { + bundleDirectory, + executablePath: join( + bundleDirectory, + ...manifest.entrypoint.path.split('/') + ), + manifest, + manifestDigest + } } export function canonicalRuntimeManifestBytes(