fix: make Agent updates reliable and visible
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

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 下载兼容性并在设置中显示更新状态;新安装默认连接本地模型,保留显式用户配置;魔法笔记待办数字不再改变侧栏菜单高度。
This commit is contained in:
mesalogo
2026-08-26 15:43:41 +08:00
parent f9137f0463
commit de64e9768a
33 changed files with 884 additions and 188 deletions
@@ -32,6 +32,8 @@ import type {
import {
agentManifestSignaturePayload,
canonicalAgentManifestBytes,
canonicalAgentReleaseKeyRegistryBytes,
parseAgentReleaseKeyRegistryBytes,
verifyAgentBundleDirectory,
verifyAgentManifestSignature,
type VerifiedAgentBundle
@@ -280,6 +282,35 @@ afterEach(() => {
})
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(
+31 -4
View File
@@ -60,11 +60,15 @@ export type VerifyBundledAgentOptions = {
export async function readAgentReleaseKeyRegistry(
filePath: string
): Promise<AgentReleaseKeyRegistry> {
const value = await readJsonMetadata(
const bytes = await readBoundedFile(
filePath,
MAXIMUM_METADATA_BYTES,
'Agent trusted key registry'
)
return parseAgentReleaseKeyRegistryBytes(
bytes,
'Agent trusted key registry'
)
return parseAgentReleaseKeyRegistry(value)
}
export async function readAgentRuntimeLock(
@@ -233,13 +237,36 @@ export function canonicalAgentManifestBytes(
export function canonicalAgentReleaseKeyRegistryBytes(
registry: AgentReleaseKeyRegistry
): Buffer {
const canonical = agentReleaseKeyRegistrySchema.parse(registry)
const canonical = parseAgentReleaseKeyRegistry(registry)
return Buffer.from(
`${JSON.stringify(canonical, null, 2)}\n`,
'utf8'
)
}
export function parseAgentReleaseKeyRegistryBytes(
bytes: Uint8Array,
description = 'Agent trusted key registry'
): AgentReleaseKeyRegistry {
let value: unknown
try {
value = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
) as unknown
} catch (error) {
throw new Error(`${description} is invalid JSON`, {
cause: error
})
}
try {
return parseAgentReleaseKeyRegistry(value)
} catch (error) {
throw new Error(`${description} contract is invalid`, {
cause: error
})
}
}
export function agentManifestSignaturePayload(
manifestBytes: Uint8Array
): Buffer {
@@ -451,7 +478,7 @@ function importEd25519PublicKey(key: AgentReleaseKey) {
return publicKey
}
function parseAgentReleaseKeyRegistry(
export function parseAgentReleaseKeyRegistry(
value: unknown
): AgentReleaseKeyRegistry {
const registry = parseContract(
@@ -243,7 +243,10 @@ const registry: AgentReleaseKeyRegistry = {
formatVersion: 1,
keys: [{
keyId: 'test-key',
publicKeySpkiBase64: 'AAAA',
publicKeySpkiBase64: Buffer.from(
'302a300506032b6570032100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f',
'hex'
).toString('base64'),
environment: 'test'
}],
revocations: []
@@ -1,7 +1,6 @@
import { createHash, randomUUID } from 'node:crypto'
import { lstat, readFile } from 'node:fs/promises'
import {
agentReleaseKeyRegistrySchema,
type AgentArchitecture,
type AgentBundleManifest,
type AgentReleaseKeyRegistry
@@ -31,6 +30,8 @@ import {
import {
assertAgentManifestMatchesRuntimeLock,
canonicalAgentReleaseKeyRegistryBytes,
parseAgentReleaseKeyRegistry,
parseAgentReleaseKeyRegistryBytes,
readAgentReleaseKeyRegistry,
readAgentRuntimeLock,
verifyAgentManifestSignature,
@@ -599,7 +600,7 @@ export class AgentInstallationManager {
)
}
const canonicalRegistry =
agentReleaseKeyRegistrySchema.parse(loaded.registry)
parseAgentReleaseKeyRegistry(loaded.registry)
return {
bundle: loaded.bundle,
registry: canonicalRegistry,
@@ -627,7 +628,7 @@ export class AgentInstallationManager {
verificationEnvironment: this.#verificationEnvironment
}
)
const canonicalRegistry = agentReleaseKeyRegistrySchema.parse(
const canonicalRegistry = parseAgentReleaseKeyRegistry(
registry
)
return {
@@ -648,14 +649,14 @@ export class AgentInstallationManager {
},
signal: AbortSignal
): Promise<AgentInstallationIdentity> {
const [canonicalRegistryBytes, runtimeLock] = await Promise.all([
readFile(this.#resourcePaths.keyRegistryPath),
const [registry, runtimeLock] = await Promise.all([
readAgentReleaseKeyRegistry(
this.#resourcePaths.keyRegistryPath
),
readAgentRuntimeLock(this.#resourcePaths.runtimeLockPath)
])
const registry = parseCanonicalAgentReleaseKeyRegistry(
canonicalRegistryBytes,
'Agent trusted key registry'
)
const canonicalRegistryBytes =
canonicalAgentReleaseKeyRegistryBytes(registry)
let metadataSftp: StagedSftp | undefined
let installationSftp: StagedSftp | undefined
@@ -706,7 +707,7 @@ export class AgentInstallationManager {
signal
)
const remoteReleaseKeyRegistry =
parseCanonicalAgentReleaseKeyRegistry(
parseAgentReleaseKeyRegistryBytes(
remoteReleaseKeyBytes,
'Installed Agent release-key registry'
)
@@ -851,21 +852,6 @@ function parseJsonBytes(
}
}
function parseCanonicalAgentReleaseKeyRegistry(
value: Buffer,
description: string
): AgentReleaseKeyRegistry {
const registry = agentReleaseKeyRegistrySchema.parse(
parseJsonBytes(value, description)
)
const canonicalBytes =
canonicalAgentReleaseKeyRegistryBytes(registry)
if (!canonicalBytes.equals(value)) {
throw new Error(`${description} is not canonical`)
}
return registry
}
function decodeDetachedSignature(
value: Buffer,
description: string
+133 -26
View File
@@ -32,7 +32,6 @@ import {
} from '../../shared/agent-protocol/contracts'
import {
agentArchitectureSchema,
agentReleaseKeyRegistrySchema,
type AgentArchitecture,
type AgentReleaseKeyRegistry
} from '../../shared/agent-installation-contracts'
@@ -49,7 +48,7 @@ import type {
AgentInstallationBundleLoader
} from './agent-installation-manager'
import {
canonicalAgentReleaseKeyRegistryBytes
readAgentReleaseKeyRegistry
} from './agent-bundle-verifier'
import type {
RemoteRuntimeInstallationBundleLoader,
@@ -84,6 +83,18 @@ type InstalledRecord = {
verified: VerifiedAgentPackage
}
type CatalogState =
| {
state: 'available'
checkedAt: string
catalog: AgentPackageCatalog
}
| {
state: 'unavailable'
checkedAt: string
error: string
}
export type AgentPackageManagerOptions = {
userDataPath: string
desktopVersion: string
@@ -111,6 +122,7 @@ export class AgentPackageManager {
>()
readonly #leaseCounts = new Map<string, number>()
readonly #pendingRemovals = new Set<string>()
#catalogState?: CatalogState
constructor(options: AgentPackageManagerOptions) {
this.#rootDirectory = resolve(
@@ -170,11 +182,17 @@ export class AgentPackageManager {
} catch {
state = 'invalid'
}
const latestVersion = this.#latestVersion(architecture)
return {
platform: 'linux' as const,
architecture,
state,
version: record?.verified.descriptor.version ?? null,
latestVersion,
updateAvailable: this.#updateAvailable(
latestVersion,
record
),
remoteRuntimeVersion:
record?.verified.descriptor.remoteRuntime.version ?? null,
agentProtocol:
@@ -184,13 +202,15 @@ export class AgentPackageManager {
)
return agentPackageInventorySchema.parse({
checkedAt: this.#now().toISOString(),
catalog: this.#publicCatalogState(),
entries
})
}
getSnapshot(
async getSnapshot(
options: { refresh?: boolean } = {}
): Promise<AgentPackageInventory> {
await this.#checkCatalog()
return this.getInventory(options)
}
@@ -229,24 +249,34 @@ export class AgentPackageManager {
totalBytes: null
})
const source = await this.#getUpdateSource()
const catalog = await this.#loadCatalog(source)
const catalog = await this.#loadCatalogAndRemember(source)
const entry = selectLatestCompatibleEntry(
catalog,
architecture,
this.#desktopVersion
)
const current = await this.#loadInstalled(architecture)
const comparison = current
? compareSemanticVersions(
entry.version,
current.verified.descriptor.version
)
: 1
if (
current &&
compareSemanticVersions(
entry.version,
current.verified.descriptor.version
) < 0
comparison < 0
) {
throw new Error(
'Agent 发布目录不能降级已安装的兼容版本'
)
}
if (
current &&
comparison === 0 &&
current.archiveSha256 === entry.sha256
) {
return this.getInventory()
}
const staging = await this.#createStaging(architecture)
const archivePath = join(staging, entry.archive)
try {
@@ -710,6 +740,90 @@ export class AgentPackageManager {
return catalog
}
async #loadCatalogAndRemember(
source: UpdateSource
): Promise<AgentPackageCatalog> {
const checkedAt = this.#now().toISOString()
try {
const catalog = await this.#loadCatalog(source)
this.#catalogState = {
state: 'available',
checkedAt,
catalog
}
return catalog
} catch (error) {
this.#catalogState = {
state: 'unavailable',
checkedAt,
error: boundedErrorMessage(error)
}
throw error
}
}
async #checkCatalog(): Promise<void> {
try {
await this.#loadCatalogAndRemember(
await this.#getUpdateSource()
)
} catch {
// The failed catalog state is already recorded. Local packages remain usable.
}
}
#latestVersion(
architecture: AgentArchitecture
): string | null {
if (this.#catalogState?.state !== 'available') {
return null
}
try {
return selectLatestCompatibleEntry(
this.#catalogState.catalog,
architecture,
this.#desktopVersion
).version
} catch {
return null
}
}
#updateAvailable(
latestVersion: string | null,
installed: InstalledRecord | undefined
): boolean {
return installed !== undefined &&
latestVersion !== null &&
compareSemanticVersions(
latestVersion,
installed.verified.descriptor.version
) > 0
}
#publicCatalogState():
AgentPackageInventory['catalog'] {
if (this.#catalogState === undefined) {
return {
state: 'not-checked',
checkedAt: null,
error: null
}
}
if (this.#catalogState.state === 'available') {
return {
state: 'available',
checkedAt: this.#catalogState.checkedAt,
error: null
}
}
return {
state: 'unavailable',
checkedAt: this.#catalogState.checkedAt,
error: this.#catalogState.error
}
}
async #fetchMirrorCatalog(): Promise<[Buffer, Buffer]> {
const pointerBytes = await this.#fetchBounded(
`${MIRROR_ROOT}latest.json`
@@ -874,7 +988,7 @@ export class AgentPackageManager {
}
#loadTrustedRegistry(): Promise<AgentReleaseKeyRegistry> {
this.#trustedRegistry ??= readCanonicalRegistry(
this.#trustedRegistry ??= readAgentReleaseKeyRegistry(
this.#keyRegistryPath
)
return this.#trustedRegistry
@@ -1010,23 +1124,6 @@ function assertCanonicalDownload(
}
}
async function readCanonicalRegistry(
filePath: string
): Promise<AgentReleaseKeyRegistry> {
const bytes = await readFile(filePath)
const registry = agentReleaseKeyRegistrySchema.parse(
JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
)
)
if (
!canonicalAgentReleaseKeyRegistryBytes(registry).equals(bytes)
) {
throw new Error('Agent 签名公钥目录不是规范格式')
}
return registry
}
function parseCanonicalCatalog(bytes: Buffer): AgentPackageCatalog {
const catalog = agentPackageCatalogSchema.parse(
JSON.parse(
@@ -1125,6 +1222,16 @@ function verifyCatalogSignature(
}
}
function boundedErrorMessage(error: unknown): string {
const message = error instanceof Error
? error.message
: String(error)
return (
message.trim().slice(0, 2_000) ||
'Agent 发布目录检查失败'
)
}
async function copyAndHashBoundedRegularFile(
sourcePath: string,
destinationPath: string,
@@ -38,12 +38,12 @@ import {
canonicalJson
} from '../../shared/agent-protocol/canonical'
import {
agentReleaseKeyRegistrySchema,
type AgentArchitecture,
type AgentReleaseKeyRegistry
} from '../../shared/agent-installation-contracts'
import {
canonicalAgentReleaseKeyRegistryBytes,
parseAgentReleaseKeyRegistry,
parseAgentReleaseKeyRegistryBytes,
verifyAgentBundleDirectory,
type AgentVerificationEnvironment,
type VerifiedAgentBundle
@@ -156,8 +156,11 @@ export async function verifyExtractedAgentPackage(options: {
'Agent package is not compatible with this GoodBuddy version'
)
}
const registry = parseCanonicalRegistry(registryBytes)
const trustedRegistry = agentReleaseKeyRegistrySchema.parse(
const registry = parseAgentReleaseKeyRegistryBytes(
registryBytes,
'Agent package key registry'
)
const trustedRegistry = parseAgentReleaseKeyRegistry(
options.trustedRegistry
)
verifyDescriptorSignature(
@@ -545,20 +548,6 @@ function parseCanonicalDescriptor(
return descriptor
}
function parseCanonicalRegistry(bytes: Buffer) {
const registry = agentReleaseKeyRegistrySchema.parse(
JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
)
)
if (
!canonicalAgentReleaseKeyRegistryBytes(registry).equals(bytes)
) {
throw new Error('Agent package key registry is not canonical')
}
return registry
}
function verifyDescriptorSignature(
descriptor: AgentPackageDescriptor,
descriptorBytes: Buffer,
@@ -236,7 +236,10 @@ const registry: AgentReleaseKeyRegistry = {
formatVersion: 1,
keys: [{
keyId: 'test-key',
publicKeySpkiBase64: 'AAAA',
publicKeySpkiBase64: Buffer.from(
'302a300506032b6570032100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f',
'hex'
).toString('base64'),
environment: 'test'
}],
revocations: []
@@ -35,6 +35,9 @@ import type {
SshConnectionPoolTarget
} from '../ssh/ssh-connection-pool'
import { settleBoundedly } from './bounded-settlement'
import {
parseAgentReleaseKeyRegistryBytes
} from './agent-bundle-verifier'
import { isMissingPathError } from './path-errors'
const MAXIMUM_INSTALLATION_FILES = 200
@@ -711,18 +714,13 @@ export class RemoteRuntimeInstallationManager {
metadataSftp.readFile(RELEASE_KEYS_PATH, signal),
metadataSftp.readFile(RUNTIME_LOCK_PATH, signal)
])
const remoteReleaseKeyRegistryBytes = canonicalJsonBytes(
remoteReleaseKeyBytes,
agentReleaseKeyRegistrySchema,
const remoteReleaseKeyRegistryBytes =
Buffer.from(remoteReleaseKeyBytes)
const remoteReleaseKeyRegistry =
parseAgentReleaseKeyRegistryBytes(
remoteReleaseKeyRegistryBytes,
'Installed Runtime release-key registry'
)
const remoteReleaseKeyRegistry =
agentReleaseKeyRegistrySchema.parse(
parseJsonBytes(
remoteReleaseKeyRegistryBytes,
'Installed Runtime release-key registry'
)
)
if (!remoteRuntimeLockFileBytes.equals(remoteRuntimeLockBytes)) {
throw new Error(
'Installed Runtime lock does not match this GoodBuddy build'
@@ -173,6 +173,23 @@ describe('remote Runtime resource loader', () => {
})
})
it('accepts equivalent key-registry formatting and returns canonical bytes', async () => {
const fixture = await createFixture()
await writeFile(
fixture.paths.keyRegistryPath,
Buffer.from(
canonical(registry).toString('utf8').replace(/\n/gu, '\r\n')
)
)
await expect(
loadRemoteRuntimeVerificationMetadata(fixture.paths)
).resolves.toMatchObject({
releaseKeyRegistry: registry,
canonicalReleaseKeyRegistryBytes: canonical(registry)
})
})
it('rejects a symlinked digest directory', async () => {
const fixture = await createFixture({ digestDirectories: 0 })
const target = join(fixture.root, 'target')
@@ -226,11 +243,6 @@ describe('remote Runtime resource loader', () => {
bytes: Buffer.from('not json\n'),
error: 'invalid JSON'
},
{
name: 'noncanonical',
bytes: Buffer.from(JSON.stringify(registry)),
error: 'not canonical'
},
{
name: 'oversize',
bytes: Buffer.alloc(1024 * 1024 + 1, 0x20),
@@ -11,7 +11,6 @@ import {
type VerifyRuntimeBundleOptions
} from '../../shared/node/runtime-bundle-verifier'
import {
agentReleaseKeyRegistrySchema,
type AgentArchitecture,
type AgentReleaseKeyRegistry
} from '../../shared/agent-installation-contracts'
@@ -30,6 +29,9 @@ import {
getBundledRemoteRuntimeRoot,
type BundledRemoteRuntimeResourcePaths
} from './bundled-remote-runtime-resources'
import {
parseAgentReleaseKeyRegistry
} from './agent-bundle-verifier'
import { isMissingPathError } from './path-errors'
const MAXIMUM_METADATA_BYTES = 1024 * 1024
@@ -106,8 +108,9 @@ export async function loadRemoteRuntimeVerificationMetadata(
const [keyRegistry, runtimeLock] = await Promise.all([
readCanonicalMetadata(
paths.keyRegistryPath,
agentReleaseKeyRegistrySchema,
'Runtime release-key registry'
{ parse: parseAgentReleaseKeyRegistry },
'Runtime release-key registry',
true
),
readCanonicalMetadata(
paths.runtimeLockPath,
@@ -139,7 +142,8 @@ function assertSafeVerificationInjection(
async function readCanonicalMetadata<T>(
filePathInput: string,
schema: { parse(value: unknown): T },
label: string
label: string,
acceptEquivalentJsonFormatting = false
): Promise<CanonicalMetadata<T>> {
const filePath = resolve(filePathInput)
const pathStat = await lstat(filePath)
@@ -212,7 +216,10 @@ async function readCanonicalMetadata<T>(
`${JSON.stringify(value, null, 2)}\n`,
'utf8'
)
if (!canonicalBytes.equals(bytes)) {
if (
!acceptEquivalentJsonFormatting &&
!canonicalBytes.equals(bytes)
) {
throw new Error(`${label} is not canonical`)
}
return { bytes: canonicalBytes, value }