import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { runtimeSettingsInputSchema, type RuntimeSettingsInput } from '../shared/contracts' import { RuntimeSettingsStore, type CredentialCipher } from './runtime-settings-store' const temporaryDirectories: string[] = [] const cipher: CredentialCipher = { isAvailable: () => true, encrypt: (value) => Buffer.from(`encrypted:${value}`), decrypt: (value) => value.toString().replace(/^encrypted:/, '') } function settings( overrides: Partial = {} ): RuntimeSettingsInput { return { provider: 'model', modelBaseUrl: 'https://bigtoken.ai', modelName: 'sonnet-5', modelProtocol: 'anthropic-messages', modelAuthentication: 'api-key', imageGenerationQuality: 'auto', opencodeBaseUrl: '', opencodeEmbedded: false, opencodeBinaryPath: '', opencodeConfigPath: '', continueBinaryPath: '', continueConfigPath: '', continueMode: 'chat', runtimeSandboxMode: 'auto', knowledgeEmbeddingEnabled: false, knowledgeEmbeddingBaseUrl: 'http://127.0.0.1:11434/v1/embeddings', knowledgeEmbeddingModel: 'nomic-embed-text', workspacePath: 'test-workspace', apiKey: { action: 'keep' }, toolApproval: 'always', ...overrides } } async function createStore( environment: NodeJS.ProcessEnv = {} ): Promise<{ filePath: string; store: RuntimeSettingsStore }> { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-settings-')) temporaryDirectories.push(directory) const filePath = join(directory, 'runtime-settings.json') return { filePath, store: new RuntimeSettingsStore(filePath, cipher, environment) } } afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }) ) ) }) describe('RuntimeSettingsStore', () => { it('configures bundled runtimes from the default model profile', async () => { const { store } = await createStore() await expect(store.getPublicSettings()).resolves.toMatchObject({ opencodeEmbedded: true, opencodeModelSource: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000001' }, continueModelSource: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000001' } }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ opencodeEmbedded: true, opencodeModelProfile: { id: '00000000-0000-4000-8000-000000000001' }, continueModelProfile: { id: '00000000-0000-4000-8000-000000000001' } }) }) it('always enables bundled OpenCode when the Server address is blank', async () => { const { filePath, store } = await createStore({ GOODBUDDY_OPENCODE_EMBEDDED: 'false' }) await expect( store.update( settings({ opencodeBaseUrl: '', opencodeEmbedded: false }) ) ).resolves.toMatchObject({ opencodeBaseUrl: '', opencodeEmbedded: true }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ opencodeBaseUrl: '', opencodeEmbedded: true }) const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { opencodeEmbedded: boolean } expect(persisted.opencodeEmbedded).toBe(true) }) it('repairs version 11 embedded state and normalizes an external Server to platform mode', async () => { const { filePath, store } = await createStore() await store.update(settings()) const versionEleven = JSON.parse( await readFile(filePath, 'utf8') ) as { opencodeEmbedded: boolean } versionEleven.opencodeEmbedded = false await writeFile(filePath, JSON.stringify(versionEleven), 'utf8') const repaired = new RuntimeSettingsStore(filePath, cipher, {}) await expect(repaired.getPublicSettings()).resolves.toMatchObject({ opencodeEmbedded: true }) await expect( repaired.update( settings({ opencodeBaseUrl: 'https://opencode.example', opencodeEmbedded: true, opencodeModelSource: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000001' } }) ) ).resolves.toMatchObject({ opencodeBaseUrl: 'https://opencode.example', opencodeEmbedded: false, opencodeModelSource: { kind: 'platform' } }) }) it('rejects an explicit Runtime source that is missing after input merge', async () => { const { store } = await createStore() await expect( store.update( settings({ opencodeModelSource: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000099' } }) ) ).rejects.toThrow('OpenCode 引用的模型连接不存在') }) it('migrates untouched version 10 platform sources to the first compatible text profile', async () => { const { filePath, store } = await createStore() const imageId = '00000000-0000-4000-8000-000000000031' const textId = '00000000-0000-4000-8000-000000000032' await store.update( settings({ provider: 'auto', modelProfiles: [ { id: imageId, name: '默认图像模型', baseUrl: 'https://images.example/v1', modelName: 'image-model', protocol: 'openai-images-generations', authentication: 'api-key', imageGenerationQuality: 'high', apiKey: { action: 'clear' } }, { id: textId, name: '文本模型', baseUrl: 'https://text.example/v1', modelName: 'text-model', protocol: 'openai-chat-completions', authentication: 'none', imageGenerationQuality: 'auto', apiKey: { action: 'clear' } } ], defaultModelProfileId: imageId, opencodeModelSource: { kind: 'platform' }, continueModelSource: { kind: 'platform' }, opencodeEmbedded: false }) ) const versionTen = JSON.parse(await readFile(filePath, 'utf8')) as { version: number intranetCompatibilityEnabled?: boolean } versionTen.version = 10 versionTen.intranetCompatibilityEnabled = false await writeFile(filePath, JSON.stringify(versionTen), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migrated.getPublicSettings()).resolves.toMatchObject({ provider: 'model', opencodeEmbedded: true, opencodeModelSource: { kind: 'profile', profileId: textId }, continueModelSource: { kind: 'profile', profileId: textId } }) await expect(migrated.getResolvedSettings()).resolves.toMatchObject({ opencodeModelProfile: { id: textId }, continueModelProfile: { id: textId } }) }) it('preserves explicit and intentionally native version 10 Runtime sources', async () => { const { filePath, store } = await createStore() const profileId = '00000000-0000-4000-8000-000000000033' await store.update( settings({ modelProfiles: [ { id: profileId, name: '文本模型', baseUrl: 'https://text.example/v1', modelName: 'text-model', protocol: 'openai-responses', authentication: 'none', imageGenerationQuality: 'auto', apiKey: { action: 'clear' } } ], defaultModelProfileId: profileId, opencodeModelSource: { kind: 'profile', profileId }, continueModelSource: { kind: 'platform' }, continueConfigPath: '' }) ) const versionTen = JSON.parse(await readFile(filePath, 'utf8')) as { version: number continueConfigPath: string intranetCompatibilityEnabled?: boolean } versionTen.version = 10 versionTen.continueConfigPath = 'C:\\Users\\test\\.continue\\config.yaml' versionTen.intranetCompatibilityEnabled = false await writeFile(filePath, JSON.stringify(versionTen), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migrated.getPublicSettings()).resolves.toMatchObject({ opencodeModelSource: { kind: 'profile', profileId }, continueModelSource: { kind: 'platform' } }) }) it('retains version 10 platform sources when no text profile exists', async () => { const { filePath, store } = await createStore() const imageId = '00000000-0000-4000-8000-000000000034' await store.update( settings({ modelProfiles: [ { id: imageId, name: '图像模型', baseUrl: 'https://images.example/v1', modelName: 'image-model', protocol: 'openai-images-generations', authentication: 'api-key', imageGenerationQuality: 'medium', apiKey: { action: 'clear' } } ], defaultModelProfileId: imageId, opencodeModelSource: { kind: 'platform' }, continueModelSource: { kind: 'platform' }, opencodeEmbedded: false }) ) const versionTen = JSON.parse(await readFile(filePath, 'utf8')) as { version: number intranetCompatibilityEnabled?: boolean } versionTen.version = 10 versionTen.intranetCompatibilityEnabled = false await writeFile(filePath, JSON.stringify(versionTen), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migrated.getPublicSettings()).resolves.toMatchObject({ opencodeEmbedded: true, opencodeModelSource: { kind: 'platform' }, continueModelSource: { kind: 'platform' } }) }) it('migrates version 8 settings with smart routing disabled', async () => { const { filePath, store } = await createStore() await store.update(settings({ subagentSmartRoutingEnabled: true })) const versionEight = JSON.parse(await readFile(filePath, 'utf8')) as { version: number subagentSmartRoutingEnabled?: boolean } versionEight.version = 8 delete versionEight.subagentSmartRoutingEnabled await writeFile(filePath, JSON.stringify(versionEight), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migrated.getPublicSettings()).resolves.toMatchObject({ subagentSmartRoutingEnabled: false }) await migrated.update(settings()) const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { version: number } expect(persisted.version).toBe(13) }) it('migrates version 11 and removes the obsolete intranet toggle', async () => { const { filePath, store } = await createStore() await store.update(settings()) const versionEleven = JSON.parse(await readFile(filePath, 'utf8')) as { version: number intranetCompatibilityEnabled?: boolean } versionEleven.version = 11 versionEleven.intranetCompatibilityEnabled = false await writeFile(filePath, JSON.stringify(versionEleven), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, {}) await migrated.update(settings()) const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { version: number intranetCompatibilityEnabled?: boolean } expect(persisted.version).toBe(13) expect(persisted).not.toHaveProperty('intranetCompatibilityEnabled') }) it('keeps image input disabled when migrating version 12 profiles', async () => { const { filePath, store } = await createStore() await store.update(settings()) const versionTwelve = JSON.parse(await readFile(filePath, 'utf8')) as { version: number modelProfiles: Array> } versionTwelve.version = 12 for (const profile of versionTwelve.modelProfiles) { delete profile.supportsImageInput } await writeFile(filePath, JSON.stringify(versionTwelve), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migrated.getPublicSettings()).resolves.toMatchObject({ supportsImageInput: false, modelProfiles: [ expect.objectContaining({ supportsImageInput: false }) ] }) }) it('persists enabled image input for a model profile', async () => { const { filePath, store } = await createStore() const profileId = '00000000-0000-4000-8000-000000000035' await store.update( settings({ modelProfiles: [ { id: profileId, name: '视觉模型', baseUrl: 'https://model.example/v1', modelName: 'vision-model', protocol: 'openai-responses', authentication: 'none', supportsImageInput: true, imageGenerationQuality: 'auto', apiKey: { action: 'clear' } } ], defaultModelProfileId: profileId }) ) await expect(store.getPublicSettings()).resolves.toMatchObject({ supportsImageInput: true, modelProfiles: [ expect.objectContaining({ supportsImageInput: true }) ] }) const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { modelProfiles: Array> } expect(persisted.modelProfiles[0]).toMatchObject({ supportsImageInput: true }) }) it('accepts only supported image quality values', () => { for (const imageGenerationQuality of [ 'auto', 'low', 'medium', 'high' ] as const) { expect( runtimeSettingsInputSchema.safeParse( settings({ imageGenerationQuality }) ).success ).toBe(true) } expect( runtimeSettingsInputSchema.safeParse({ ...settings(), imageGenerationQuality: 'ultra' }).success ).toBe(false) }) it('allows HTTP embedding endpoints on any host', () => { expect( runtimeSettingsInputSchema.safeParse( settings({ knowledgeEmbeddingEnabled: true, knowledgeEmbeddingBaseUrl: 'http://10.7.0.23:11434/v1/embeddings', knowledgeEmbeddingModel: 'bge-m3' }) ).success ).toBe(true) expect( runtimeSettingsInputSchema.safeParse( settings({ knowledgeEmbeddingEnabled: true, knowledgeEmbeddingBaseUrl: 'http://example.com:11434/v1/embeddings' }) ).success ).toBe(true) }) it('encrypts an OpenAI-compatible embedding API key and binds it to the full endpoint', async () => { const { filePath, store } = await createStore() await store.update( settings({ knowledgeEmbeddingEnabled: true, knowledgeEmbeddingBaseUrl: 'https://vectors.example/custom/embeddings', knowledgeEmbeddingModel: 'vendor/embed-large', knowledgeEmbeddingApiKey: { action: 'replace', value: 'vector-secret-value' } }) ) const contents = await readFile(filePath, 'utf8') expect(contents).not.toContain('vector-secret-value') await expect(store.getResolvedSettings()).resolves.toMatchObject({ knowledgeEmbeddingBaseUrl: 'https://vectors.example/custom/embeddings', knowledgeEmbeddingModel: 'vendor/embed-large', knowledgeEmbeddingApiKey: 'vector-secret-value' }) await expect(store.getPublicSettings()).resolves.toMatchObject({ knowledgeEmbeddingApiKeyConfigured: true, knowledgeEmbeddingCredentialSource: 'encrypted' }) await expect( store.update( settings({ knowledgeEmbeddingBaseUrl: 'https://vectors.example/v1/embeddings', knowledgeEmbeddingApiKey: { action: 'keep' } }) ) ).rejects.toThrow('重新输入或清除 API Key') }) it('migrates version 6 Ollama origins to OpenAI-compatible embedding endpoints', async () => { const { filePath, store } = await createStore() await store.update(settings()) const persisted = JSON.parse(await readFile(filePath, 'utf8')) as Record< string, unknown > persisted.version = 6 persisted.knowledgeEmbeddingBaseUrl = 'http://127.0.0.1:11434' delete persisted.knowledgeEmbeddingCredential await writeFile(filePath, JSON.stringify(persisted), 'utf8') const migratedStore = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migratedStore.getPublicSettings()).resolves.toMatchObject({ knowledgeEmbeddingBaseUrl: 'http://127.0.0.1:11434/v1/embeddings', knowledgeEmbeddingApiKeyConfigured: false, imageGenerationQuality: 'auto', modelProfiles: [ expect.objectContaining({ imageGenerationQuality: 'auto' }) ] }) }) it('defaults image quality when migrating version 7 settings', async () => { const { filePath, store } = await createStore() await store.update( settings({ imageGenerationQuality: 'high' }) ) const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { version: number modelProfiles: Array> } persisted.version = 7 for (const profile of persisted.modelProfiles) { delete profile.imageGenerationQuality } await writeFile(filePath, JSON.stringify(persisted), 'utf8') const migratedStore = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migratedStore.getResolvedSettings()).resolves.toMatchObject({ imageGenerationQuality: 'auto' }) await expect(migratedStore.getPublicSettings()).resolves.toMatchObject({ imageGenerationQuality: 'auto', modelProfiles: [ expect.objectContaining({ imageGenerationQuality: 'auto' }) ] }) }) it('encrypts the API key and binds it to the configured origin', async () => { const { filePath, store } = await createStore() await store.update( settings({ apiKey: { action: 'replace', value: 'test-secret-value' } }) ) const contents = await readFile(filePath, 'utf8') expect(contents).not.toContain('test-secret-value') await expect(store.getResolvedSettings()).resolves.toMatchObject({ apiKey: 'test-secret-value', modelBaseUrl: 'https://bigtoken.ai' }) await expect( store.update( settings({ modelBaseUrl: 'https://other.example', apiKey: { action: 'keep' } }) ) ).rejects.toThrow('请重新输入或清除') }) it('does not infer image capability from the model name', async () => { const { store } = await createStore() await store.update( settings({ modelBaseUrl: 'https://bigtoken.ai', modelName: 'gpt-image-2', modelProtocol: 'anthropic-messages', apiKey: { action: 'replace', value: 'image-secret' } }) ) await expect(store.getPublicSettings()).resolves.toMatchObject({ modelBaseUrl: 'https://bigtoken.ai', modelName: 'gpt-image-2', modelProtocol: 'anthropic-messages', apiKeyConfigured: true, credentialSource: 'encrypted', modelProfiles: [ expect.objectContaining({ baseUrl: 'https://bigtoken.ai', modelName: 'gpt-image-2', protocol: 'anthropic-messages' }) ] }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelBaseUrl: 'https://bigtoken.ai', modelName: 'gpt-image-2', modelProtocol: 'anthropic-messages', apiKey: 'image-secret' }) }) it('uses the explicit protocol as the image-generation capability marker', async () => { const { filePath, store } = await createStore() const chatId = crypto.randomUUID() const imageId = crypto.randomUUID() await store.update( settings({ modelProfiles: [ { id: chatId, name: 'Chat', baseUrl: 'https://chat.example/v1', modelName: 'chat-model', protocol: 'openai-chat-completions', authentication: 'api-key', imageGenerationQuality: 'auto', apiKey: { action: 'replace', value: 'chat-secret' } }, { id: imageId, name: 'Custom Image', baseUrl: 'https://images.example/custom/v2', modelName: 'vendor/custom-renderer', protocol: 'openai-images-generations', authentication: 'api-key', imageGenerationQuality: 'high', apiKey: { action: 'replace', value: 'image-secret' } } ], defaultModelProfileId: imageId }) ) await expect(store.getPublicSettings()).resolves.toMatchObject({ modelBaseUrl: 'https://images.example/custom/v2', modelName: 'vendor/custom-renderer', modelProtocol: 'openai-images-generations', modelProfiles: [ expect.objectContaining({ id: chatId }), expect.objectContaining({ id: imageId, baseUrl: 'https://images.example/custom/v2', modelName: 'vendor/custom-renderer', protocol: 'openai-images-generations', imageGenerationQuality: 'high' }) ] }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelBaseUrl: 'https://images.example/custom/v2', modelName: 'vendor/custom-renderer', modelProtocol: 'openai-images-generations', imageGenerationQuality: 'high', apiKey: 'image-secret' }) const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { version: number modelProfiles: Array> } expect(persisted.version).toBe(13) expect(persisted.modelProfiles).toContainEqual( expect.objectContaining({ id: imageId, imageGenerationQuality: 'high' }) ) }) it('stores multiple encrypted model profiles and resolves runtime sources', async () => { const { filePath, store } = await createStore() const firstId = '00000000-0000-4000-8000-000000000011' const secondId = '00000000-0000-4000-8000-000000000012' await store.update( settings({ modelProfiles: [ { id: firstId, name: '工作模型', baseUrl: 'https://work.example', modelName: 'work-model', protocol: 'openai-responses', authentication: 'api-key', imageGenerationQuality: 'auto', apiKey: { action: 'replace', value: 'work-secret' } }, { id: secondId, name: '默认模型', baseUrl: 'https://default.example', modelName: 'default-model', protocol: 'openai-chat-completions', authentication: 'none', imageGenerationQuality: 'auto', apiKey: { action: 'keep' } } ], defaultModelProfileId: secondId, opencodeModelSource: { kind: 'profile', profileId: firstId }, continueModelSource: { kind: 'profile', profileId: secondId } }) ) await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelBaseUrl: 'https://default.example', modelName: 'default-model', modelAuthentication: 'none', opencodeModelProfile: { id: firstId, protocol: 'openai-responses', apiKey: 'work-secret' }, continueModelProfile: { id: secondId, protocol: 'openai-chat-completions', authentication: 'none' } }) const persisted = await readFile(filePath, 'utf8') expect(persisted).not.toContain('work-secret') const publicSettings = await store.getPublicSettings() expect(publicSettings.modelProfiles).toHaveLength(2) expect(JSON.stringify(publicSettings)).not.toContain('work-secret') await store.update( settings({ modelBaseUrl: 'https://default.example', modelName: 'updated-default-model' }) ) await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelName: 'updated-default-model', opencodeModelProfile: { id: firstId, apiKey: 'work-secret' } }) await expect(store.getPublicSettings()).resolves.toMatchObject({ modelProfiles: [ expect.objectContaining({ id: firstId }), expect.objectContaining({ id: secondId, modelName: 'updated-default-model' }) ] }) }) it('does not mix an environment key with a stored base URL', async () => { const { filePath, store } = await createStore() await store.update( settings({ modelBaseUrl: 'https://custom.example', apiKey: { action: 'replace', value: 'stored-test-key' } }) ) const environmentStore = new RuntimeSettingsStore(filePath, cipher, { GOODBUDDY_BIGTOKEN_API_KEY: 'YOUR_API_KEY_HERE' }) await expect(environmentStore.getResolvedSettings()).resolves.toMatchObject({ apiKey: 'YOUR_API_KEY_HERE', modelBaseUrl: 'https://bigtoken.ai' }) }) it('prefers generic model environment variables over legacy fallbacks', async () => { const { filePath } = await createStore() const store = new RuntimeSettingsStore(filePath, cipher, { GOODBUDDY_MODEL_API_KEY: 'generic-key', GOODBUDDY_MODEL_BASE_URL: 'https://generic.example', GOODBUDDY_MODEL_NAME: 'generic-model', GOODBUDDY_BIGTOKEN_API_KEY: 'legacy-key', GOODBUDDY_BIGTOKEN_BASE_URL: 'https://legacy.example', GOODBUDDY_BIGTOKEN_MODEL: 'legacy-model' }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ apiKey: 'generic-key', modelBaseUrl: 'https://generic.example', modelName: 'generic-model' }) }) it('migrates version 1 settings without losing the encrypted API key', async () => { const { filePath, store } = await createStore() const encryptedCredential = cipher .encrypt( JSON.stringify({ version: 1, apiKey: 'legacy-secret', origin: 'https://legacy.example' }) ) .toString('base64') await writeFile( filePath, JSON.stringify({ version: 1, provider: 'bigtoken', bigtokenBaseUrl: 'https://legacy.example', bigtokenModel: 'legacy-model', opencodeBaseUrl: '', opencodeEmbedded: false, continueCommand: 'cn', workspacePath: 'legacy-workspace', credential: { formatVersion: 1, scheme: 'electron-safe-storage', ciphertextBase64: encryptedCredential }, toolApproval: 'always' }), 'utf8' ) await expect(store.getResolvedSettings()).resolves.toMatchObject({ provider: 'model', modelBaseUrl: 'https://legacy.example', modelName: 'legacy-model', apiKey: 'legacy-secret' }) await store.update( settings({ modelBaseUrl: 'https://legacy.example', modelName: 'legacy-model' }) ) const saved = JSON.parse(await readFile(filePath, 'utf8')) as Record< string, unknown > expect(saved).toMatchObject({ version: 13, provider: 'model', continueBinaryPath: '', continueMode: 'chat', modelProfiles: [ expect.objectContaining({ baseUrl: 'https://legacy.example', modelName: 'legacy-model' }) ] }) expect(saved).not.toHaveProperty('bigtokenBaseUrl') await expect(store.getResolvedSettings()).resolves.toMatchObject({ apiKey: 'legacy-secret' }) }) it('migrates version 2 Continue commands to binary paths', async () => { const { filePath, store } = await createStore() await writeFile( filePath, JSON.stringify({ version: 2, provider: 'continue', modelBaseUrl: 'https://bigtoken.ai', modelName: 'sonnet-5', opencodeBaseUrl: '', opencodeEmbedded: false, continueCommand: 'C:\\Tools\\continue.exe', workspacePath: 'legacy-workspace', toolApproval: 'always' }), 'utf8' ) const publicSettings = await store.getPublicSettings() expect(publicSettings).toMatchObject({ continueBinaryPath: 'C:\\Tools\\continue.exe', continueConfigPath: '', opencodeBinaryPath: '', opencodeConfigPath: '' }) expect(publicSettings).not.toHaveProperty('continueCommand') }) it('migrates version 3 settings to read-only Continue chat mode', async () => { const { filePath, store } = await createStore() await writeFile( filePath, JSON.stringify({ version: 3, provider: 'continue', modelBaseUrl: 'https://bigtoken.ai', modelName: 'sonnet-5', opencodeBaseUrl: '', opencodeEmbedded: false, opencodeBinaryPath: '', opencodeConfigPath: '', continueBinaryPath: '', continueConfigPath: '', workspacePath: 'legacy-workspace', toolApproval: 'always' }), 'utf8' ) await expect(store.getPublicSettings()).resolves.toMatchObject({ provider: 'continue', continueMode: 'chat' }) }) it('treats the legacy default cn command as automatic detection', async () => { const { filePath, store } = await createStore() await writeFile( filePath, JSON.stringify({ version: 2, provider: 'continue', modelBaseUrl: 'https://bigtoken.ai', modelName: 'sonnet-5', continueCommand: 'cn', workspacePath: 'legacy-workspace', toolApproval: 'always' }), 'utf8' ) await expect(store.getPublicSettings()).resolves.toMatchObject({ continueBinaryPath: '' }) }) it('canonicalizes runtime paths and only accepts regular files', async () => { const { filePath, store } = await createStore() const directory = join(filePath, '..') const binaryPath = join(directory, 'continue-test-binary') const configPath = join(directory, 'continue-test-config.json') await Promise.all([ writeFile(binaryPath, 'binary', 'utf8'), writeFile(configPath, '{}', 'utf8') ]) await expect( store.update( settings({ continueBinaryPath: binaryPath, continueConfigPath: configPath }) ) ).resolves.toMatchObject({ continueBinaryPath: binaryPath, continueConfigPath: configPath }) await expect( store.update(settings({ opencodeConfigPath: directory })) ).rejects.toThrow('不是普通文件') }) it('rejects control characters in runtime paths', () => { expect( runtimeSettingsInputSchema.safeParse( settings({ continueBinaryPath: 'C:\\Tools\\continue.exe\n--evil' }) ).success ).toBe(false) expect( runtimeSettingsInputSchema.safeParse( settings({ opencodeConfigPath: '' }) ).success ).toBe(true) }) it('allows HTTP, IP literals, credentials, paths and queries', () => { expect( runtimeSettingsInputSchema.safeParse( settings({ modelBaseUrl: 'http://user@10.0.0.25:8000/models/v1?api-version=2024-02-01', knowledgeEmbeddingEnabled: true, knowledgeEmbeddingBaseUrl: 'http://vectors.example.com/v1/embeddings?format=float' }) ).success ).toBe(true) expect( runtimeSettingsInputSchema.safeParse( settings({ modelProfiles: [ { id: crypto.randomUUID(), name: '内网模型', baseUrl: 'http://[fd00::25]:8000/api', modelName: 'corp-model', protocol: 'openai-chat-completions', authentication: 'none', imageGenerationQuality: 'auto', apiKey: { action: 'clear' } } ] }) ).success ).toBe(true) }) it('still rejects endpoint protocols the clients cannot transport', () => { expect( runtimeSettingsInputSchema.safeParse( settings({ modelBaseUrl: 'ftp://models.example.com/v1' }) ).success ).toBe(false) expect( runtimeSettingsInputSchema.safeParse( settings({ knowledgeEmbeddingEnabled: true, knowledgeEmbeddingBaseUrl: 'file:///tmp/embeddings' }) ).success ).toBe(false) }) it('migrates version 5 profiles to Anthropic API-key profiles', async () => { const { filePath, store } = await createStore() const profileId = '00000000-0000-4000-8000-000000000021' const encryptedCredential = cipher .encrypt( JSON.stringify({ version: 1, apiKey: 'version-five-secret', origin: 'https://legacy-v5.example' }) ) .toString('base64') await writeFile( filePath, JSON.stringify({ version: 5, provider: 'model', modelProfiles: [ { id: profileId, name: 'V5 模型', baseUrl: 'https://legacy-v5.example', modelName: 'legacy-v5-model', credential: { formatVersion: 1, scheme: 'electron-safe-storage', ciphertextBase64: encryptedCredential } } ], defaultModelProfileId: profileId, opencodeModelSource: { kind: 'profile', profileId }, continueModelSource: { kind: 'profile', profileId }, opencodeBaseUrl: '', opencodeEmbedded: false, opencodeBinaryPath: '', opencodeConfigPath: '', continueBinaryPath: '', continueConfigPath: '', continueMode: 'chat', workspacePath: 'legacy-workspace', toolApproval: 'always' }), 'utf8' ) await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelProtocol: 'anthropic-messages', modelAuthentication: 'api-key', apiKey: 'version-five-secret', opencodeModelProfile: { id: profileId, protocol: 'anthropic-messages', authentication: 'api-key' }, continueModelProfile: { id: profileId } }) }) it('persists an unauthenticated Ollama profile without a credential', async () => { const { filePath, store } = await createStore() const profileId = '00000000-0000-4000-8000-000000000022' await store.update( settings({ modelBaseUrl: 'http://127.0.0.1:11434/v1', modelName: 'qwen3', modelProtocol: 'openai-chat-completions', modelAuthentication: 'none', modelProfiles: [ { id: profileId, name: 'Ollama', baseUrl: 'http://127.0.0.1:11434/v1', modelName: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', imageGenerationQuality: 'auto', apiKey: { action: 'clear' } } ], defaultModelProfileId: profileId }) ) await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelBaseUrl: 'http://127.0.0.1:11434/v1', modelProtocol: 'openai-chat-completions', modelAuthentication: 'none', apiKey: undefined }) const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { version: number modelProfiles: Array> } expect(persisted.version).toBe(13) expect(persisted.modelProfiles[0]).not.toHaveProperty('credential') }) it('resolves new runtime environment variables with legacy fallback', async () => { const { store } = await createStore({ GOODBUDDY_OPENCODE_BINARY: 'C:\\Tools\\opencode.exe', GOODBUDDY_OPENCODE_CONFIG: 'C:\\Config\\opencode.json', GOODBUDDY_CONTINUE_BINARY: 'C:\\Tools\\cn.exe', GOODBUDDY_CONTINUE_CONFIG: 'C:\\Config\\continue.yaml', GOODBUDDY_CONTINUE_COMMAND: 'legacy-cn' }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ opencodeBinaryPath: 'C:\\Tools\\opencode.exe', opencodeConfigPath: 'C:\\Config\\opencode.json', continueBinaryPath: 'C:\\Tools\\cn.exe', continueConfigPath: 'C:\\Config\\continue.yaml' }) const { store: legacyStore } = await createStore({ GOODBUDDY_CONTINUE_COMMAND: 'legacy-cn' }) await expect(legacyStore.getResolvedSettings()).resolves.toMatchObject({ continueBinaryPath: 'legacy-cn' }) const { store: defaultLegacyStore } = await createStore({ GOODBUDDY_CONTINUE_COMMAND: 'cn' }) await expect(defaultLegacyStore.getResolvedSettings()).resolves.toMatchObject({ continueBinaryPath: '' }) }) it('refuses to persist credentials when secure storage is unavailable', async () => { const { filePath } = await createStore() const store = new RuntimeSettingsStore(filePath, { ...cipher, isAvailable: () => false }) await expect( store.update( settings({ apiKey: { action: 'replace', value: 'test-secret-value' } }) ) ).rejects.toThrow('安全存储不可用') }) it('preserves settings created by a newer unsupported version', async () => { const { filePath, store } = await createStore() const futureSettings = JSON.stringify({ version: 99, futureField: 'keep-me' }) await writeFile(filePath, futureSettings, 'utf8') await expect(store.getPublicSettings()).rejects.toThrow( '不支持 Runtime 设置版本 99' ) expect(await readFile(filePath, 'utf8')).toBe(futureSettings) const files = await readdir(join(filePath, '..')) expect( files.some((name) => name.startsWith('runtime-settings.json.corrupt-')) ).toBe(false) }) it('isolates a corrupt settings file and reports recovery', async () => { const { filePath, store } = await createStore() await writeFile(filePath, '{not-valid-json', 'utf8') await expect(store.getPublicSettings()).resolves.toMatchObject({ provider: 'model', warning: expect.stringContaining('已损坏') }) const files = await readdir(join(filePath, '..')) expect( files.some((name) => name.startsWith('runtime-settings.json.corrupt-')) ).toBe(true) }) })