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.
This commit is contained in:
mesalogo
2026-09-16 19:04:54 +08:00
parent 441f14b5f7
commit 1698d9fd3e
9 changed files with 100 additions and 24 deletions
@@ -630,6 +630,16 @@ bundle 构建通过;这组历史结果不替代完整 Host 支持改动后的
### Linux 历史验收 ### 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 - 2026-09-06 使用当前源码隔离 Agent 验证原生子代理过程插件。最终场景收到 37 条 ACP
插件事件,桌面侧从已保留活动重建跟踪器后仍接收后续文本与工具;子代理和父请求正常 插件事件,桌面侧从已保留活动重建跟踪器后仍接收后续文本与工具;子代理和父请求正常
完成,最终模型调用账本为 4 次 completed。此前成功场景另 4 次,两轮真实 Host 共 完成,最终模型调用账本为 4 次 completed。此前成功场景另 4 次,两轮真实 Host 共
@@ -793,6 +803,13 @@ goodbuddy-agent diagnostics --installation-id <installationId>
校验,但所有 production 层统一使用一组 GoodBuddy 通用发布身份并通过签名域区分用途, 校验,但所有 production 层统一使用一组 GoodBuddy 通用发布身份并通过签名域区分用途,
不要求内部 Runtime 单独配置密钥。`build/agent-catalog.cjs` 为双架构包生成签名累计目录, 不要求内部 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/agents.yml` 只使用进程内临时测试 key 做分支/PR 原生验证,不发布。
`.github/workflows/agent-release.yml` 才可在 annotated `agent-v<version>` 标签和受保护 `.github/workflows/agent-release.yml` 才可在 annotated `agent-v<version>` 标签和受保护
`agent-signing` Environment 中构建 production 包。Agent GitHub Release 必须 `agent-signing` Environment 中构建 production 包。Agent GitHub Release 必须
@@ -66,6 +66,7 @@ export const TEST_REMOTE_RUNTIME_LOCK: RemoteRuntimeLock = {
export async function createRuntimeBundleTestFixture( export async function createRuntimeBundleTestFixture(
overrides: { overrides: {
runtimeVersion?: RemoteRuntimeBundleManifest['runtimeVersion']
sourcePackage?: RemoteRuntimeBundleManifest['sourcePackage'] sourcePackage?: RemoteRuntimeBundleManifest['sourcePackage']
directoryDigest?: string directoryDigest?: string
} = {} } = {}
@@ -87,7 +88,7 @@ export async function createRuntimeBundleTestFixture(
formatVersion: 2, formatVersion: 2,
product: 'GoodBuddy', product: 'GoodBuddy',
runtimeId: 'opencode', runtimeId: 'opencode',
runtimeVersion: '1.18.29', runtimeVersion: overrides.runtimeVersion ?? '1.18.29',
provider: 'opencode', provider: 'opencode',
platform: 'linux', platform: 'linux',
architecture: 'x64', architecture: 'x64',
@@ -127,7 +128,7 @@ export async function createRuntimeBundleTestFixture(
licenses: [ licenses: [
{ {
package: 'opencode-ai', package: 'opencode-ai',
version: '1.18.29', version: overrides.runtimeVersion ?? '1.18.29',
spdx: 'MIT', spdx: 'MIT',
path: 'licenses/opencode.txt' path: 'licenses/opencode.txt'
} }
@@ -31,14 +31,20 @@ afterEach(() => {
}) })
describe('Remote Runtime bundle verifier', () => { describe('Remote Runtime bundle verifier', () => {
it('verifies an authentic digest-addressed OpenCode bundle', async () => { 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() const fixture = await createBundle({ runtimeVersion })
await expect( await expect(
verifyRuntimeBundle(fixture.bundleDirectory, { verifyRuntimeBundle(fixture.bundleDirectory, {
architecture: 'x64', architecture: 'x64',
releaseKeyRegistry: fixture.registry, releaseKeyRegistry: fixture.registry,
runtimeLock: lock, runtimeLock: {
...lock,
runtimes: {
...lock.runtimes,
opencode: { ...lock.runtimes.opencode, version: runtimeVersion }
}
},
verificationEnvironment: 'test', verificationEnvironment: 'test',
filesystemPlatform: 'win32' filesystemPlatform: 'win32'
}) })
@@ -108,14 +114,19 @@ describe('Remote Runtime bundle verifier', () => {
).rejects.toThrow(/payload (?:size|hash) mismatch/iu) ).rejects.toThrow(/payload (?:size|hash) mismatch/iu)
}) })
it('rejects a validly signed bundle outside the locked profile', async () => { it.each([
const fixture = await createBundle({ {
runtimeVersion: '1.18.9'
},
{
sourcePackage: { sourcePackage: {
name: 'opencode-linux-x64', name: 'opencode-linux-x64',
integrity: integrity:
'sha512-VrvzV5Agrj0T2ZPvr5gzmh8xc4zqQ5pW8UeNgTzt5cJ/9Cbdxw6oFywgv7nfJqlpSfQqTWYJYH+LIHt3QdCS5g==' 'sha512-VrvzV5Agrj0T2ZPvr5gzmh8xc4zqQ5pW8UeNgTzt5cJ/9Cbdxw6oFywgv7nfJqlpSfQqTWYJYH+LIHt3QdCS5g=='
} }
}) }
])('rejects a validly signed bundle outside the locked profile: %j', async (overrides) => {
const fixture = await createBundle(overrides)
await expect( await expect(
verifyRuntimeBundle(fixture.bundleDirectory, { verifyRuntimeBundle(fixture.bundleDirectory, {
@@ -259,6 +270,7 @@ describe('Remote Runtime bundle verifier', () => {
async function createBundle( async function createBundle(
overrides: { overrides: {
runtimeVersion?: RemoteRuntimeBundleManifest['runtimeVersion']
sourcePackage?: RemoteRuntimeBundleManifest['sourcePackage'] sourcePackage?: RemoteRuntimeBundleManifest['sourcePackage']
directoryDigest?: string directoryDigest?: string
} = {} } = {}
@@ -108,7 +108,7 @@ function writeKoffiPayload(
join(packageRoot, 'package.json'), join(packageRoot, 'package.json'),
JSON.stringify({ JSON.stringify({
name: 'koffi', name: 'koffi',
version: '3.1.4', version: runtimeLock.koffi.version,
type: 'module', type: 'module',
exports: { '.': { import: './index.js' } } exports: { '.': { import: './index.js' } }
}) })
@@ -129,7 +129,7 @@ function writeKoffiPayload(
join(nativeRoot, 'package.json'), join(nativeRoot, 'package.json'),
JSON.stringify({ JSON.stringify({
name: nativePackage, name: nativePackage,
version: '3.1.4', version: runtimeLock.koffi.version,
main: './index.js', main: './index.js',
os: ['linux'], os: ['linux'],
cpu: [architecture] cpu: [architecture]
@@ -202,9 +202,9 @@ function writeBundle(
const manifest = buildAgentBundle.createManifest(directory, { const manifest = buildAgentBundle.createManifest(directory, {
agentVersion: '0.11.0', agentVersion: '0.11.0',
nodeVersion: '24.19.0', nodeVersion: runtimeLock.node.version,
zodVersion: '4.4.3', zodVersion: '4.4.3',
koffiVersion: '3.1.4', koffiVersion: runtimeLock.koffi.version,
koffiNativePackage: '@koromix/koffi-linux-x64', koffiNativePackage: '@koromix/koffi-linux-x64',
arch: 'x64', arch: 'x64',
protocol: { major: 1, minor: 0 }, protocol: { major: 1, minor: 0 },
@@ -282,6 +282,27 @@ afterEach(() => {
}) })
describe('runtime and build-time Agent verifier parity', () => { 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', () => { it('accepts equivalent registry whitespace and canonicalizes validated keys', () => {
const canonical = canonicalAgentReleaseKeyRegistryBytes(registry) const canonical = canonicalAgentReleaseKeyRegistryBytes(registry)
const crlf = Buffer.from( const crlf = Buffer.from(
@@ -342,6 +342,12 @@ export function assertAgentManifestMatchesRuntimeLock(
'Agent manifest architecture does not match the selected bundle' '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 ( if (
manifest.protocol.major !== lock.protocol.major || manifest.protocol.major !== lock.protocol.major ||
manifest.protocol.minor !== lock.protocol.minor manifest.protocol.minor !== lock.protocol.minor
@@ -74,19 +74,33 @@ async function createFixture(): Promise<{
} }
describe('remote Runtime verification metadata loader', () => { 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 fixture = await createFixture()
const packageLock = structuredClone(runtimeLock)
packageLock.runtimes.opencode.version = version
await writeFile(fixture.paths.runtimeLockPath, canonical(packageLock))
await expect( await expect(
loadRemoteRuntimeVerificationMetadata(fixture.paths) loadRemoteRuntimeVerificationMetadata(fixture.paths)
).resolves.toEqual({ ).resolves.toEqual({
releaseKeyRegistry: registry, releaseKeyRegistry: registry,
runtimeLock, runtimeLock: packageLock,
canonicalReleaseKeyRegistryBytes: canonical(registry), 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 () => { it('canonicalizes equivalent release-key registry formatting', async () => {
const fixture = await createFixture() const fixture = await createFixture()
await writeFile( await writeFile(
@@ -147,7 +147,10 @@ describe('Agent installation contracts', () => {
).toThrow('canonical Base64') ).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 = { const target = {
archive: 'node.tar.gz', archive: 'node.tar.gz',
sha256: digest, sha256: digest,
@@ -160,16 +163,16 @@ describe('Agent installation contracts', () => {
agentVersion: '0.11.0', agentVersion: '0.11.0',
protocol: { major: 1, minor: 0 }, protocol: { major: 1, minor: 0 },
node: { node: {
version: '24.19.0', version: nodeVersion,
source: 'https://nodejs.org/dist/v24.19.0/', source: `https://nodejs.org/dist/v${nodeVersion}/`,
targets: { targets: {
'linux-x64': target, 'linux-x64': target,
'linux-arm64': target 'linux-arm64': target
} }
}, },
koffi: { version: '3.1.4' } koffi: { version: koffiVersion }
}).koffi.version }).koffi.version
).toBe('3.1.4') ).toBe(koffiVersion)
expect(() => expect(() =>
agentRuntimeLockSchema.parse({ agentRuntimeLockSchema.parse({
formatVersion: 1, formatVersion: 1,
+2 -2
View File
@@ -239,7 +239,7 @@ export const agentRuntimeLockSchema = z
protocol: agentProtocolVersionSchema, protocol: agentProtocolVersionSchema,
node: z node: z
.object({ .object({
version: z.literal('24.19.0'), version: componentVersionSchema,
source: z source: z
.url() .url()
.refine( .refine(
@@ -257,7 +257,7 @@ export const agentRuntimeLockSchema = z
.strict(), .strict(),
koffi: z koffi: z
.object({ .object({
version: z.literal('3.1.4') version: componentVersionSchema
}) })
.strict() .strict()
}) })
@@ -5,7 +5,8 @@ import {
agentManifestLicenseSchema, agentManifestLicenseSchema,
agentManifestPathSchema, agentManifestPathSchema,
agentProtocolVersionSchema, agentProtocolVersionSchema,
agentReleaseKeySchema agentReleaseKeySchema,
componentVersionSchema
} from './agent-installation-contracts' } from './agent-installation-contracts'
import { import {
agentIdentifierSchema, agentIdentifierSchema,
@@ -227,7 +228,8 @@ export const remoteRuntimeLockSchema = z
.object({ .object({
opencode: z opencode: z
.object({ .object({
version: z.literal('1.18.29'), // Installed packages carry their own signed Runtime version.
version: componentVersionSchema,
provider: z.literal('opencode'), provider: z.literal('opencode'),
entrypoint: z.literal('bin/opencode'), entrypoint: z.literal('bin/opencode'),
entrypointIdentity: z.literal('opencode-acp'), entrypointIdentity: z.literal('opencode-acp'),