From 1698d9fd3e60fb95ae223f44701125001abff7aa Mon Sep 17 00:00:00 2001 From: mesalogo Date: Wed, 16 Sep 2026 19:04:54 +0800 Subject: [PATCH] fix: keep valid older agent packages usable after desktop updates Accept package-declared OpenCode, Node and koffi versions instead of desktop release pins. Preserve signature, hash, protocol and package-internal version consistency checks. Validate both cached 0.11.18 packages and Linux x64 installation, bootstrap, health and stop. Related regression suite: 326 passed, 1 skipped; unrelated full-workspace check failures are recorded in the remote Host design. Release note: Desktop updates no longer reject intact older Agent packages solely because their bundled runtime versions differ. --- docs/features/remote-host/technical-design.md | 17 +++++++++++ .../runtime-bundle-test-fixture.ts | 5 ++-- .../runtime-bundle-verifier.test.ts | 24 +++++++++++---- .../agent-bundle-verifier.test.ts | 29 ++++++++++++++++--- .../remote-agent/agent-bundle-verifier.ts | 6 ++++ .../remote-runtime-resource-loader.test.ts | 20 +++++++++++-- .../agent-installation-contracts.test.ts | 13 +++++---- src/shared/agent-installation-contracts.ts | 4 +-- src/shared/remote-runtime-launch-contracts.ts | 6 ++-- 9 files changed, 100 insertions(+), 24 deletions(-) diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index 12fdd6e3..2addb52c 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -630,6 +630,16 @@ bundle 构建通过;这组历史结果不替代完整 Host 支持改动后的 ### Linux 历史验收 +- 2026-09-16:当前源码通过本机 Linux x64/arm64 两份 `0.11.18` 旧包副本的生产签名、 + manifest、包内 lock 和 payload 校验(OpenCode `1.18.9`),原缓存元数据未变。 + 共享 Linux x64 Host 的 LAN/VPN 两条路由均通过固定主机密钥验证;在 `/root/tmp` + 独立目录运行当前源码校验与 `preparePackage` / `commitPackage`,随后旧包 Agent + bootstrap、health、stop 全部通过,未操作原有 Agent,模型请求为 0 次。 + 相关扩大回归为 326 通过、1 跳过;全量 `npm test` 为 4146 通过、67 跳过、4 失败, + 另有 App 测试文件因缺少 `LocalInferencePage` 导入失败。大离线包超时用例复跑通过; + 其余失败为 heartbeat fixture 重复 `pinned` 列及导航设置默认字段导致的断言差异。 + Node/Agent 类型检查和本次改动文件 lint 通过;全量 typecheck 受上述缺失组件阻塞, + 全量 lint 受 story-graph demo 的 22 项 `document` 未定义错误阻塞。 - 2026-09-06 使用当前源码隔离 Agent 验证原生子代理过程插件。最终场景收到 37 条 ACP 插件事件,桌面侧从已保留活动重建跟踪器后仍接收后续文本与工具;子代理和父请求正常 完成,最终模型调用账本为 4 次 completed。此前成功场景另 4 次,两轮真实 Host 共 @@ -793,6 +803,13 @@ goodbuddy-agent diagnostics --installation-id 校验,但所有 production 层统一使用一组 GoodBuddy 通用发布身份并通过签名域区分用途, 不要求内部 Runtime 单独配置密钥。`build/agent-catalog.cjs` 为双架构包生成签名累计目录, 拒绝同一版本/架构改变字节。 +- `remote-runtime-launch-contracts.ts` 中的 OpenCode Runtime lock 版本使用现有 + `componentVersionSchema` 校验,不再硬编码为 `1.18.29`,避免 Desktop 升级后仅因版本 + 不同拒绝已有完整签名旧包。包内 manifest、lock 与 descriptor 的版本一致性,以及签名、 + 哈希和协议兼容性检查仍须通过;构建与发行的 OpenCode pin 保持 `1.18.29`。 +- Agent lock 的 Node/koffi 版本同样使用 `componentVersionSchema`,允许完整旧包携带 + 不同于 Desktop 构建 pin 的依赖版本。验证时仍以包自身 lock 精确核对 manifest 的 + Node.js、koffi 和 koffi native 版本;Node `24.19.0`、koffi `3.1.4` 发行 pin 不变。 - `.github/workflows/agents.yml` 只使用进程内临时测试 key 做分支/PR 原生验证,不发布。 `.github/workflows/agent-release.yml` 才可在 annotated `agent-v` 标签和受保护 `agent-signing` Environment 中构建 production 包。Agent GitHub Release 必须 diff --git a/src/agent-daemon/runtime-bundle-test-fixture.ts b/src/agent-daemon/runtime-bundle-test-fixture.ts index 22fa59e3..3b6ae1b5 100644 --- a/src/agent-daemon/runtime-bundle-test-fixture.ts +++ b/src/agent-daemon/runtime-bundle-test-fixture.ts @@ -66,6 +66,7 @@ export const TEST_REMOTE_RUNTIME_LOCK: RemoteRuntimeLock = { export async function createRuntimeBundleTestFixture( overrides: { + runtimeVersion?: RemoteRuntimeBundleManifest['runtimeVersion'] sourcePackage?: RemoteRuntimeBundleManifest['sourcePackage'] directoryDigest?: string } = {} @@ -87,7 +88,7 @@ export async function createRuntimeBundleTestFixture( formatVersion: 2, product: 'GoodBuddy', runtimeId: 'opencode', - runtimeVersion: '1.18.29', + runtimeVersion: overrides.runtimeVersion ?? '1.18.29', provider: 'opencode', platform: 'linux', architecture: 'x64', @@ -127,7 +128,7 @@ export async function createRuntimeBundleTestFixture( licenses: [ { package: 'opencode-ai', - version: '1.18.29', + version: overrides.runtimeVersion ?? '1.18.29', spdx: 'MIT', path: 'licenses/opencode.txt' } diff --git a/src/agent-daemon/runtime-bundle-verifier.test.ts b/src/agent-daemon/runtime-bundle-verifier.test.ts index 993c5835..71cef8ea 100644 --- a/src/agent-daemon/runtime-bundle-verifier.test.ts +++ b/src/agent-daemon/runtime-bundle-verifier.test.ts @@ -31,14 +31,20 @@ afterEach(() => { }) describe('Remote Runtime bundle verifier', () => { - it('verifies an authentic digest-addressed OpenCode bundle', async () => { - const fixture = await createBundle() + it.each(['1.18.29', '1.18.9'])('verifies an authentic digest-addressed OpenCode %s bundle with a matching lock', async (runtimeVersion) => { + const fixture = await createBundle({ runtimeVersion }) await expect( verifyRuntimeBundle(fixture.bundleDirectory, { architecture: 'x64', releaseKeyRegistry: fixture.registry, - runtimeLock: lock, + runtimeLock: { + ...lock, + runtimes: { + ...lock.runtimes, + opencode: { ...lock.runtimes.opencode, version: runtimeVersion } + } + }, verificationEnvironment: 'test', filesystemPlatform: 'win32' }) @@ -108,14 +114,19 @@ describe('Remote Runtime bundle verifier', () => { ).rejects.toThrow(/payload (?:size|hash) mismatch/iu) }) - it('rejects a validly signed bundle outside the locked profile', async () => { - const fixture = await createBundle({ + it.each([ + { + runtimeVersion: '1.18.9' + }, + { sourcePackage: { name: 'opencode-linux-x64', integrity: 'sha512-VrvzV5Agrj0T2ZPvr5gzmh8xc4zqQ5pW8UeNgTzt5cJ/9Cbdxw6oFywgv7nfJqlpSfQqTWYJYH+LIHt3QdCS5g==' } - }) + } + ])('rejects a validly signed bundle outside the locked profile: %j', async (overrides) => { + const fixture = await createBundle(overrides) await expect( verifyRuntimeBundle(fixture.bundleDirectory, { @@ -259,6 +270,7 @@ describe('Remote Runtime bundle verifier', () => { async function createBundle( overrides: { + runtimeVersion?: RemoteRuntimeBundleManifest['runtimeVersion'] sourcePackage?: RemoteRuntimeBundleManifest['sourcePackage'] directoryDigest?: string } = {} diff --git a/src/main/remote-agent/agent-bundle-verifier.test.ts b/src/main/remote-agent/agent-bundle-verifier.test.ts index 520fee39..a034dedf 100644 --- a/src/main/remote-agent/agent-bundle-verifier.test.ts +++ b/src/main/remote-agent/agent-bundle-verifier.test.ts @@ -108,7 +108,7 @@ function writeKoffiPayload( join(packageRoot, 'package.json'), JSON.stringify({ name: 'koffi', - version: '3.1.4', + version: runtimeLock.koffi.version, type: 'module', exports: { '.': { import: './index.js' } } }) @@ -129,7 +129,7 @@ function writeKoffiPayload( join(nativeRoot, 'package.json'), JSON.stringify({ name: nativePackage, - version: '3.1.4', + version: runtimeLock.koffi.version, main: './index.js', os: ['linux'], cpu: [architecture] @@ -202,9 +202,9 @@ function writeBundle( const manifest = buildAgentBundle.createManifest(directory, { agentVersion: '0.11.0', - nodeVersion: '24.19.0', + nodeVersion: runtimeLock.node.version, zodVersion: '4.4.3', - koffiVersion: '3.1.4', + koffiVersion: runtimeLock.koffi.version, koffiNativePackage: '@koromix/koffi-linux-x64', arch: 'x64', protocol: { major: 1, minor: 0 }, @@ -282,6 +282,27 @@ afterEach(() => { }) describe('runtime and build-time Agent verifier parity', () => { + it('accepts older dependencies when the signed manifest matches the package lock', async () => { + runtimeLock.node.version = '24.18.0' + runtimeLock.koffi.version = '3.1.3' + writeBundle() + await expect(verifyBundle()).resolves.toMatchObject({ + manifest: { agentVersion: runtimeLock.agentVersion } + }) + }) + + it.each(['node', 'koffi'] as const)('rejects a signed bundle with a mismatched %s lock version', async (component) => { + writeBundle() + runtimeLock[component].version = component === 'node' ? '24.18.0' : '3.1.3' + await expect(verifyBundle()).rejects.toThrow(/does not match the runtime lock|dependencies do not match the runtime lock/u) + }) + + it.each(['node', 'koffi'] as const)('rejects an invalid %s lock version', async (component) => { + writeBundle() + runtimeLock[component].version = 'latest' + await expect(verifyBundle()).rejects.toThrow('Agent runtime lock contract is invalid') + }) + it('accepts equivalent registry whitespace and canonicalizes validated keys', () => { const canonical = canonicalAgentReleaseKeyRegistryBytes(registry) const crlf = Buffer.from( diff --git a/src/main/remote-agent/agent-bundle-verifier.ts b/src/main/remote-agent/agent-bundle-verifier.ts index b654a584..33b39c31 100644 --- a/src/main/remote-agent/agent-bundle-verifier.ts +++ b/src/main/remote-agent/agent-bundle-verifier.ts @@ -342,6 +342,12 @@ export function assertAgentManifestMatchesRuntimeLock( 'Agent manifest architecture does not match the selected bundle' ) } + if ( + manifest.licenses.find((license) => license.package === 'Node.js') + ?.version !== lock.node.version + ) { + throw new Error('Agent Node version does not match the runtime lock') + } if ( manifest.protocol.major !== lock.protocol.major || manifest.protocol.minor !== lock.protocol.minor diff --git a/src/main/remote-agent/remote-runtime-resource-loader.test.ts b/src/main/remote-agent/remote-runtime-resource-loader.test.ts index b4c90cca..c0346d12 100644 --- a/src/main/remote-agent/remote-runtime-resource-loader.test.ts +++ b/src/main/remote-agent/remote-runtime-resource-loader.test.ts @@ -74,19 +74,33 @@ async function createFixture(): Promise<{ } describe('remote Runtime verification metadata loader', () => { - it('loads canonical verification metadata without Runtime payloads', async () => { + it.each(['1.18.9', '1.18.29'])('loads canonical verification metadata for Runtime %s without payloads', async (version) => { const fixture = await createFixture() + const packageLock = structuredClone(runtimeLock) + packageLock.runtimes.opencode.version = version + await writeFile(fixture.paths.runtimeLockPath, canonical(packageLock)) await expect( loadRemoteRuntimeVerificationMetadata(fixture.paths) ).resolves.toEqual({ releaseKeyRegistry: registry, - runtimeLock, + runtimeLock: packageLock, canonicalReleaseKeyRegistryBytes: canonical(registry), - canonicalRemoteRuntimeLockBytes: canonical(runtimeLock) + canonicalRemoteRuntimeLockBytes: canonical(packageLock) }) }) + it.each(['', 'latest', '^1.18.9', '1.18'])('rejects invalid Runtime version %j', async (version) => { + const fixture = await createFixture() + const packageLock = structuredClone(runtimeLock) + packageLock.runtimes.opencode.version = version + await writeFile(fixture.paths.runtimeLockPath, canonical(packageLock)) + + await expect( + loadRemoteRuntimeVerificationMetadata(fixture.paths) + ).rejects.toThrow('Remote Runtime lock contract is invalid') + }) + it('canonicalizes equivalent release-key registry formatting', async () => { const fixture = await createFixture() await writeFile( diff --git a/src/shared/agent-installation-contracts.test.ts b/src/shared/agent-installation-contracts.test.ts index 07e53cf4..70ecb2f5 100644 --- a/src/shared/agent-installation-contracts.test.ts +++ b/src/shared/agent-installation-contracts.test.ts @@ -147,7 +147,10 @@ describe('Agent installation contracts', () => { ).toThrow('canonical Base64') }) - it('requires both locked Linux targets and the fixed Node patch', () => { + it.each([ + ['24.19.0', '3.1.4'], + ['24.18.0', '3.1.3'] + ])('accepts package Node %s and koffi %s versions while requiring valid targets and source', (nodeVersion, koffiVersion) => { const target = { archive: 'node.tar.gz', sha256: digest, @@ -160,16 +163,16 @@ describe('Agent installation contracts', () => { agentVersion: '0.11.0', protocol: { major: 1, minor: 0 }, node: { - version: '24.19.0', - source: 'https://nodejs.org/dist/v24.19.0/', + version: nodeVersion, + source: `https://nodejs.org/dist/v${nodeVersion}/`, targets: { 'linux-x64': target, 'linux-arm64': target } }, - koffi: { version: '3.1.4' } + koffi: { version: koffiVersion } }).koffi.version - ).toBe('3.1.4') + ).toBe(koffiVersion) expect(() => agentRuntimeLockSchema.parse({ formatVersion: 1, diff --git a/src/shared/agent-installation-contracts.ts b/src/shared/agent-installation-contracts.ts index 846b7966..82e167ec 100644 --- a/src/shared/agent-installation-contracts.ts +++ b/src/shared/agent-installation-contracts.ts @@ -239,7 +239,7 @@ export const agentRuntimeLockSchema = z protocol: agentProtocolVersionSchema, node: z .object({ - version: z.literal('24.19.0'), + version: componentVersionSchema, source: z .url() .refine( @@ -257,7 +257,7 @@ export const agentRuntimeLockSchema = z .strict(), koffi: z .object({ - version: z.literal('3.1.4') + version: componentVersionSchema }) .strict() }) diff --git a/src/shared/remote-runtime-launch-contracts.ts b/src/shared/remote-runtime-launch-contracts.ts index 8daf637f..eded07b6 100644 --- a/src/shared/remote-runtime-launch-contracts.ts +++ b/src/shared/remote-runtime-launch-contracts.ts @@ -5,7 +5,8 @@ import { agentManifestLicenseSchema, agentManifestPathSchema, agentProtocolVersionSchema, - agentReleaseKeySchema + agentReleaseKeySchema, + componentVersionSchema } from './agent-installation-contracts' import { agentIdentifierSchema, @@ -227,7 +228,8 @@ export const remoteRuntimeLockSchema = z .object({ opencode: z .object({ - version: z.literal('1.18.29'), + // Installed packages carry their own signed Runtime version. + version: componentVersionSchema, provider: z.literal('opencode'), entrypoint: z.literal('bin/opencode'), entrypointIdentity: z.literal('opencode-acp'),