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, vi } from 'vitest' import { builtinEmbeddingConnectionId, legacyEmbeddingConnectionId, 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', deepseekHarnessModelSource: { kind: 'platform' }, knowledgeEmbeddingEnabled: false, knowledgeEmbeddingBaseUrl: 'http://127.0.0.1:11434/v1/embeddings', knowledgeEmbeddingModel: 'nomic-embed-text', knowledgeRerankEnabled: false, knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank', knowledgeRerankModel: 'rerank-v3.5', 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('round-trips conversation image settings and retains unavailable defaults without resolving credentials', async () => { const { store, filePath } = await createStore() const initial = await store.getPublicSettings() expect(initial.modelProfiles[0]?.allowConversationInvocation).toBe(false) expect(initial.defaultImageModelProfileId).toBeUndefined() const imageId = '00000000-0000-4000-8000-000000000099' const text = { id: initial.modelProfiles[0]!.id, name: 'Text', baseUrl: 'https://example.com', modelName: 'text', protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const, imageGenerationQuality: 'auto' as const, apiKey: { action: 'keep' as const } } const image = { ...text, id: imageId, name: 'Image model', protocol: 'openai-images-generations' as const, allowConversationInvocation: true, apiKey: { action: 'replace' as const, value: 'image-secret' } } const input = settings({ modelProfiles: [text, image], defaultModelProfileId: text.id, defaultImageModelProfileId: imageId }) await store.update(runtimeSettingsInputSchema.parse(input)) const reloaded = new RuntimeSettingsStore(filePath, cipher, {}) expect((await reloaded.getPublicSettings()).modelProfiles[1]).toMatchObject({ allowConversationInvocation: true, apiKeyConfigured: true }) expect((await reloaded.getResolvedSettings()).defaultImageModelProfileId).toBe(imageId) expect((await reloaded.getResolvedSettings()).modelProfiles[1]).toMatchObject({ allowConversationInvocation: true, apiKey: 'image-secret' }) expect((await reloaded.getPublicSettings()).configured?.modelProfiles[1]?.allowConversationInvocation).toBe(true) const decrypt = vi.fn(() => { throw new Error('Credential unavailable') }) const credentialFree = new RuntimeSettingsStore(filePath, { ...cipher, decrypt }, {}) expect(await credentialFree.getSavedModelProfiles()).toEqual([ { id: text.id, name: text.name, protocol: text.protocol, allowConversationInvocation: false }, { id: imageId, name: image.name, protocol: image.protocol, allowConversationInvocation: true } ]) expect(decrypt).not.toHaveBeenCalled() await reloaded.update(settings()) expect((await reloaded.getSavedModelProfiles())[1]?.allowConversationInvocation).toBe(true) expect((await reloaded.getPublicSettings()).defaultImageModelProfileId).toBe(imageId) const disabled = await reloaded.update({ ...input, modelProfiles: [text, { ...image, allowConversationInvocation: false, apiKey: { action: 'keep' } }] }) expect(disabled.defaultImageModelProfileId).toBe(imageId) expect(disabled.modelProfiles[1]).toMatchObject({ allowConversationInvocation: false, apiKeyConfigured: true }) const removed = await reloaded.update({ ...input, modelProfiles: [text] }) expect(removed.defaultImageModelProfileId).toBe(imageId) expect((await new RuntimeSettingsStore(filePath, cipher, {}).getPublicSettings()).defaultImageModelProfileId).toBe(imageId) expect((await reloaded.update({ ...input, modelProfiles: [text], defaultImageModelProfileId: null })).defaultImageModelProfileId).toBeNull() }) it('copies an existing model credential to a new profile without exposing it', async () => { const { store } = await createStore() const initial = await store.getPublicSettings() const source = initial.modelProfiles[0]! const copiedId = '00000000-0000-4000-8000-000000000123' await store.update( runtimeSettingsInputSchema.parse({ ...settings(), modelAuthentication: 'api-key', modelProfiles: [ { id: source.id, name: source.name, baseUrl: source.baseUrl, modelName: source.modelName, protocol: source.protocol, authentication: 'api-key', imageGenerationQuality: source.imageGenerationQuality, apiKey: { action: 'replace', value: 'source-secret' } } ], defaultModelProfileId: source.id }) ) const copied = await store.update( runtimeSettingsInputSchema.parse({ ...settings(), modelAuthentication: 'api-key', modelProfiles: [ { id: source.id, name: source.name, baseUrl: source.baseUrl, modelName: source.modelName, protocol: source.protocol, authentication: 'api-key', imageGenerationQuality: source.imageGenerationQuality, apiKey: { action: 'keep' } }, { id: copiedId, name: 'Copied model', baseUrl: source.baseUrl, modelName: source.modelName, protocol: source.protocol, authentication: 'api-key', imageGenerationQuality: source.imageGenerationQuality, apiKey: { action: 'copy', sourceProfileId: source.id } } ], defaultModelProfileId: source.id }) ) expect(copied.modelProfiles.find((profile) => profile.id === copiedId)) .toMatchObject({ apiKeyConfigured: true, credentialSource: 'encrypted' }) expect(JSON.stringify(copied)).not.toContain('source-secret') await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelProfiles: expect.arrayContaining([ expect.objectContaining({ id: copiedId, apiKey: 'source-secret' }) ]) }) }) it('rejects malformed conversation image settings', () => { expect(runtimeSettingsInputSchema.safeParse(settings({ defaultImageModelProfileId: 'not-a-uuid' })).success).toBe(false) expect(runtimeSettingsInputSchema.safeParse({ ...settings(), modelProfiles: [{ id: '00000000-0000-4000-8000-000000000001', name: 'Image', baseUrl: 'https://example.com', modelName: 'image', protocol: 'openai-images-generations', authentication: 'none', imageGenerationQuality: 'auto', apiKey: { action: 'keep' }, allowConversationInvocation: 'true' }] }).success).toBe(false) }) it('defaults to automatic Execute tool authorization and preserves an explicitly saved deny policy', async () => { const { store, filePath } = await createStore() await expect(store.getPublicSettings()).resolves.toMatchObject({ toolApproval: 'always' }) await expect(store.getPolicySettings()).resolves.toMatchObject({ toolApproval: 'always' }) await store.update(settings({ toolApproval: 'policy' })) const reloaded = new RuntimeSettingsStore(filePath, cipher, {}) await expect(reloaded.getPublicSettings()).resolves.toMatchObject({ toolApproval: 'policy' }) await expect(reloaded.getPolicySettings()).resolves.toMatchObject({ toolApproval: 'policy' }) }) it('persists default references separately from fixed profiles and resolves each settings generation', async () => { const { store, filePath } = await createStore() const firstId = '00000000-0000-4000-8000-000000000041' const secondId = '00000000-0000-4000-8000-000000000042' const modelProfiles = [firstId, secondId].map((id) => ({ id, name: id, baseUrl: 'https://gateway.example/v1', modelName: id, protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const, imageGenerationQuality: 'auto' as const, apiKey: { action: 'keep' as const } })) await store.update(settings({ modelProfiles, defaultModelProfileId: firstId, opencodeModelSource: { kind: 'default' }, continueModelSource: { kind: 'profile', profileId: firstId }, deepseekHarnessModelSource: { kind: 'default' } })) const started = await store.getResolvedSettings() await store.update(settings({ defaultModelProfileId: secondId, deepseekHarnessModelSource: { kind: 'default' } })) const reloaded = new RuntimeSettingsStore(filePath, cipher, {}) await expect(reloaded.getPublicSettings()).resolves.toMatchObject({ opencodeModelSource: { kind: 'default' }, continueModelSource: { kind: 'profile', profileId: firstId }, deepseekHarnessModelSource: { kind: 'default' } }) await expect(reloaded.getResolvedSettings()).resolves.toMatchObject({ opencodeModelProfile: { id: secondId }, continueModelProfile: { id: firstId }, deepseekHarnessModelProfile: { id: secondId } }) expect(started.opencodeModelProfile?.id).toBe(firstId) }) it('falls back to compatible profiles when the global default is incompatible with a Runtime', async () => { const { store } = await createStore() const textId = '00000000-0000-4000-8000-000000000043' const imageId = '00000000-0000-4000-8000-000000000044' const anthropicId = '00000000-0000-4000-8000-000000000045' const modelProfiles = [ { id: textId, name: 'OpenAI text', baseUrl: 'https://gateway.example/v1', modelName: 'text', protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const, imageGenerationQuality: 'auto' as const, apiKey: { action: 'keep' as const } }, { id: imageId, name: 'Image', baseUrl: 'https://gateway.example/v1', modelName: 'image', protocol: 'openai-images-generations' as const, authentication: 'api-key' as const, imageGenerationQuality: 'auto' as const, apiKey: { action: 'keep' as const } }, { id: anthropicId, name: 'Anthropic text', baseUrl: 'https://gateway.example/v1', modelName: 'text', protocol: 'anthropic-messages' as const, authentication: 'api-key' as const, imageGenerationQuality: 'auto' as const, apiKey: { action: 'keep' as const } } ] const runtimeDefaults = settings({ modelProfiles, defaultModelProfileId: textId, opencodeModelSource: { kind: 'default' }, continueModelSource: { kind: 'default' }, deepseekHarnessModelSource: { kind: 'default' } }) await store.update(runtimeDefaults) await store.update({ ...runtimeDefaults, defaultModelProfileId: imageId }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ opencodeModelProfile: { id: textId }, continueModelProfile: { id: textId }, deepseekHarnessModelProfile: { id: textId } }) await store.update({ ...runtimeDefaults, defaultModelProfileId: anthropicId }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ opencodeModelProfile: { id: anthropicId }, continueModelProfile: { id: anthropicId }, deepseekHarnessModelProfile: { id: textId } }) }) it('round-trips the Harness platform rule without pinning its resolved fallback', async () => { const { store, filePath } = await createStore() const firstId = '00000000-0000-4000-8000-000000000041' const secondId = '00000000-0000-4000-8000-000000000042' const modelProfiles = [firstId, secondId].map((id) => ({ id, name: id, baseUrl: 'https://gateway.example/v1', modelName: id, protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const, imageGenerationQuality: 'auto' as const, apiKey: { action: 'keep' as const } })) await store.update(settings({ modelProfiles, defaultModelProfileId: firstId })) const publicSettings = await store.getPublicSettings() const started = await store.getResolvedSettings() await store.update(settings({ defaultModelProfileId: secondId, deepseekHarnessModelSource: publicSettings.deepseekHarnessModelSource })) const reloaded = new RuntimeSettingsStore(filePath, cipher, {}) expect((await reloaded.getPublicSettings()).deepseekHarnessModelSource).toEqual({ kind: 'platform' }) expect((await reloaded.getResolvedSettings()).deepseekHarnessModelProfile?.id).toBe(secondId) expect(started.deepseekHarnessModelProfile?.id).toBe(firstId) }) 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('migrates version 20 model profiles with empty request customization', async () => { const { filePath, store } = await createStore() await store.update(settings()) const version20 = JSON.parse( await readFile(filePath, 'utf8') ) as { version: number modelProfiles: Array> } version20.version = 20 for (const profile of version20.modelProfiles) { delete profile.requestHeaders delete profile.requestBody } await writeFile(filePath, JSON.stringify(version20), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migrated.getPublicSettings()).resolves.toMatchObject({ modelProfiles: [ expect.objectContaining({ requestHeaders: {}, requestBody: {} }) ] }) await expect(migrated.getResolvedSettings()).resolves.toMatchObject({ modelProfiles: [ expect.objectContaining({ requestHeaders: {}, requestBody: {} }) ] }) }) it('round-trips model request headers and body without treating them as credentials', async () => { const { filePath, store } = await createStore() const profileId = '00000000-0000-4000-8000-000000000010' const updated = await store.update( settings({ modelProfiles: [ { id: profileId, name: 'Customized model', baseUrl: 'https://provider.example/v1', modelName: 'model-1', protocol: 'openai-responses', authentication: 'none', supportsImageInput: false, imageGenerationQuality: 'auto', requestHeaders: { 'x-tenant-id': 'tenant-a' }, requestBody: { temperature: 0.2, metadata: { source: 'goodbuddy' } }, apiKey: { action: 'keep' } } ], defaultModelProfileId: profileId, opencodeModelSource: { kind: 'profile', profileId }, continueModelSource: { kind: 'profile', profileId } }) ) expect(updated.modelProfiles[0]).toMatchObject({ requestHeaders: { 'x-tenant-id': 'tenant-a' }, requestBody: { temperature: 0.2, metadata: { source: 'goodbuddy' } }, apiKeyConfigured: false }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelProfiles: [ expect.objectContaining({ requestHeaders: { 'x-tenant-id': 'tenant-a' }, requestBody: { temperature: 0.2, metadata: { source: 'goodbuddy' } }, apiKey: undefined }) ] }) const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { version: number modelProfiles: Array> } expect(persisted.version).toBe(21) expect(persisted.modelProfiles[0]).toMatchObject({ requestHeaders: { 'x-tenant-id': 'tenant-a' }, requestBody: { temperature: 0.2 } }) expect(persisted.modelProfiles[0]).not.toHaveProperty('apiKey') }) 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 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 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('preserves the historical connection for a legacy environment key', async () => { const { filePath, store } = await createStore() await store.update(settings()) const version19 = JSON.parse( await readFile(filePath, 'utf8') ) as Record version19.version = 19 await writeFile(filePath, JSON.stringify(version19), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, { GOODBUDDY_BIGTOKEN_API_KEY: 'legacy-key' }) await expect(migrated.getResolvedSettings()).resolves.toMatchObject({ modelBaseUrl: 'https://bigtoken.ai', modelName: 'sonnet-5', modelProtocol: 'anthropic-messages', modelAuthentication: 'api-key', apiKey: 'legacy-key' }) }) it('restores an exact credential-bearing snapshot after a failed activation', async () => { const { store } = await createStore() await store.update( settings({ apiKey: { action: 'replace', value: 'previous-key' } }) ) const rollback = await store.captureRollback() await store.update( settings({ modelBaseUrl: 'https://candidate.example/v1', modelName: 'candidate', apiKey: { action: 'replace', value: 'candidate-key' } }) ) await expect(rollback.restore()).resolves.toMatchObject({ modelBaseUrl: 'https://bigtoken.ai', modelName: 'sonnet-5', apiKeyConfigured: true }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelBaseUrl: 'https://bigtoken.ai', modelName: 'sonnet-5', apiKey: 'previous-key' }) }) it('migrates version 17 to empty Runtime customization', async () => { const { filePath, store } = await createStore() await store.update(settings()) const previous = JSON.parse( await readFile(filePath, 'utf8') ) as Record previous.version = 17 Reflect.deleteProperty(previous, 'runtimeCustomization') await writeFile(filePath, JSON.stringify(previous)) const migrated = new RuntimeSettingsStore( filePath, cipher ) await expect(migrated.getRuntimeCustomization()).resolves.toEqual({ opencode: {}, continue: { presets: [] } }) }) it('persists validated Runtime customization independently', async () => { const { store } = await createStore() const presetId = '00000000-0000-4000-8000-000000000071' const ruleId = '00000000-0000-4000-8000-000000000072' const promptId = '00000000-0000-4000-8000-000000000073' const customization = { opencode: { defaultAgent: 'build' }, continue: { defaultPresetId: presetId, presets: [ { id: presetId, name: '代码审查', rules: [ { id: ruleId, name: '只报告可复现问题', content: 'Only report reproducible findings.', enabled: true } ], prompts: [ { id: promptId, name: '审查变更', prompt: 'Review the current changes.' } ] } ] } } await expect( store.updateRuntimeCustomization(customization) ).resolves.toEqual(customization) await expect(store.getRuntimeCustomization()).resolves.toEqual( customization ) }) it('migrates version 16 to disabled default context compression', async () => { const { filePath, store } = await createStore() await store.update(settings()) const previous = JSON.parse( await readFile(filePath, 'utf8') ) as Record previous.version = 16 delete previous.contextCompression await writeFile(filePath, JSON.stringify(previous), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migrated.getPublicSettings()).resolves.toMatchObject({ contextCompression: { enabled: false, triggerTokens: 200_000, recentRawTokens: 32_000, modelSource: { kind: 'current' } } }) }) it('persists context compression and optional model context windows', async () => { const { store } = await createStore() const profileId = '00000000-0000-4000-8000-000000000061' const updated = await store.update( settings({ modelProfiles: [ { id: profileId, name: 'Long context', baseUrl: 'https://model.example/v1', modelName: 'long-model', protocol: 'openai-chat-completions', authentication: 'none', supportsImageInput: false, contextWindowTokens: 256_000, maximumOutputTokens: 48_000, imageGenerationQuality: 'auto', apiKey: { action: 'keep' } } ], defaultModelProfileId: profileId, contextCompression: { enabled: true, triggerTokens: 200_000, recentRawTokens: 32_000, modelSource: { kind: 'profile', profileId }, summaryPrompt: 'Keep exact decisions and unresolved work.' } }) ) expect(updated).toMatchObject({ modelProfiles: [ { id: profileId, contextWindowTokens: 256_000, maximumOutputTokens: 48_000 } ], contextCompression: { enabled: true, triggerTokens: 200_000, recentRawTokens: 32_000, modelSource: { kind: 'profile', profileId } } }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelProfiles: [ { id: profileId, contextWindowTokens: 256_000, maximumOutputTokens: 48_000 } ], contextCompression: { enabled: true } }) }) it('rejects undersized model context windows and repairs legacy values', async () => { const { filePath, store } = await createStore() const profileId = '00000000-0000-4000-8000-000000000062' const profile = { id: profileId, name: 'Small context', baseUrl: 'https://model.example/v1', modelName: 'small-model', protocol: 'openai-responses' as const, authentication: 'api-key' as const, supportsImageInput: false, contextWindowTokens: 10_000, imageGenerationQuality: 'auto' as const, apiKey: { action: 'keep' as const } } expect(() => runtimeSettingsInputSchema.parse( settings({ modelProfiles: [profile], defaultModelProfileId: profileId }) ) ).toThrow() await store.update( settings({ modelProfiles: [ { ...profile, contextWindowTokens: 32_000 } ], defaultModelProfileId: profileId }) ) const persisted = JSON.parse( await readFile(filePath, 'utf8') ) as { modelProfiles: Array<{ contextWindowTokens?: number }> } persisted.modelProfiles[0]!.contextWindowTokens = 10_000 await writeFile(filePath, JSON.stringify(persisted), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migrated.getPublicSettings()).resolves.toMatchObject({ modelProfiles: [ expect.objectContaining({ id: profileId, contextWindowTokens: undefined }) ] }) }) it('configures bundled runtimes from the default model profile', async () => { const { store } = await createStore() await expect(store.getPublicSettings()).resolves.toMatchObject({ opencodeEmbedded: true, opencodeModelSource: { kind: 'default' }, continueModelSource: { kind: 'default' } }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ opencodeEmbedded: true, opencodeModelProfile: { id: '00000000-0000-4000-8000-000000000001' }, continueModelProfile: { id: '00000000-0000-4000-8000-000000000001' } }) }) it('uses a compatible GoodBuddy model for Harness when no platform model is configured', async () => { const profileId = '00000000-0000-4000-8000-000000000044' const { store } = await createStore() await store.update( settings({ modelProfiles: [ { id: profileId, name: 'OpenAI-compatible gateway', baseUrl: 'https://gateway.example/openai/v1', modelName: 'qwen-plus', protocol: 'openai-chat-completions', authentication: 'api-key', imageGenerationQuality: 'auto', apiKey: { action: 'replace', value: 'deepseek-secret' } } ], defaultModelProfileId: profileId }) ) await expect(store.getPublicSettings()).resolves.toMatchObject({ deepseekHarnessModelSource: { kind: 'platform' }, configured: { deepseekHarnessModelSource: { kind: 'platform' } } }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ deepseekHarnessModelProfile: { id: profileId, apiKey: 'deepseek-secret' } }) }) it('uses the first compatible Harness model when the default model is incompatible', async () => { const defaultId = '00000000-0000-4000-8000-000000000043' const harnessId = '00000000-0000-4000-8000-000000000044' const { store } = await createStore() await store.update( settings({ modelProfiles: [ { id: defaultId, name: 'Anthropic model', baseUrl: 'https://anthropic.example', modelName: 'claude', protocol: 'anthropic-messages', authentication: 'api-key', imageGenerationQuality: 'auto', apiKey: { action: 'replace', value: 'anthropic-secret' } }, { id: harnessId, name: 'OpenAI-compatible gateway', baseUrl: 'https://gateway.example/openai/v1', modelName: 'qwen-plus', protocol: 'openai-chat-completions', authentication: 'api-key', imageGenerationQuality: 'auto', apiKey: { action: 'replace', value: 'deepseek-secret' } } ], defaultModelProfileId: defaultId }) ) await expect(store.getResolvedSettings()).resolves.toMatchObject({ deepseekHarnessModelProfile: { id: harnessId } }) }) it('migrates DeepSeek Harness to controlled platform mode and stores a compatible profile', async () => { const { filePath, store } = await createStore() await store.update(settings()) const versionFourteen = JSON.parse( await readFile(filePath, 'utf8') ) as Record versionFourteen.version = 14 delete versionFourteen.deepseekHarnessModelSource delete versionFourteen.deepseekHarnessBinaryPath await writeFile(filePath, JSON.stringify(versionFourteen), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migrated.getResolvedSettings()).resolves.toMatchObject({ deepseekHarnessModelProfile: undefined }) const profileId = '00000000-0000-4000-8000-000000000044' await migrated.update( settings({ provider: 'deepseek-harness', modelProfiles: [ { id: profileId, name: 'OpenAI-compatible gateway', baseUrl: 'https://gateway.example/openai/v1', modelName: 'qwen-plus', protocol: 'openai-chat-completions', authentication: 'api-key', imageGenerationQuality: 'auto', apiKey: { action: 'replace', value: 'deepseek-secret' } } ], defaultModelProfileId: profileId, deepseekHarnessModelSource: { kind: 'profile', profileId } }) ) await expect(migrated.getResolvedSettings()).resolves.toMatchObject({ provider: 'deepseek-harness', deepseekHarnessModelProfile: { id: profileId, apiKey: 'deepseek-secret' } }) }) it.each([ { name: 'complete administrator override', environment: { GOODBUDDY_MODEL_API_KEY: 'administrator-secret', GOODBUDDY_MODEL_BASE_URL: 'https://administrator.example/v1?private=marker', GOODBUDDY_MODEL_NAME: 'administrator-model' }, compatibleDefault: true, expected: 'environment' }, { name: 'API key only with incompatible default', environment: { GOODBUDDY_MODEL_API_KEY: 'administrator-secret' }, compatibleDefault: false, expected: 'first' }, { name: 'API key only with compatible default', environment: { GOODBUDDY_MODEL_API_KEY: 'administrator-secret' }, compatibleDefault: true, expected: 'default' }, { name: 'address and model without API key', environment: { GOODBUDDY_MODEL_BASE_URL: 'https://administrator.example/v1', GOODBUDDY_MODEL_NAME: 'administrator-model' }, compatibleDefault: false, expected: 'first' }, { name: 'legacy environment credentials', environment: { GOODBUDDY_BIGTOKEN_API_KEY: 'legacy-secret', GOODBUDDY_BIGTOKEN_BASE_URL: 'https://legacy.example/v1', GOODBUDDY_BIGTOKEN_MODEL: 'legacy-model' }, compatibleDefault: false, expected: 'first' }, { name: 'compatible saved default', environment: {}, compatibleDefault: true, expected: 'default' }, { name: 'first compatible saved connection', environment: {}, compatibleDefault: false, expected: 'first' }, { name: 'no compatible connection', environment: {}, compatibleDefault: false, expected: 'unavailable' } ])('projects the actual Harness platform source: $name', async ({ environment, compatibleDefault, expected }) => { const { store, filePath } = await createStore(environment) const firstId = '00000000-0000-4000-8000-000000000061' const defaultId = '00000000-0000-4000-8000-000000000062' const profile = { name: 'First compatible', baseUrl: 'https://saved.example/v1?private=marker', modelName: 'saved-model', protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const, imageGenerationQuality: 'auto' as const, apiKey: { action: 'replace' as const, value: 'saved-secret' }, requestHeaders: { 'X-Private-Header': 'header-marker' }, requestBody: { privateMetadata: 'body-marker' } } const input = settings({ modelProfiles: [ ...(expected === 'unavailable' ? [] : [{ ...profile, id: firstId }]), { ...profile, id: defaultId, name: 'Preferred connection', protocol: compatibleDefault ? 'openai-chat-completions' : 'anthropic-messages' } ], defaultModelProfileId: defaultId }) const publicSettings = await store.update(input) const resolved = (await store.getResolvedSettings()).deepseekHarnessModelProfile const projection = publicSettings.deepseekHarnessPlatformModel if (expected === 'unavailable') { expect(resolved).toBeUndefined() expect(projection).toEqual({ source: 'unavailable' }) } else { expect(resolved).toBeDefined() expect(projection).toEqual({ source: expected === 'environment' ? 'environment' : 'profile', ...(expected === 'environment' ? {} : { profileId: expected === 'default' ? defaultId : firstId }), name: resolved!.name, modelName: resolved!.modelName }) if (expected === 'environment') { expect(projection?.source).toBe('environment') expect(resolved?.modelName).toBe('administrator-model') } } const serialized = JSON.stringify(projection) for (const excluded of [ 'https://', 'apiKey', 'baseUrl', 'requestHeaders', 'requestBody', 'administrator-secret', 'saved-secret', 'legacy-secret', 'header-marker', 'body-marker' ]) { expect(serialized).not.toContain(excluded) } expect(await readFile(filePath, 'utf8')).not.toContain('deepseekHarnessPlatformModel') expect((await store.getPublicSettings()).deepseekHarnessPlatformModel).toEqual(projection) // Merely inspecting the platform option must not change a selected default rule. if (expected !== 'unavailable') { const followingDefault = await store.update({ ...input, deepseekHarnessModelSource: { kind: 'default' } }) expect(followingDefault.deepseekHarnessModelSource).toEqual({ kind: 'default' }) expect(followingDefault.deepseekHarnessPlatformModel).toEqual(projection) } }) it('resolves a controlled platform Harness profile without exposing its credential', async () => { const apiKey = 'platform-harness-secret' const { store } = await createStore({ GOODBUDDY_MODEL_API_KEY: apiKey, GOODBUDDY_MODEL_BASE_URL: 'http://gateway.example/openai/v1?api-version=1', GOODBUDDY_MODEL_NAME: 'qwen-plus' }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelProtocol: 'openai-chat-completions', deepseekHarnessModelProfile: { id: 'goodbuddy-platform-harness', name: '管理员预置模型', baseUrl: 'http://gateway.example/openai/v1?api-version=1', modelName: 'qwen-plus', protocol: 'openai-chat-completions', authentication: 'api-key', supportsImageInput: false, imageGenerationQuality: 'auto', apiKey } }) const publicSettings = await store.getPublicSettings() expect(JSON.stringify(publicSettings)).not.toContain(apiKey) expect(publicSettings.modelProtocol).toBe( 'openai-chat-completions' ) }) it.each([ [ 'an unsupported endpoint protocol', { GOODBUDDY_MODEL_API_KEY: 'platform-key', GOODBUDDY_MODEL_BASE_URL: 'ftp://gateway.example/v1', GOODBUDDY_MODEL_NAME: 'qwen-plus' } ], [ 'a missing API key', { GOODBUDDY_MODEL_BASE_URL: 'https://gateway.example/v1', GOODBUDDY_MODEL_NAME: 'qwen-plus' } ] ])('does not resolve platform Harness from %s', async (_, environment) => { const { store } = await createStore(environment) await expect(store.getResolvedSettings()).resolves.toMatchObject({ deepseekHarnessModelProfile: undefined }) }) it('drops the legacy custom Harness Host path and ignores its environment override', async () => { const { filePath, store } = await createStore() await store.update(settings()) const versionFifteen = JSON.parse( await readFile(filePath, 'utf8') ) as Record versionFifteen.version = 15 versionFifteen.deepseekHarnessBinaryPath = 'C:\\untrusted\\custom-harness.js' versionFifteen.runtimeSandboxMode = 'strict' await writeFile(filePath, JSON.stringify(versionFifteen), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, { GOODBUDDY_DEEPSEEK_HARNESS_BINARY: 'C:\\environment\\custom-harness.js' }) const publicSettings = await migrated.getPublicSettings() const resolvedSettings = await migrated.getResolvedSettings() expect(publicSettings).not.toHaveProperty( 'deepseekHarnessBinaryPath' ) expect(publicSettings.configured).not.toHaveProperty( 'deepseekHarnessBinaryPath' ) expect(resolvedSettings).not.toHaveProperty( 'deepseekHarnessBinaryPath' ) expect(publicSettings).not.toHaveProperty('runtimeSandboxMode') expect(publicSettings.configured).not.toHaveProperty( 'runtimeSandboxMode' ) expect(resolvedSettings).not.toHaveProperty( 'runtimeSandboxMode' ) await migrated.update(settings()) const persisted = JSON.parse( await readFile(filePath, 'utf8') ) as Record expect(persisted.version).toBe(21) expect(persisted).not.toHaveProperty( 'deepseekHarnessBinaryPath' ) expect(persisted).not.toHaveProperty('runtimeSandboxMode') }) it('accepts compatible gateways and rejects incompatible Harness profiles', () => { const profileId = '00000000-0000-4000-8000-000000000045' expect( runtimeSettingsInputSchema.safeParse( settings({ modelProfiles: [ { id: profileId, name: 'Compatible API', baseUrl: 'https://other.example/v1', modelName: 'qwen-plus', protocol: 'openai-chat-completions', authentication: 'api-key', imageGenerationQuality: 'auto', apiKey: { action: 'keep' } } ], defaultModelProfileId: profileId, deepseekHarnessModelSource: { kind: 'profile', profileId } }) ).success ).toBe(true) expect( runtimeSettingsInputSchema.safeParse( settings({ modelProfiles: [ { id: profileId, name: 'Gateway without API key', baseUrl: 'https://other.example/v1', modelName: 'qwen-plus', protocol: 'openai-chat-completions', authentication: 'none', imageGenerationQuality: 'auto', apiKey: { action: 'clear' } } ], defaultModelProfileId: profileId, deepseekHarnessModelSource: { kind: 'profile', profileId } }) ).success ).toBe(false) }) 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(21) }) 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(21) 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('rejects non-HTTP model profile URLs during legacy migration', async () => { const { filePath, store } = await createStore() await store.update(settings()) const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { version: number modelProfiles: Array<{ baseUrl: string }> } persisted.version = 6 persisted.modelProfiles[0]!.baseUrl = 'file:///tmp/model' await writeFile(filePath, JSON.stringify(persisted), 'utf8') const migratedStore = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migratedStore.getPublicSettings()).resolves.toMatchObject({ provider: 'model', warnings: [{ code: 'runtime-settings-recovered' }] }) expect( (await readdir(join(filePath, '..'))).some((name) => name.startsWith('runtime-settings.json.corrupt-') ) ).toBe(true) }) it('keeps an encrypted embedding API key when its endpoint changes', 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 store.update( settings({ knowledgeEmbeddingBaseUrl: 'https://vectors.example/v1/embeddings', knowledgeEmbeddingApiKey: { action: 'keep' } }) ) await expect(store.getResolvedSettings()).resolves.toMatchObject({ knowledgeEmbeddingBaseUrl: 'https://vectors.example/v1/embeddings', knowledgeEmbeddingApiKey: 'vector-secret-value' }) }) it('migrates version 18 embedding settings into a separate user connection', async () => { const { filePath, store } = await createStore() await store.update( settings({ knowledgeEmbeddingEnabled: true, knowledgeEmbeddingBaseUrl: 'https://legacy-vectors.example/v1/embeddings', knowledgeEmbeddingModel: 'legacy/embed-large', knowledgeEmbeddingApiKey: { action: 'replace', value: 'legacy-vector-secret' } }) ) const previous = JSON.parse( await readFile(filePath, 'utf8') ) as Record previous.version = 18 delete previous.embeddingConnections delete previous.activeEmbeddingConnectionId await writeFile(filePath, JSON.stringify(previous), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migrated.getPublicSettings()).resolves.toMatchObject({ activeEmbeddingConnectionId: legacyEmbeddingConnectionId, embeddingConnections: [ { id: builtinEmbeddingConnectionId, kind: 'builtin', apiKeyConfigured: false, credentialSource: 'none' }, { id: legacyEmbeddingConnectionId, kind: 'openai-compatible', baseUrl: 'https://legacy-vectors.example/v1/embeddings', modelName: 'legacy/embed-large', apiKeyConfigured: true, credentialSource: 'encrypted' } ], knowledgeEmbeddingBaseUrl: 'https://legacy-vectors.example/v1/embeddings', knowledgeEmbeddingModel: 'legacy/embed-large' }) await expect(migrated.getResolvedSettings()).resolves.toMatchObject({ activeEmbeddingConnection: { id: legacyEmbeddingConnectionId, apiKey: 'legacy-vector-secret' }, knowledgeEmbeddingApiKey: 'legacy-vector-secret' }) }) it('round-trips embedding connections without assigning them to Runtime profiles', async () => { const { filePath, store } = await createStore() const connectionId = '00000000-0000-4000-8000-000000000081' await store.update( settings({ embeddingConnections: [ { id: builtinEmbeddingConnectionId, name: 'GoodBuddy 内置向量模型', kind: 'builtin' }, { id: connectionId, name: '团队向量模型', kind: 'openai-compatible', baseUrl: 'https://team-vectors.example/v1/embeddings', modelName: 'team/embed-v2', authentication: 'api-key', apiKey: { action: 'replace', value: 'team-vector-secret' } } ], activeEmbeddingConnectionId: connectionId }) ) const reloaded = new RuntimeSettingsStore(filePath, cipher, {}) await expect(reloaded.getResolvedSettings()).resolves.toMatchObject({ activeEmbeddingConnectionId: connectionId, activeEmbeddingConnection: { id: connectionId, kind: 'openai-compatible', apiKey: 'team-vector-secret' }, modelProfiles: [ expect.not.objectContaining({ id: connectionId }) ] }) const publicSettings = await reloaded.getPublicSettings() expect(JSON.stringify(publicSettings)).not.toContain( 'team-vector-secret' ) expect(publicSettings.embeddingConnections).toContainEqual( expect.objectContaining({ id: connectionId, apiKeyConfigured: true, credentialSource: 'encrypted' }) ) expect( (JSON.parse(await readFile(filePath, 'utf8')) as { version: number }).version ).toBe(21) }) it('changes the active embedding connection without changing model profiles', async () => { const { store } = await createStore() const profileId = '00000000-0000-4000-8000-000000000091' const connectionId = '00000000-0000-4000-8000-000000000092' await store.update( settings({ modelProfiles: [ { id: profileId, name: 'Tenant model', baseUrl: 'https://gateway.example/v1', modelName: 'tenant-model', protocol: 'anthropic-messages', authentication: 'api-key', supportsImageInput: false, contextWindowTokens: 200_000, maximumOutputTokens: 48_000, imageGenerationQuality: 'auto', requestHeaders: { 'x-tenant-id': 'tenant-a' }, requestBody: { temperature: 0.2 }, apiKey: { action: 'replace', value: 'tenant-secret' } } ], defaultModelProfileId: profileId, embeddingConnections: [ { id: builtinEmbeddingConnectionId, name: 'GoodBuddy 内置向量模型', kind: 'builtin' }, { id: connectionId, name: '团队向量模型', kind: 'openai-compatible', baseUrl: 'https://team-vectors.example/v1/embeddings', modelName: 'team/embed-v2', authentication: 'none', apiKey: { action: 'keep' } } ], activeEmbeddingConnectionId: builtinEmbeddingConnectionId }) ) const selection = await store.setActiveEmbeddingConnection(connectionId) expect(selection).toMatchObject({ changed: true, previousConnectionId: builtinEmbeddingConnectionId }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ activeEmbeddingConnectionId: connectionId, modelProfiles: [ expect.objectContaining({ id: profileId, contextWindowTokens: 200_000, maximumOutputTokens: 48_000, requestHeaders: { 'x-tenant-id': 'tenant-a' }, requestBody: { temperature: 0.2 }, apiKey: 'tenant-secret' }) ] }) }) it('preserves configured model fields when embedding changes under an environment override', async () => { const { filePath, store } = await createStore() const profileId = '00000000-0000-4000-8000-000000000093' await store.update( settings({ modelProfiles: [ { id: profileId, name: 'Stored OpenAI model', baseUrl: 'https://stored.example/v1', modelName: 'stored-model', protocol: 'openai-chat-completions', authentication: 'none', supportsImageInput: false, imageGenerationQuality: 'auto', requestHeaders: { 'x-tenant-id': 'tenant-a' }, requestBody: { temperature: 0.2 }, apiKey: { action: 'keep' } } ], defaultModelProfileId: profileId }) ) const environmentStore = new RuntimeSettingsStore( filePath, cipher, { GOODBUDDY_BIGTOKEN_API_KEY: 'environment-key' } ) const effective = await environmentStore.getPublicSettings() expect(effective.modelProfiles[0]).toMatchObject({ protocol: 'anthropic-messages', authentication: 'api-key', credentialSource: 'environment' }) await environmentStore.setActiveEmbeddingConnection( legacyEmbeddingConnectionId ) const withoutEnvironment = new RuntimeSettingsStore( filePath, cipher, {} ) await expect( withoutEnvironment.getResolvedSettings() ).resolves.toMatchObject({ activeEmbeddingConnectionId: legacyEmbeddingConnectionId, modelProfiles: [ expect.objectContaining({ id: profileId, baseUrl: 'https://stored.example/v1', modelName: 'stored-model', protocol: 'openai-chat-completions', authentication: 'none', requestHeaders: { 'x-tenant-id': 'tenant-a' }, requestBody: { temperature: 0.2 } }) ] }) }) it('serializes embedding selection with full settings updates', async () => { const { store } = await createStore() const settingsUpdate = store.update( settings({ modelName: 'updated-after-selection-read' }) ) const selectionUpdate = store.setActiveEmbeddingConnection( legacyEmbeddingConnectionId ) await Promise.all([settingsUpdate, selectionUpdate]) await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelName: 'updated-after-selection-read', activeEmbeddingConnectionId: legacyEmbeddingConnectionId }) }) it('rolls back only the embedding selection after later settings updates', async () => { const { store } = await createStore() const selection = await store.setActiveEmbeddingConnection( legacyEmbeddingConnectionId ) await store.update( settings({ modelName: 'saved-while-embedding-activated', activeEmbeddingConnectionId: legacyEmbeddingConnectionId }) ) await selection.restore() await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelName: 'saved-while-embedding-activated', activeEmbeddingConnectionId: builtinEmbeddingConnectionId }) }) it('migrates version 13 with reranking disabled by default', async () => { const { filePath, store } = await createStore() await store.update(settings()) const persisted = JSON.parse(await readFile(filePath, 'utf8')) as Record< string, unknown > persisted.version = 13 delete persisted.knowledgeRerankEnabled delete persisted.knowledgeRerankEndpoint delete persisted.knowledgeRerankModel delete persisted.knowledgeRerankCredential await writeFile(filePath, JSON.stringify(persisted), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migrated.getPublicSettings()).resolves.toMatchObject({ knowledgeRerankEnabled: false, knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank', knowledgeRerankModel: 'rerank-v3.5', knowledgeRerankApiKeyConfigured: false, knowledgeRerankCredentialSource: 'none' }) }) it('keeps an encrypted rerank API key when its endpoint changes', async () => { const { filePath, store } = await createStore() await store.update( settings({ knowledgeRerankEnabled: true, knowledgeRerankEndpoint: 'https://rerank.example/v1/rerank', knowledgeRerankModel: 'vendor/rerank-large', knowledgeRerankApiKey: { action: 'replace', value: 'rerank-secret-value' } }) ) expect(await readFile(filePath, 'utf8')).not.toContain( 'rerank-secret-value' ) await expect(store.getResolvedSettings()).resolves.toMatchObject({ knowledgeRerankEnabled: true, knowledgeRerankEndpoint: 'https://rerank.example/v1/rerank', knowledgeRerankModel: 'vendor/rerank-large', knowledgeRerankApiKey: 'rerank-secret-value' }) await expect(store.getPublicSettings()).resolves.toMatchObject({ knowledgeRerankApiKeyConfigured: true, knowledgeRerankCredentialSource: 'encrypted' }) await store.update( settings({ knowledgeRerankEndpoint: 'https://other.example/v1/rerank', knowledgeRerankApiKey: { action: 'keep' } }) ) await expect(store.getResolvedSettings()).resolves.toMatchObject({ knowledgeRerankEndpoint: 'https://other.example/v1/rerank', knowledgeRerankApiKey: 'rerank-secret-value' }) }) it('prefers the rerank environment API key without exposing it', async () => { const { filePath, store } = await createStore() await store.update( settings({ knowledgeRerankApiKey: { action: 'replace', value: 'stored-rerank-secret' } }) ) const environmentStore = new RuntimeSettingsStore(filePath, cipher, { GOODBUDDY_RERANK_API_KEY: 'environment-rerank-secret' }) await expect(environmentStore.getResolvedSettings()).resolves.toMatchObject({ knowledgeRerankApiKey: 'environment-rerank-secret' }) const publicSettings = await environmentStore.getPublicSettings() expect(publicSettings).toMatchObject({ knowledgeRerankApiKeyConfigured: true, knowledgeRerankCredentialSource: 'environment' }) expect(JSON.stringify(publicSettings)).not.toContain( 'environment-rerank-secret' ) }) it('does not warn about unreadable stored credentials shadowed by environment keys', async () => { const { filePath, store } = await createStore() await store.update( settings({ apiKey: { action: 'replace', value: 'stored-model-secret' }, knowledgeEmbeddingApiKey: { action: 'replace', value: 'stored-embedding-secret' }, knowledgeRerankApiKey: { action: 'replace', value: 'stored-rerank-secret' } }) ) const environmentStore = new RuntimeSettingsStore( filePath, { ...cipher, decrypt: () => { throw new Error('stored credential is unreadable') } }, { GOODBUDDY_MODEL_API_KEY: 'environment-model-secret', GOODBUDDY_EMBEDDING_API_KEY: 'environment-embedding-secret', GOODBUDDY_RERANK_API_KEY: 'environment-rerank-secret' } ) const publicSettings = await environmentStore.getPublicSettings() expect(publicSettings).toMatchObject({ credentialSource: 'environment', knowledgeEmbeddingCredentialSource: 'environment', knowledgeRerankCredentialSource: 'environment' }) expect(publicSettings.warnings ?? []).toEqual([]) await expect(environmentStore.getResolvedSettings()).resolves.toMatchObject({ apiKey: 'environment-model-secret', knowledgeEmbeddingApiKey: 'environment-embedding-secret', knowledgeRerankApiKey: 'environment-rerank-secret' }) }) it('reads Runtime policy without decrypting stored credentials', async () => { const { filePath, store } = await createStore() await store.update( settings({ subagentSmartRoutingEnabled: true, toolApproval: 'policy', apiKey: { action: 'replace', value: 'stored-model-secret' }, knowledgeEmbeddingApiKey: { action: 'replace', value: 'stored-embedding-secret' }, knowledgeRerankApiKey: { action: 'replace', value: 'stored-rerank-secret' } }) ) const decrypt = vi.fn(cipher.decrypt) const policyStore = new RuntimeSettingsStore( filePath, { ...cipher, decrypt }, {} ) await expect(policyStore.getPolicySettings()).resolves.toEqual({ subagentSmartRoutingEnabled: true, toolApproval: 'policy' }) expect(decrypt).not.toHaveBeenCalled() }) it('clears rerank credentials and rejects replacement without secure storage', async () => { const { filePath, store } = await createStore() await store.update( settings({ knowledgeRerankApiKey: { action: 'replace', value: 'rerank-secret-to-clear' } }) ) await store.update( settings({ knowledgeRerankApiKey: { action: 'clear' } }) ) expect(await readFile(filePath, 'utf8')).not.toContain( 'rerank-secret-to-clear' ) await expect(store.getPublicSettings()).resolves.toMatchObject({ knowledgeRerankApiKeyConfigured: false, knowledgeRerankCredentialSource: 'none' }) const unavailable = new RuntimeSettingsStore(filePath, { ...cipher, isAvailable: () => false }) await expect( unavailable.update( settings({ knowledgeRerankApiKey: { action: 'replace', value: 'must-not-be-persisted' } }) ) ).rejects.toThrow('安全存储不可用') expect(await readFile(filePath, 'utf8')).not.toContain( 'must-not-be-persisted' ) }) 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('keeps an already complete version 6 embedding endpoint unchanged', 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 = 'https://vectors.example/custom/v1/embeddings' delete persisted.knowledgeEmbeddingCredential await writeFile(filePath, JSON.stringify(persisted), 'utf8') const migratedStore = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migratedStore.getPublicSettings()).resolves.toMatchObject({ knowledgeEmbeddingBaseUrl: 'https://vectors.example/custom/v1/embeddings' }) }) it('repairs only an invalid version 6 embedding endpoint', async () => { const { filePath, store } = await createStore() await store.update( settings({ provider: 'continue', workspacePath: 'preserve-this-workspace' }) ) const persisted = JSON.parse(await readFile(filePath, 'utf8')) as Record< string, unknown > persisted.version = 6 persisted.knowledgeEmbeddingBaseUrl = 'not a URL' delete persisted.knowledgeEmbeddingCredential await writeFile(filePath, JSON.stringify(persisted), 'utf8') const migratedStore = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migratedStore.getPublicSettings()).resolves.toMatchObject({ provider: 'continue', workspacePath: 'preserve-this-workspace', knowledgeEmbeddingBaseUrl: 'http://127.0.0.1:11434/v1/embeddings' }) expect( (await readdir(join(filePath, '..'))).some((name) => name.startsWith('runtime-settings.json.corrupt-') ) ).toBe(false) }) 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('keeps an encrypted API key with its model connection when the URL changes', 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 store.update( settings({ modelBaseUrl: 'https://other.example', apiKey: { action: 'keep' } }) ) await expect(store.getResolvedSettings()).resolves.toMatchObject({ apiKey: 'test-secret-value', modelBaseUrl: 'https://other.example' }) await expect(store.getPublicSettings()).resolves.toMatchObject({ apiKeyConfigured: true, credentialSource: 'encrypted' }) }) it('keeps a model API key while authentication is disabled and restores it when re-enabled', async () => { const { store } = await createStore() await store.update( settings({ apiKey: { action: 'replace', value: 'connection-scoped-secret' } }) ) await store.update( settings({ modelAuthentication: 'none', apiKey: { action: 'keep' } }) ) await expect(store.getPublicSettings()).resolves.toMatchObject({ modelAuthentication: 'none', modelProfiles: [ expect.objectContaining({ authentication: 'none', apiKeyConfigured: true, credentialSource: 'encrypted' }) ] }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelAuthentication: 'none', apiKey: undefined }) await store.update( settings({ modelAuthentication: 'api-key', apiKey: { action: 'keep' } }) ) await expect(store.getResolvedSettings()).resolves.toMatchObject({ modelAuthentication: 'api-key', apiKey: 'connection-scoped-secret' }) }) it('reads a legacy API key payload after the model connection URL changes', async () => { const { filePath, store } = await createStore() await store.update(settings()) const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { modelProfiles: Array> } persisted.modelProfiles[0]!.baseUrl = 'https://new.example/v1' persisted.modelProfiles[0]!.credential = { formatVersion: 1, scheme: 'electron-safe-storage', ciphertextBase64: cipher .encrypt( JSON.stringify({ version: 1, apiKey: 'legacy-connection-secret', origin: 'https://old.example' }) ) .toString('base64') } await writeFile(filePath, JSON.stringify(persisted), 'utf8') const migrated = new RuntimeSettingsStore(filePath, cipher, {}) await expect(migrated.getResolvedSettings()).resolves.toMatchObject({ modelBaseUrl: 'https://new.example/v1', apiKey: 'legacy-connection-secret' }) }) 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(21) 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('keeps provider-specific environment aliases for existing deployments', 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: 'legacy-key', GOODBUDDY_BIGTOKEN_BASE_URL: 'https://legacy.example', GOODBUDDY_BIGTOKEN_MODEL: 'legacy-model' }) await expect(environmentStore.getResolvedSettings()).resolves.toMatchObject({ apiKey: 'legacy-key', modelBaseUrl: 'https://legacy.example', modelName: 'legacy-model' }) }) it('uses the default environment key only for the default model connection', async () => { const { filePath, store } = await createStore() const defaultId = '00000000-0000-4000-8000-000000000031' const secondaryId = '00000000-0000-4000-8000-000000000032' await store.update( settings({ modelProfiles: [ { id: defaultId, name: 'Default', baseUrl: 'https://default.example/v1', modelName: 'default-model', protocol: 'openai-chat-completions', authentication: 'api-key', imageGenerationQuality: 'auto', apiKey: { action: 'replace', value: 'stored-default-key' } }, { id: secondaryId, name: 'Secondary', baseUrl: 'https://secondary.example/v1', modelName: 'secondary-model', protocol: 'openai-chat-completions', authentication: 'api-key', imageGenerationQuality: 'auto', apiKey: { action: 'replace', value: 'secondary-key' } } ], defaultModelProfileId: defaultId }) ) const environmentStore = new RuntimeSettingsStore(filePath, cipher, { GOODBUDDY_MODEL_API_KEY: 'environment-default-key', GOODBUDDY_MODEL_BASE_URL: 'https://environment.example/v1', GOODBUDDY_MODEL_NAME: 'environment-model' }) await expect( environmentStore.getResolvedSettings() ).resolves.toMatchObject({ modelProfiles: [ expect.objectContaining({ id: defaultId, apiKey: 'environment-default-key' }), expect.objectContaining({ id: secondaryId, apiKey: 'secondary-key' }) ] }) }) 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', 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('keeps configured model values when environment values are effective', async () => { const { filePath, store } = await createStore() await store.update( settings({ modelBaseUrl: 'https://stored.example/v1', modelName: 'stored-model', apiKey: { action: 'replace', value: 'stored-key' } }) ) const environmentStore = new RuntimeSettingsStore(filePath, cipher, { GOODBUDDY_MODEL_API_KEY: 'environment-key', GOODBUDDY_MODEL_BASE_URL: 'https://environment.example/v1', GOODBUDDY_MODEL_NAME: 'environment-model' }) const publicSettings = await environmentStore.getPublicSettings() expect(publicSettings).toMatchObject({ modelBaseUrl: 'https://environment.example/v1', modelName: 'environment-model', credentialSource: 'environment', modelProfiles: [ expect.objectContaining({ baseUrl: 'https://environment.example/v1', modelName: 'environment-model', credentialSource: 'environment' }) ], configured: { modelProfiles: [ expect.objectContaining({ baseUrl: 'https://stored.example/v1', modelName: 'stored-model', credentialSource: 'environment' }) ] } }) const defaultProfile = publicSettings.configured!.modelProfiles[0]! await environmentStore.update( settings({ modelBaseUrl: defaultProfile.baseUrl, modelName: defaultProfile.modelName, modelProtocol: defaultProfile.protocol, modelAuthentication: defaultProfile.authentication, imageGenerationQuality: defaultProfile.imageGenerationQuality, modelProfiles: publicSettings.configured!.modelProfiles.map( (profile) => ({ id: profile.id, name: profile.name, baseUrl: profile.baseUrl, modelName: profile.modelName, protocol: profile.protocol, authentication: profile.authentication, supportsImageInput: profile.supportsImageInput, imageGenerationQuality: profile.imageGenerationQuality, apiKey: { action: 'keep' } }) ), defaultModelProfileId: publicSettings.defaultModelProfileId }) ) await expect( new RuntimeSettingsStore(filePath, cipher, {}).getResolvedSettings() ).resolves.toMatchObject({ modelBaseUrl: 'https://stored.example/v1', modelName: 'stored-model', apiKey: 'stored-key' }) }) 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: 21, 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(21) 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', warnings: [{ code: 'runtime-settings-recovered' }] }) const files = await readdir(join(filePath, '..')) expect( files.some((name) => name.startsWith('runtime-settings.json.corrupt-')) ).toBe(true) }) it('distinguishes an unreadable saved credential from a missing credential', async () => { const { filePath, store } = await createStore() await store.update( settings({ apiKey: { action: 'replace', value: 'credential-that-will-become-unreadable' } }) ) const unreadable = new RuntimeSettingsStore( filePath, { ...cipher, decrypt: () => { throw new Error('cannot decrypt') } }, {} ) await expect(unreadable.getPublicSettings()).resolves.toMatchObject({ apiKeyConfigured: false, credentialSource: 'unreadable', modelProfiles: [ expect.objectContaining({ credentialSource: 'unreadable' }) ], warnings: [ expect.objectContaining({ code: 'runtime-model-credential-unreadable', subject: '默认模型' }) ] }) }) it('clears credential warnings after secure storage recovers', async () => { const { filePath, store } = await createStore() await store.update( settings({ apiKey: { action: 'replace', value: 'recoverable-model-credential' }, knowledgeEmbeddingApiKey: { action: 'replace', value: 'recoverable-embedding-credential' }, knowledgeRerankApiKey: { action: 'replace', value: 'recoverable-rerank-credential' } }) ) let decryptAvailable = false const recoveringStore = new RuntimeSettingsStore( filePath, { ...cipher, decrypt: (value) => { if (!decryptAvailable) { throw new Error('secure storage is temporarily unavailable') } return cipher.decrypt(value) } }, {} ) await expect(recoveringStore.getPublicSettings()).resolves.toMatchObject({ warnings: expect.arrayContaining([ expect.objectContaining({ code: 'runtime-model-credential-unreadable' }), { code: 'runtime-embedding-credential-unreadable' }, { code: 'runtime-rerank-credential-unreadable' } ]) }) decryptAvailable = true await expect(recoveringStore.getPublicSettings()).resolves.toMatchObject({ apiKeyConfigured: true, knowledgeEmbeddingApiKeyConfigured: true, knowledgeRerankApiKeyConfigured: true }) expect((await recoveringStore.getPublicSettings()).warnings ?? []).toEqual( [] ) }) })