OpenCode and Continue customization was previously planned but unavailable, and Runtime-native capabilities were not represented consistently across providers. GoodBuddy now provides secure Main-owned customization settings, truthful native inventories, OpenCode Agents and Commands, Continue Rules and Prompts, DSH Web Search/Fetch, MCP Prompt and Resource metadata, and manual compaction where supported. Native capabilities are presented in eleven accessible tabs with Tools separated from Commands, LSP, and Formatters. Tool source and Ask/Execute availability are explicit, external OpenCode remains connection-only, Continue reports unsupported static tool discovery instead of advertising unreachable Skills, and disposable inventory probes avoid retaining background runtimes. Ask remains read-only at the Runtime boundary, Execute keeps the existing authorization controls, and credentials remain confined to Main. Release note: 新增 OpenCode、Continue 与 DeepSeek Harness 的 Runtime 原生定制与真实能力清单;工具来源、Ask/Execute 可用性、上下文压缩和 MCP 元数据现在可清晰查看,同时继续保持 Main 进程凭据保护与现有权限边界。
2042 lines
64 KiB
TypeScript
2042 lines
64 KiB
TypeScript
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 {
|
|
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> = {}
|
|
): 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('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<string, unknown>
|
|
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<string, unknown>
|
|
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,
|
|
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
|
|
}
|
|
],
|
|
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
|
|
}
|
|
],
|
|
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: '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('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<string, unknown>
|
|
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('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:
|
|
'https://gateway.example/openai/v1',
|
|
GOODBUDDY_MODEL_NAME: 'qwen-plus'
|
|
})
|
|
|
|
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
|
modelProtocol: 'anthropic-messages',
|
|
deepseekHarnessModelProfile: {
|
|
id: 'goodbuddy-platform-harness',
|
|
name: '管理员预置模型',
|
|
baseUrl: 'https://gateway.example/openai/v1',
|
|
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('anthropic-messages')
|
|
})
|
|
|
|
it.each([
|
|
[
|
|
'an insecure public endpoint',
|
|
{
|
|
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
|
GOODBUDDY_MODEL_BASE_URL: 'http://gateway.example/v1',
|
|
GOODBUDDY_MODEL_NAME: 'qwen-plus'
|
|
}
|
|
],
|
|
[
|
|
'an endpoint with embedded credentials',
|
|
{
|
|
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
|
GOODBUDDY_MODEL_BASE_URL:
|
|
'https://user:secret@gateway.example/v1',
|
|
GOODBUDDY_MODEL_NAME: 'qwen-plus'
|
|
}
|
|
],
|
|
[
|
|
'an endpoint with a query string',
|
|
{
|
|
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
|
GOODBUDDY_MODEL_BASE_URL:
|
|
'https://gateway.example/v1?api-version=1',
|
|
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<string, unknown>
|
|
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<string, unknown>
|
|
expect(persisted.version).toBe(18)
|
|
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(18)
|
|
})
|
|
|
|
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(18)
|
|
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<Record<string, unknown>>
|
|
}
|
|
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<Record<string, unknown>>
|
|
}
|
|
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('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 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('encrypts and endpoint-binds the rerank API key', 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 expect(
|
|
store.update(
|
|
settings({
|
|
knowledgeRerankEndpoint: 'https://other.example/v1/rerank',
|
|
knowledgeRerankApiKey: { action: 'keep' }
|
|
})
|
|
)
|
|
).rejects.toThrow('重排接口 URL 已更改')
|
|
})
|
|
|
|
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<Record<string, unknown>>
|
|
}
|
|
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<Record<string, unknown>>
|
|
}
|
|
expect(persisted.version).toBe(18)
|
|
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('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: 18,
|
|
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<Record<string, unknown>>
|
|
}
|
|
expect(persisted.version).toBe(18)
|
|
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(
|
|
[]
|
|
)
|
|
})
|
|
})
|