Files
goodbuddy/src/main/remote-agent/agent-bundle-verifier.test.ts
T
mesalogo de64e9768a
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
fix: make Agent updates reliable and visible
Valid Agent packages could be rejected when packaged trusted-key JSON used CRLF, and Settings could not show whether a newer compatible Agent existed. Registry formatting is now parsed independently of cryptographic trust, and Settings reads only the signed catalog to report local and latest versions.

New installations use a credential-free local Ollama-compatible profile. The v20 migration changes only untouched historical defaults, while explicit profiles and legacy environment aliases remain compatible. Magic Notes count badges no longer shift navigation rows.

Release note: 修复 Agent 下载兼容性并在设置中显示更新状态;新安装默认连接本地模型,保留显式用户配置;魔法笔记待办数字不再改变侧栏菜单高度。
2026-08-26 15:43:41 +08:00

471 lines
12 KiB
TypeScript

import {
createHash,
generateKeyPairSync,
sign,
type KeyObject
} from 'node:crypto'
import {
chmodSync,
mkdtempSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest'
import type {
AgentBundleManifest,
AgentReleaseKeyRegistry,
AgentRuntimeLock
} from '../../shared/agent-installation-contracts'
import {
agentManifestSignaturePayload,
canonicalAgentManifestBytes,
canonicalAgentReleaseKeyRegistryBytes,
parseAgentReleaseKeyRegistryBytes,
verifyAgentBundleDirectory,
verifyAgentManifestSignature,
type VerifiedAgentBundle
} from './agent-bundle-verifier'
type BuildAgentBundleModule = {
canonicalManifestBytes(
manifest: AgentBundleManifest
): Buffer
createManifest(
directory: string,
metadata: {
agentVersion: string
nodeVersion: string
zodVersion: string
koffiVersion: string
koffiNativePackage: string
arch: 'x64' | 'arm64'
protocol: { major: number; minor: number }
signingKeyId: string
}
): AgentBundleManifest
signaturePayload(manifestBytes: Buffer): Buffer
}
const require = createRequire(import.meta.url)
const buildAgentBundle = require(
'../../../build/agent-bundle.cjs'
) as BuildAgentBundleModule
let directory: string
let privateKey: KeyObject
let registry: AgentReleaseKeyRegistry
let runtimeLock: AgentRuntimeLock
function elf(architecture: 'x64' | 'arm64'): Buffer {
const contents = Buffer.alloc(64)
contents.set([0x7f, 0x45, 0x4c, 0x46])
contents[5] = 1
contents.writeUInt16LE(architecture === 'x64' ? 62 : 183, 18)
return contents
}
function writeKoffiPayload(
bundleDirectory: string,
architecture: 'x64' | 'arm64'
): void {
const packageRoot = join(
bundleDirectory,
'lib',
'node_modules',
'koffi'
)
const nativePackage = `@koromix/koffi-linux-${architecture}`
const nativeRoot = join(
bundleDirectory,
'lib',
'node_modules',
...nativePackage.split('/')
)
mkdirSync(join(packageRoot, 'src', 'koffi', 'src'), {
recursive: true
})
mkdirSync(join(nativeRoot, `linux_${architecture}`), {
recursive: true
})
mkdirSync(join(nativeRoot, `musl_${architecture}`), {
recursive: true
})
const files = new Map<string, string | Buffer>([
[
join(packageRoot, 'package.json'),
JSON.stringify({
name: 'koffi',
version: '3.1.4',
type: 'module',
exports: { '.': { import: './index.js' } }
})
],
[
join(packageRoot, 'index.js'),
'export { default } from "./src/koffi/index.js";\n'
],
[
join(packageRoot, 'src', 'koffi', 'index.js'),
'export default {};\n'
],
[
join(packageRoot, 'src', 'koffi', 'src', 'static.js'),
'export function loadStatic() { return null }\n'
],
[
join(nativeRoot, 'package.json'),
JSON.stringify({
name: nativePackage,
version: '3.1.4',
main: './index.js',
os: ['linux'],
cpu: [architecture]
})
],
[
join(nativeRoot, 'index.js'),
`module.exports = require('./linux_${architecture}/koffi.node');\n`
],
[
join(nativeRoot, `linux_${architecture}`, 'koffi.node'),
elf(architecture)
],
[
join(nativeRoot, `musl_${architecture}`, 'koffi.node'),
elf(architecture)
]
])
for (const [filePath, contents] of files) {
writeFileSync(filePath, contents)
chmodSync(filePath, 0o644)
}
}
function writeBundle(
signingKeyId = 'generated-test-key'
): AgentBundleManifest {
mkdirSync(join(directory, 'lib'), { recursive: true })
mkdirSync(join(directory, 'licenses'), { recursive: true })
writeFileSync(join(directory, 'node'), elf('x64'))
writeFileSync(join(directory, 'goodbuddy-agent'), '#!/bin/sh\n')
writeFileSync(
join(directory, 'lib', 'agent.cjs'),
'module.exports = {}\n'
)
writeFileSync(
join(directory, 'licenses', 'GoodBuddy-0BSD.txt'),
'0BSD'
)
writeFileSync(
join(directory, 'licenses', 'Node.js-MIT.txt'),
'MIT'
)
writeFileSync(
join(directory, 'licenses', 'zod-MIT.txt'),
'MIT'
)
writeFileSync(
join(directory, 'licenses', 'koffi-MIT.txt'),
'MIT'
)
writeFileSync(
join(directory, 'licenses', 'koffi-native-MIT.txt'),
'MIT'
)
writeKoffiPayload(directory, 'x64')
for (const filePath of ['node', 'goodbuddy-agent']) {
chmodSync(join(directory, filePath), 0o755)
}
for (const filePath of [
'lib/agent.cjs',
'licenses/GoodBuddy-0BSD.txt',
'licenses/Node.js-MIT.txt',
'licenses/zod-MIT.txt',
'licenses/koffi-MIT.txt',
'licenses/koffi-native-MIT.txt'
]) {
chmodSync(join(directory, ...filePath.split('/')), 0o644)
}
const manifest = buildAgentBundle.createManifest(directory, {
agentVersion: '0.11.0',
nodeVersion: '24.19.0',
zodVersion: '4.4.3',
koffiVersion: '3.1.4',
koffiNativePackage: '@koromix/koffi-linux-x64',
arch: 'x64',
protocol: { major: 1, minor: 0 },
signingKeyId
})
const manifestBytes =
buildAgentBundle.canonicalManifestBytes(manifest)
writeFileSync(join(directory, 'manifest.json'), manifestBytes)
writeFileSync(
join(directory, 'manifest.sig'),
`${sign(
null,
buildAgentBundle.signaturePayload(manifestBytes),
privateKey
).toString('base64')}\n`
)
chmodSync(join(directory, 'manifest.json'), 0o644)
chmodSync(join(directory, 'manifest.sig'), 0o644)
return manifest
}
async function verifyBundle(): Promise<VerifiedAgentBundle> {
return verifyAgentBundleDirectory(directory, {
architecture: 'x64',
registry,
runtimeLock,
verificationEnvironment: 'test',
enforceFilesystemMode: process.platform !== 'win32'
})
}
beforeEach(() => {
directory = mkdtempSync(
join(tmpdir(), 'goodbuddy-runtime-agent-bundle-')
)
const generated = generateKeyPairSync('ed25519')
privateKey = generated.privateKey
registry = {
formatVersion: 1,
keys: [
{
keyId: 'generated-test-key',
publicKeySpkiBase64: generated.publicKey
.export({ format: 'der', type: 'spki' })
.toString('base64'),
environment: 'test'
}
],
revocations: []
}
const target = {
archive: 'node-v24.19.0-linux.tar.gz',
sha256: 'a'.repeat(64),
binaryPath: 'node/bin/node',
licensePath: 'node/LICENSE'
}
runtimeLock = {
formatVersion: 1,
agentVersion: '0.11.0',
protocol: { major: 1, minor: 0 },
node: {
version: '24.19.0',
source: 'https://nodejs.org/dist/v24.19.0/',
targets: {
'linux-x64': target,
'linux-arm64': target
}
},
koffi: { version: '3.1.4' }
}
})
afterEach(() => {
rmSync(directory, { recursive: true, force: true })
})
describe('runtime and build-time Agent verifier parity', () => {
it('accepts equivalent registry whitespace and canonicalizes validated keys', () => {
const canonical = canonicalAgentReleaseKeyRegistryBytes(registry)
const crlf = Buffer.from(
canonical.toString('utf8').replace(/\n/gu, '\r\n'),
'utf8'
)
const parsed = parseAgentReleaseKeyRegistryBytes(crlf)
expect(parsed).toEqual(registry)
expect(canonicalAgentReleaseKeyRegistryBytes(parsed)).toEqual(
canonical
)
})
it('rejects malformed or non-Ed25519 registry keys', () => {
expect(() =>
parseAgentReleaseKeyRegistryBytes(
Buffer.from(JSON.stringify({
...registry,
keys: [{
...registry.keys[0],
publicKeySpkiBase64: 'AAAA'
}]
}))
)
).toThrow('contract is invalid')
})
it('uses the exact build-time canonical bytes and signature domain', async () => {
const manifest = writeBundle()
const manifestBytes = readFileSync(
join(directory, 'manifest.json')
)
expect(canonicalAgentManifestBytes(manifest)).toEqual(
buildAgentBundle.canonicalManifestBytes(manifest)
)
expect(agentManifestSignaturePayload(manifestBytes)).toEqual(
buildAgentBundle.signaturePayload(manifestBytes)
)
await expect(verifyBundle()).resolves.toMatchObject({
manifest: {
arch: 'x64',
protocol: { major: 1, minor: 0 }
}
})
})
it('rejects corrupt, undeclared, missing, and architecture-mismatched payloads', async () => {
writeBundle()
writeFileSync(join(directory, 'lib', 'agent.cjs'), 'corrupt')
await expect(verifyBundle()).rejects.toThrow(
/size mismatch|hash mismatch/u
)
rmSync(directory, { recursive: true, force: true })
directory = mkdtempSync(
join(tmpdir(), 'goodbuddy-runtime-agent-bundle-')
)
writeBundle()
writeFileSync(join(directory, 'undeclared.txt'), 'no')
await expect(verifyBundle()).rejects.toThrow(
'undeclared or missing files'
)
rmSync(join(directory, 'licenses', 'zod-MIT.txt'))
await expect(verifyBundle()).rejects.toThrow()
rmSync(directory, { recursive: true, force: true })
directory = mkdtempSync(
join(tmpdir(), 'goodbuddy-runtime-agent-bundle-')
)
writeBundle()
writeFileSync(join(directory, 'node'), elf('arm64'))
const manifest = JSON.parse(
readFileSync(join(directory, 'manifest.json'), 'utf8')
) as AgentBundleManifest
const nodeFile = manifest.files.find((file) => file.path === 'node')
if (!nodeFile) {
throw new Error('Fixture Node entry is missing')
}
nodeFile.sha256 = createHash('sha256')
.update(elf('arm64'))
.digest('hex')
const bytes = canonicalAgentManifestBytes(manifest)
writeFileSync(join(directory, 'manifest.json'), bytes)
writeFileSync(
join(directory, 'manifest.sig'),
`${sign(
null,
agentManifestSignaturePayload(bytes),
privateKey
).toString('base64')}\n`
)
await expect(verifyBundle()).rejects.toThrow(
'architecture mismatch'
)
})
it('refuses symlink payloads', async () => {
writeBundle()
const targetDirectory = join(directory, '.lib-target')
renameSync(
join(directory, 'lib'),
targetDirectory
)
symlinkSync(
targetDirectory,
join(directory, 'lib'),
process.platform === 'win32' ? 'junction' : 'dir'
)
await expect(verifyBundle()).rejects.toThrow(
/symlink|regular file/u
)
})
it('rejects non-canonical manifests and signature corruption', () => {
const manifest = writeBundle()
const canonical = canonicalAgentManifestBytes(manifest)
const nonCanonical = Buffer.from(JSON.stringify(manifest), 'utf8')
const nonCanonicalSignature = sign(
null,
agentManifestSignaturePayload(nonCanonical),
privateKey
)
expect(() =>
verifyAgentManifestSignature(
nonCanonical,
nonCanonicalSignature,
registry,
'test'
)
).toThrow('not in canonical deterministic form')
const signature = sign(
null,
agentManifestSignaturePayload(canonical),
privateKey
)
signature[0] = (signature[0] ?? 0) ^ 1
expect(() =>
verifyAgentManifestSignature(
canonical,
signature,
registry,
'test'
)
).toThrow('signature verification failed')
})
it('enforces key revocation and production keys', () => {
const manifest = writeBundle()
const manifestBytes = canonicalAgentManifestBytes(manifest)
const signature = sign(
null,
agentManifestSignaturePayload(manifestBytes),
privateKey
)
expect(() =>
verifyAgentManifestSignature(
manifestBytes,
signature,
{ ...registry, keys: [] }
)
).toThrow('production key registry is empty')
expect(() =>
verifyAgentManifestSignature(
manifestBytes,
signature,
{
...registry,
revocations: [
{
keyId: 'generated-test-key'
}
]
},
'test'
)
).toThrow('is revoked')
})
})