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
+9
View File
@@ -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(
+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 }
+76 -14
View File
@@ -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')
})
+58 -16
View File
@@ -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)