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
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:
@@ -1091,12 +1091,19 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
}
|
||||
const agentPackageInventory = {
|
||||
checkedAt: '2030-01-01T00:00:00.000Z',
|
||||
catalog: {
|
||||
state: 'available',
|
||||
checkedAt: '2030-01-01T00:00:00.000Z',
|
||||
error: null
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
state: 'verified',
|
||||
version: '0.11.2',
|
||||
latestVersion: '0.11.4',
|
||||
updateAvailable: true,
|
||||
remoteRuntimeVersion: '1.18.9',
|
||||
agentProtocol: { major: 2, minor: 0 }
|
||||
},
|
||||
@@ -1105,6 +1112,8 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
architecture: 'arm64',
|
||||
state: 'not-downloaded',
|
||||
version: null,
|
||||
latestVersion: '0.11.4',
|
||||
updateAvailable: false,
|
||||
remoteRuntimeVersion: null,
|
||||
agentProtocol: null
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -80,6 +80,63 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('RuntimeSettingsStore', () => {
|
||||
it('seeds a local provider-neutral model connection', async () => {
|
||||
const { store } = await createStore()
|
||||
|
||||
await expect(store.getPublicSettings()).resolves.toMatchObject({
|
||||
modelBaseUrl: 'http://127.0.0.1:11434/v1',
|
||||
modelName: 'qwen3',
|
||||
modelProtocol: 'openai-chat-completions',
|
||||
modelAuthentication: 'none',
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
modelName: 'qwen3',
|
||||
authentication: 'none'
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces only the untouched legacy BigToken seed', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(settings())
|
||||
const version19 = JSON.parse(
|
||||
await readFile(filePath, 'utf8')
|
||||
) as Record<string, unknown>
|
||||
version19.version = 19
|
||||
await writeFile(filePath, JSON.stringify(version19), 'utf8')
|
||||
|
||||
const migrated = new RuntimeSettingsStore(filePath, cipher, {})
|
||||
await expect(migrated.getPublicSettings()).resolves.toMatchObject({
|
||||
modelBaseUrl: 'http://127.0.0.1:11434/v1',
|
||||
modelName: 'qwen3',
|
||||
modelAuthentication: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves an explicitly credentialed BigToken connection', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
settings({
|
||||
apiKey: { action: 'replace', value: 'explicit-key' }
|
||||
})
|
||||
)
|
||||
const version19 = JSON.parse(
|
||||
await readFile(filePath, 'utf8')
|
||||
) as Record<string, unknown>
|
||||
version19.version = 19
|
||||
await writeFile(filePath, JSON.stringify(version19), 'utf8')
|
||||
|
||||
const migrated = new RuntimeSettingsStore(filePath, cipher, {})
|
||||
await expect(migrated.getResolvedSettings()).resolves.toMatchObject({
|
||||
modelBaseUrl: 'https://bigtoken.ai',
|
||||
modelName: 'sonnet-5',
|
||||
apiKey: 'explicit-key'
|
||||
})
|
||||
})
|
||||
|
||||
it('restores an exact credential-bearing snapshot after a failed activation', async () => {
|
||||
const { store } = await createStore()
|
||||
await store.update(
|
||||
@@ -382,7 +439,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
})
|
||||
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
modelProtocol: 'anthropic-messages',
|
||||
modelProtocol: 'openai-chat-completions',
|
||||
deepseekHarnessModelProfile: {
|
||||
id: 'goodbuddy-platform-harness',
|
||||
name: '管理员预置模型',
|
||||
@@ -398,7 +455,9 @@ describe('RuntimeSettingsStore', () => {
|
||||
|
||||
const publicSettings = await store.getPublicSettings()
|
||||
expect(JSON.stringify(publicSettings)).not.toContain(apiKey)
|
||||
expect(publicSettings.modelProtocol).toBe('anthropic-messages')
|
||||
expect(publicSettings.modelProtocol).toBe(
|
||||
'openai-chat-completions'
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -481,7 +540,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
const persisted = JSON.parse(
|
||||
await readFile(filePath, 'utf8')
|
||||
) as Record<string, unknown>
|
||||
expect(persisted.version).toBe(19)
|
||||
expect(persisted.version).toBe(20)
|
||||
expect(persisted).not.toHaveProperty(
|
||||
'deepseekHarnessBinaryPath'
|
||||
)
|
||||
@@ -760,7 +819,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as {
|
||||
version: number
|
||||
}
|
||||
expect(persisted.version).toBe(19)
|
||||
expect(persisted.version).toBe(20)
|
||||
})
|
||||
|
||||
it('migrates version 11 and removes the obsolete intranet toggle', async () => {
|
||||
@@ -780,7 +839,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
version: number
|
||||
intranetCompatibilityEnabled?: boolean
|
||||
}
|
||||
expect(persisted.version).toBe(19)
|
||||
expect(persisted.version).toBe(20)
|
||||
expect(persisted).not.toHaveProperty('intranetCompatibilityEnabled')
|
||||
})
|
||||
|
||||
@@ -1058,7 +1117,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
(JSON.parse(await readFile(filePath, 'utf8')) as {
|
||||
version: number
|
||||
}).version
|
||||
).toBe(19)
|
||||
).toBe(20)
|
||||
})
|
||||
|
||||
it('migrates version 13 with reranking disabled by default', async () => {
|
||||
@@ -1565,7 +1624,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
version: number
|
||||
modelProfiles: Array<Record<string, unknown>>
|
||||
}
|
||||
expect(persisted.version).toBe(19)
|
||||
expect(persisted.version).toBe(20)
|
||||
expect(persisted.modelProfiles).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: imageId,
|
||||
@@ -1653,7 +1712,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mix an environment key with a stored base URL', async () => {
|
||||
it('keeps provider-specific environment aliases for existing deployments', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
settings({
|
||||
@@ -1663,11 +1722,14 @@ describe('RuntimeSettingsStore', () => {
|
||||
)
|
||||
|
||||
const environmentStore = new RuntimeSettingsStore(filePath, cipher, {
|
||||
GOODBUDDY_BIGTOKEN_API_KEY: 'YOUR_API_KEY_HERE'
|
||||
GOODBUDDY_BIGTOKEN_API_KEY: 'legacy-key',
|
||||
GOODBUDDY_BIGTOKEN_BASE_URL: 'https://legacy.example',
|
||||
GOODBUDDY_BIGTOKEN_MODEL: 'legacy-model'
|
||||
})
|
||||
await expect(environmentStore.getResolvedSettings()).resolves.toMatchObject({
|
||||
apiKey: 'YOUR_API_KEY_HERE',
|
||||
modelBaseUrl: 'https://bigtoken.ai'
|
||||
apiKey: 'legacy-key',
|
||||
modelBaseUrl: 'https://legacy.example',
|
||||
modelName: 'legacy-model'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1724,7 +1786,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers generic model environment variables over legacy fallbacks', async () => {
|
||||
it('prefers generic model environment variables over legacy aliases', async () => {
|
||||
const { filePath } = await createStore()
|
||||
const store = new RuntimeSettingsStore(filePath, cipher, {
|
||||
GOODBUDDY_MODEL_API_KEY: 'generic-key',
|
||||
@@ -1864,7 +1926,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
unknown
|
||||
>
|
||||
expect(saved).toMatchObject({
|
||||
version: 19,
|
||||
version: 20,
|
||||
provider: 'model',
|
||||
continueBinaryPath: '',
|
||||
continueMode: 'chat',
|
||||
@@ -2143,7 +2205,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
version: number
|
||||
modelProfiles: Array<Record<string, unknown>>
|
||||
}
|
||||
expect(persisted.version).toBe(19)
|
||||
expect(persisted.version).toBe(20)
|
||||
expect(persisted.modelProfiles[0]).not.toHaveProperty('credential')
|
||||
})
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ const storedEmbeddingConnectionSchema = z.discriminatedUnion('kind', [
|
||||
.strict()
|
||||
])
|
||||
|
||||
const storedSettingsSchema = version18StoredSettingsSchema
|
||||
const version19StoredSettingsSchema = version18StoredSettingsSchema
|
||||
.omit({ version: true })
|
||||
.extend({
|
||||
version: z.literal(19),
|
||||
@@ -270,6 +270,12 @@ const storedSettingsSchema = version18StoredSettingsSchema
|
||||
activeEmbeddingConnectionId: z.string().uuid()
|
||||
})
|
||||
|
||||
const storedSettingsSchema = version19StoredSettingsSchema
|
||||
.omit({ version: true })
|
||||
.extend({
|
||||
version: z.literal(20)
|
||||
})
|
||||
|
||||
type StoredSettings = z.infer<typeof storedSettingsSchema>
|
||||
export type RuntimeSettingsRollback = {
|
||||
publicSettings: RuntimeSettings
|
||||
@@ -281,6 +287,9 @@ type Version17StoredSettings = z.infer<
|
||||
type Version18StoredSettings = z.infer<
|
||||
typeof version18StoredSettingsSchema
|
||||
>
|
||||
type Version19StoredSettings = z.infer<
|
||||
typeof version19StoredSettingsSchema
|
||||
>
|
||||
type Version16StoredSettings = z.infer<
|
||||
typeof version16StoredSettingsSchema
|
||||
>
|
||||
@@ -449,7 +458,7 @@ type ResolvedModelCredential = {
|
||||
}
|
||||
|
||||
const defaultSettings: StoredSettings = {
|
||||
version: 19,
|
||||
version: 20,
|
||||
provider: defaultRuntimeSettings.provider,
|
||||
modelProfiles: [
|
||||
{
|
||||
@@ -653,7 +662,7 @@ function migrateVersion17(
|
||||
function migrateVersion18(
|
||||
settings: Version18StoredSettings
|
||||
): StoredSettings {
|
||||
return {
|
||||
return migrateVersion19({
|
||||
...settings,
|
||||
version: 19,
|
||||
embeddingConnections: [
|
||||
@@ -673,6 +682,36 @@ function migrateVersion18(
|
||||
}
|
||||
],
|
||||
activeEmbeddingConnectionId: legacyEmbeddingConnectionId
|
||||
})
|
||||
}
|
||||
|
||||
function migrateVersion19(
|
||||
settings: Version19StoredSettings
|
||||
): StoredSettings {
|
||||
return {
|
||||
...settings,
|
||||
version: 20,
|
||||
modelProfiles: settings.modelProfiles.map((profile) =>
|
||||
profile.id === defaultModelProfileId &&
|
||||
profile.name === '默认模型' &&
|
||||
profile.baseUrl === 'https://bigtoken.ai' &&
|
||||
profile.modelName === 'sonnet-5' &&
|
||||
profile.protocol === 'anthropic-messages' &&
|
||||
profile.authentication === 'api-key' &&
|
||||
profile.supportsImageInput === false &&
|
||||
profile.imageGenerationQuality === 'auto' &&
|
||||
profile.contextWindowTokens === undefined &&
|
||||
profile.credential === undefined
|
||||
? {
|
||||
...profile,
|
||||
baseUrl: defaultRuntimeSettings.modelBaseUrl,
|
||||
modelName: defaultRuntimeSettings.modelName,
|
||||
protocol: defaultRuntimeSettings.modelProtocol,
|
||||
authentication:
|
||||
defaultRuntimeSettings.modelAuthentication
|
||||
}
|
||||
: profile
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -995,13 +1034,17 @@ export class RuntimeSettingsStore {
|
||||
const parsed: unknown = JSON.parse(contents)
|
||||
assertSupportedSettingsVersion(
|
||||
parsed,
|
||||
19,
|
||||
20,
|
||||
(version) =>
|
||||
`当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试`
|
||||
)
|
||||
const current = storedSettingsSchema.safeParse(parsed)
|
||||
const version19 =
|
||||
version19StoredSettingsSchema.safeParse(parsed)
|
||||
if (current.success) {
|
||||
this.settings = current.data
|
||||
} else if (version19.success) {
|
||||
this.settings = migrateVersion19(version19.data)
|
||||
} else {
|
||||
const version18 =
|
||||
version18StoredSettingsSchema.safeParse(parsed)
|
||||
@@ -1377,10 +1420,7 @@ export class RuntimeSettingsStore {
|
||||
settings.modelProfiles.find(
|
||||
(profile) => profile.id === settings.defaultModelProfileId
|
||||
) ?? settings.modelProfiles[0]
|
||||
const environmentApiKey =
|
||||
defaultProfile?.authentication === 'api-key'
|
||||
? this.getEnvironmentApiKey()
|
||||
: undefined
|
||||
const environmentApiKey = this.getEnvironmentApiKey()
|
||||
return new Map(
|
||||
settings.modelProfiles.map((profile) => {
|
||||
const environmentManaged =
|
||||
@@ -1401,11 +1441,11 @@ export class RuntimeSettingsStore {
|
||||
profile.id,
|
||||
{
|
||||
activeApiKey:
|
||||
profile.authentication === 'api-key'
|
||||
? environmentManaged
|
||||
? environmentApiKey
|
||||
: storedApiKey
|
||||
: undefined,
|
||||
environmentManaged
|
||||
? environmentApiKey
|
||||
: profile.authentication === 'api-key'
|
||||
? storedApiKey
|
||||
: undefined,
|
||||
configured: environmentManaged || Boolean(storedApiKey),
|
||||
source
|
||||
}
|
||||
@@ -1457,7 +1497,9 @@ export class RuntimeSettingsStore {
|
||||
baseUrl,
|
||||
model,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication,
|
||||
authentication: environmentManaged
|
||||
? 'api-key'
|
||||
: profile.authentication,
|
||||
supportsImageInput: profile.supportsImageInput,
|
||||
contextWindowTokens: profile.contextWindowTokens,
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
@@ -2228,7 +2270,7 @@ export class RuntimeSettingsStore {
|
||||
|
||||
const next: StoredSettings = {
|
||||
...current,
|
||||
version: 19,
|
||||
version: 20,
|
||||
provider: input.provider,
|
||||
modelProfiles,
|
||||
defaultModelProfileId,
|
||||
@@ -2289,7 +2331,7 @@ export class RuntimeSettingsStore {
|
||||
const current = await this.load()
|
||||
const next: StoredSettings = {
|
||||
...current,
|
||||
version: 19,
|
||||
version: 20,
|
||||
runtimeCustomization: parsed
|
||||
}
|
||||
await writeJsonFileAtomically(this.filePath, next)
|
||||
|
||||
Reference in New Issue
Block a user