fix: harden scoped tools and settings persistence

This commit is contained in:
lofyer
2026-08-13 14:56:53 +08:00
parent bf1ec5d2f1
commit aab961226f
62 changed files with 4343 additions and 1314 deletions
+1
View File
@@ -139,6 +139,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
readonly runtimeId = 'continue' readonly runtimeId = 'continue'
readonly requiresToolApproval = false readonly requiresToolApproval = false
readonly supportsToolExecution = true readonly supportsToolExecution = true
readonly supportsScopedDataTools = true
private detection?: Promise<RuntimeBinaryDetection> private detection?: Promise<RuntimeBinaryDetection>
private readonly hostAdapters = new Map< private readonly hostAdapters = new Map<
RuntimeSettings['continueMode'], RuntimeSettings['continueMode'],
+221
View File
@@ -1096,7 +1096,9 @@ describe('ModelAgentRuntime', () => {
tool_calls: [ tool_calls: [
{ {
index: 0, index: 0,
id: '',
function: { function: {
name: '',
arguments: '"README.md"}' arguments: '"README.md"}'
} }
} }
@@ -1219,6 +1221,86 @@ describe('ModelAgentRuntime', () => {
expect(events.at(-1)).toMatchObject({ type: 'done' }) expect(events.at(-1)).toMatchObject({ type: 'done' })
}) })
it('synthesizes and pairs a missing OpenAI Chat tool call id', async () => {
const responses = [
{
id: 'chatcmpl-missing-call-id-1',
model: 'qwen3',
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{"path":"README.md"}'
}
}
]
}
}
]
},
{
id: 'chatcmpl-missing-call-id-2',
model: 'qwen3',
choices: [
{
message: {
role: 'assistant',
content: '读取完成。'
}
}
]
}
]
const fetcher = vi.fn<typeof fetch>(async () =>
Response.json(responses.shift())
)
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider: createToolProvider()
})
for await (const _event of runtime.run(
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed143',
conversationId: 'conversation-chat-fallback-id',
prompt: '读取 README',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
void _event
}
const secondBody = JSON.parse(
fetcher.mock.calls[1]?.[1]?.body as string
) as { messages: Array<Record<string, unknown>> }
const assistant = secondBody.messages.at(-2) as {
tool_calls: Array<Record<string, unknown>>
}
const result = secondBody.messages.at(-1) as {
tool_call_id: string
}
const toolCallId = assistant.tool_calls[0]?.id
expect(toolCallId).toEqual(
expect.stringMatching(/^goodbuddy_call_[0-9a-f]{32}$/u)
)
expect(result).toMatchObject({
role: 'tool',
tool_call_id: toolCallId
})
})
it('uses refreshed tool definitions in subsequent model rounds', async () => { it('uses refreshed tool definitions in subsequent model rounds', async () => {
const loadTool: ModelToolDefinition = { const loadTool: ModelToolDefinition = {
name: 'mcp_load_tools', name: 'mcp_load_tools',
@@ -1833,6 +1915,81 @@ describe('ModelAgentRuntime', () => {
expect(events.at(-1)).toMatchObject({ type: 'done' }) expect(events.at(-1)).toMatchObject({ type: 'done' })
}) })
it('pairs a missing Responses call_id with the function-call item id', async () => {
const responses = [
{
id: 'resp-tool-fallback-1',
model: 'gpt-5',
output: [
{
id: 'fc-responses-fallback-1',
type: 'function_call',
name: 'workspace_read_text',
arguments: '{"path":"README.md"}'
}
]
},
{
id: 'resp-tool-fallback-2',
model: 'gpt-5',
output: [
{
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: '读取完成。'
}
]
}
]
}
]
const fetcher = vi.fn<typeof fetch>(async () =>
Response.json(responses.shift())
)
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-5',
protocol: 'openai-responses',
authentication: 'api-key',
fetcher,
toolProvider: createToolProvider()
})
for await (const _event of runtime.run(
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed141',
conversationId: 'conversation-responses-fallback-id',
prompt: '读取 README',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
void _event
}
const secondBody = JSON.parse(
fetcher.mock.calls[1]?.[1]?.body as string
) as { input: Array<Record<string, unknown>> }
expect(secondBody.input).toContainEqual(
expect.objectContaining({
id: 'fc-responses-fallback-1',
type: 'function_call',
call_id: 'fc-responses-fallback-1'
})
)
expect(secondBody.input).toContainEqual(
expect.objectContaining({
type: 'function_call_output',
call_id: 'fc-responses-fallback-1'
})
)
})
it('fails closed when a direct-model tool is denied', async () => { it('fails closed when a direct-model tool is denied', async () => {
const fetcher = vi.fn<typeof fetch>(async () => const fetcher = vi.fn<typeof fetch>(async () =>
Response.json({ Response.json({
@@ -1980,6 +2137,70 @@ describe('ModelAgentRuntime', () => {
}) })
}) })
it('synthesizes and pairs a missing Anthropic tool_use id', async () => {
const responses = [
{
id: 'message-tool-missing-id-1',
model: 'claude',
content: [
{
type: 'tool_use',
name: 'workspace_read_text',
input: { path: 'notes.md' }
}
]
},
{
id: 'message-tool-missing-id-2',
model: 'claude',
content: [{ type: 'text', text: '读取完成。' }]
}
]
const fetcher = vi.fn<typeof fetch>(async () =>
Response.json(responses.shift())
)
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'claude',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher,
toolProvider: createToolProvider()
})
for await (const _event of runtime.run(
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed142',
conversationId: 'conversation-anthropic-fallback-id',
prompt: '读取 notes',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
void _event
}
const secondBody = JSON.parse(
fetcher.mock.calls[1]?.[1]?.body as string
) as { messages: Array<Record<string, unknown>> }
const assistant = secondBody.messages.at(-2) as {
content: Array<Record<string, unknown>>
}
const result = secondBody.messages.at(-1) as {
content: Array<Record<string, unknown>>
}
const toolUseId = assistant.content[0]?.id
expect(toolUseId).toEqual(
expect.stringMatching(/^goodbuddy_call_[0-9a-f]{32}$/u)
)
expect(result.content[0]).toMatchObject({
type: 'tool_result',
tool_use_id: toolUseId
})
})
it('does not issue a follow-up model request after tool cancellation', async () => { it('does not issue a follow-up model request after tool cancellation', async () => {
const response = { const response = {
choices: [ choices: [
+29 -9
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto'
import type { import type {
ApprovalDecision, ApprovalDecision,
AgentRuntimeStatus, AgentRuntimeStatus,
@@ -725,21 +726,30 @@ function getChatToolImageCarrierContent(
] ]
} }
function createToolCallId(): string {
return `goodbuddy_call_${randomBytes(16).toString('hex')}`
}
function parseToolCallIdentity( function parseToolCallIdentity(
id: unknown, id: unknown,
name: unknown name: unknown,
fallbackId?: unknown
): { id: string; name: string } { ): { id: string; name: string } {
const resolvedId =
typeof id === 'string' && id.length > 0
? id
: typeof fallbackId === 'string' && fallbackId.length > 0
? fallbackId
: createToolCallId()
if ( if (
typeof id !== 'string' || resolvedId.length > 256 ||
id.length === 0 ||
id.length > 256 ||
typeof name !== 'string' || typeof name !== 'string' ||
name.length === 0 || name.length === 0 ||
name.length > 128 name.length > 128
) { ) {
throw new Error('模型返回了无效的工具调用标识') throw new Error('模型返回了无效的工具调用标识或名称')
} }
return { id, name } return { id: resolvedId, name }
} }
function parseModelToolResponse( function parseModelToolResponse(
@@ -771,6 +781,7 @@ function parseModelToolResponse(
reasoning.push(record.thinking) reasoning.push(record.thinking)
} else if (record.type === 'tool_use') { } else if (record.type === 'tool_use') {
const identity = parseToolCallIdentity(record.id, record.name) const identity = parseToolCallIdentity(record.id, record.name)
record.id = identity.id
toolCalls.push({ toolCalls.push({
...identity, ...identity,
arguments: parseToolArguments(record.input) arguments: parseToolArguments(record.input)
@@ -845,8 +856,10 @@ function parseModelToolResponse(
} else if (output.type === 'function_call') { } else if (output.type === 'function_call') {
const identity = parseToolCallIdentity( const identity = parseToolCallIdentity(
output.call_id, output.call_id,
output.name output.name,
output.id
) )
output.call_id = identity.id
toolCalls.push({ toolCalls.push({
...identity, ...identity,
arguments: parseToolArguments(output.arguments) arguments: parseToolArguments(output.arguments)
@@ -893,6 +906,7 @@ function parseModelToolResponse(
toolCall.id, toolCall.id,
functionCall.name functionCall.name
) )
toolCall.id = identity.id
toolCalls.push({ toolCalls.push({
...identity, ...identity,
arguments: parseToolArguments(functionCall.arguments) arguments: parseToolArguments(functionCall.arguments)
@@ -1112,6 +1126,10 @@ export class ModelAgentRuntime implements AgentRuntime {
return this.capability === 'chat' return this.capability === 'chat'
} }
get supportsScopedDataTools(): boolean {
return this.capability === 'chat'
}
private isConfigured(): boolean { private isConfigured(): boolean {
return ( return (
this.options.authentication === 'none' || this.options.authentication === 'none' ||
@@ -1665,11 +1683,13 @@ export class ModelAgentRuntime implements AgentRuntime {
? functionDelta.arguments ? functionDelta.arguments
: ''), : ''),
id: id:
typeof toolDelta?.id === 'string' typeof toolDelta?.id === 'string' &&
toolDelta.id.length > 0
? toolDelta.id ? toolDelta.id
: current.id, : current.id,
name: name:
typeof functionDelta?.name === 'string' typeof functionDelta?.name === 'string' &&
functionDelta.name.length > 0
? functionDelta.name ? functionDelta.name
: current.name : current.name
} }
+4
View File
@@ -565,6 +565,10 @@ export class OpenCodeRuntime implements AgentRuntime {
return this.options.embedded && !this.options.baseUrl return this.options.embedded && !this.options.baseUrl
} }
get supportsScopedDataTools(): boolean {
return this.usesEmbeddedPermissionMediation()
}
private async acquireEmbeddedRun( private async acquireEmbeddedRun(
signal: AbortSignal signal: AbortSignal
): Promise<() => void> { ): Promise<() => void> {
+4
View File
@@ -45,6 +45,10 @@ export class AgentRuntimeController implements AgentRuntime {
return this.current.runtime.supportsToolExecution return this.current.runtime.supportsToolExecution
} }
get supportsScopedDataTools(): boolean {
return this.current.runtime.supportsScopedDataTools !== false
}
get capability(): AgentRuntime['capability'] { get capability(): AgentRuntime['capability'] {
return this.current.runtime.capability return this.current.runtime.capability
} }
+2
View File
@@ -51,6 +51,8 @@ export interface AgentRuntime {
readonly runtimeId?: AgentRuntimeStatus['id'] readonly runtimeId?: AgentRuntimeStatus['id']
readonly requiresToolApproval: boolean readonly requiresToolApproval: boolean
readonly supportsToolExecution: boolean readonly supportsToolExecution: boolean
/** Whether request-scoped GoodBuddy data tools can reach this runtime. */
readonly supportsScopedDataTools?: boolean
readonly capability?: 'chat' | 'image-generation' readonly capability?: 'chat' | 'image-generation'
getStatus(): Promise<AgentRuntimeStatus> getStatus(): Promise<AgentRuntimeStatus>
testConnection?(): Promise<AgentRuntimeStatus> testConnection?(): Promise<AgentRuntimeStatus>
+1
View File
@@ -11,6 +11,7 @@ export class UnconfiguredAgentRuntime implements AgentRuntime {
readonly runtimeId = 'setup' readonly runtimeId = 'setup'
readonly requiresToolApproval = false readonly requiresToolApproval = false
readonly supportsToolExecution = false readonly supportsToolExecution = false
readonly supportsScopedDataTools = false
getStatus(): Promise<AgentRuntimeStatus> { getStatus(): Promise<AgentRuntimeStatus> {
return Promise.resolve({ return Promise.resolve({
+23 -3
View File
@@ -266,9 +266,10 @@ describe('ApplicationSettingsStore', () => {
const { directory, filePath, store } = await createStore() const { directory, filePath, store } = await createStore()
await writeFile(filePath, data, 'utf8') await writeFile(filePath, data, 'utf8')
await expect(store.get()).resolves.toEqual( await expect(store.get()).resolves.toEqual({
defaultApplicationSettings ...defaultApplicationSettings,
) warnings: [{ code: 'application-settings-recovered' }]
})
const entries = await readdir(directory) const entries = await readdir(directory)
expect(entries).toHaveLength(1) expect(entries).toHaveLength(1)
expect(entries[0]).toMatch( expect(entries[0]).toMatch(
@@ -279,6 +280,25 @@ describe('ApplicationSettingsStore', () => {
) )
}) })
it('preserves settings created by a newer unsupported version', async () => {
const { directory, filePath, store } = await createStore()
const futureSettings = JSON.stringify({
version: 99,
futureField: 'keep-me'
})
await writeFile(filePath, futureSettings, 'utf8')
await expect(store.get()).rejects.toThrow(
'不支持应用设置版本 99'
)
expect(await readFile(filePath, 'utf8')).toBe(futureSettings)
expect(
(await readdir(directory)).some((name) =>
name.startsWith('application-settings.json.corrupt-')
)
).toBe(false)
})
it('does not classify an I/O failure as corrupt settings', async () => { it('does not classify an I/O failure as corrupt settings', async () => {
const { directory } = await createStore() const { directory } = await createStore()
const filePath = join(directory, 'settings-directory') const filePath = join(directory, 'settings-directory')
+32 -51
View File
@@ -1,12 +1,4 @@
import { import { readFile } from 'node:fs/promises'
mkdir,
readFile,
rename,
rm,
writeFile
} from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
import { dirname } from 'node:path'
import { z } from 'zod' import { z } from 'zod'
import { import {
applicationSettingsSchema, applicationSettingsSchema,
@@ -14,6 +6,14 @@ import {
type ApplicationSettings type ApplicationSettings
} from '../shared/application-settings-contracts' } from '../shared/application-settings-contracts'
import { releaseVersionSchema } from '../shared/release-notes-contracts' import { releaseVersionSchema } from '../shared/release-notes-contracts'
import type { SettingsWarning } from '../shared/settings-warning-contracts'
import {
assertSupportedSettingsVersion,
isolateCorruptSettingsFile,
isMissingFileError,
UnsupportedSettingsVersionError,
writeJsonFileAtomically
} from './settings-file-utils'
export { export {
applicationSettingsSchema, applicationSettingsSchema,
applicationSettingsUpdateSchema applicationSettingsUpdateSchema
@@ -70,36 +70,19 @@ export const defaultApplicationSettings: ApplicationSettings = {
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
} }
function isMissingFile(error: unknown): boolean {
return (
error !== null &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
}
export class ApplicationSettingsStore { export class ApplicationSettingsStore {
private settings?: StoredApplicationSettings private settings?: StoredApplicationSettings
private settingsLoad?: Promise<StoredApplicationSettings> private settingsLoad?: Promise<StoredApplicationSettings>
private warnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve() private updateQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {} constructor(private readonly filePath: string) {}
private async isolateCorruptFile(): Promise<void> { private async isolateCorruptFile(): Promise<void> {
const isolatedPath = await isolateCorruptSettingsFile(
`${this.filePath}.corrupt-${Date.now()}-` + this.filePath,
randomBytes(6).toString('hex') 'Application settings are corrupt and could not be isolated'
try { )
await rename(this.filePath, isolatedPath)
} catch (error) {
if (!isMissingFile(error)) {
throw new Error(
'Application settings are corrupt and could not be isolated',
{ cause: error }
)
}
}
} }
private async loadStored(): Promise<StoredApplicationSettings> { private async loadStored(): Promise<StoredApplicationSettings> {
@@ -122,6 +105,7 @@ export class ApplicationSettingsStore {
parsed = JSON.parse(contents) as unknown parsed = JSON.parse(contents) as unknown
} catch { } catch {
await this.isolateCorruptFile() await this.isolateCorruptFile()
this.warnings = [{ code: 'application-settings-recovered' }]
this.settings = { this.settings = {
version: CURRENT_SETTINGS_VERSION, version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null, lastSeenReleaseNotesVersion: null,
@@ -129,6 +113,12 @@ export class ApplicationSettingsStore {
} }
return this.settings return this.settings
} }
assertSupportedSettingsVersion(
parsed,
CURRENT_SETTINGS_VERSION,
(version) =>
`当前 GoodBuddy 不支持应用设置版本 ${version},请升级应用后重试`
)
const result = storedApplicationSettingsSchema.safeParse(parsed) const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) { if (!result.success) {
const versionFourResult = const versionFourResult =
@@ -179,6 +169,7 @@ export class ApplicationSettingsStore {
return this.settings return this.settings
} }
await this.isolateCorruptFile() await this.isolateCorruptFile()
this.warnings = [{ code: 'application-settings-recovered' }]
this.settings = { this.settings = {
version: CURRENT_SETTINGS_VERSION, version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null, lastSeenReleaseNotesVersion: null,
@@ -188,7 +179,10 @@ export class ApplicationSettingsStore {
} }
this.settings = result.data this.settings = result.data
} catch (error) { } catch (error) {
if (!isMissingFile(error)) { if (error instanceof UnsupportedSettingsVersionError) {
throw error
}
if (!isMissingFileError(error)) {
throw new Error('Application settings could not be read', { throw new Error('Application settings could not be read', {
cause: error cause: error
}) })
@@ -208,7 +202,10 @@ export class ApplicationSettingsStore {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup, checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
magicNotesEnabled: stored.magicNotesEnabled, magicNotesEnabled: stored.magicNotesEnabled,
magicNoteCommentMode: stored.magicNoteCommentMode, magicNoteCommentMode: stored.magicNoteCommentMode,
magicNoteCommentFormat: stored.magicNoteCommentFormat magicNoteCommentFormat: stored.magicNoteCommentFormat,
...(this.warnings.length > 0
? { warnings: [...this.warnings] }
: {})
} }
} }
@@ -217,24 +214,7 @@ export class ApplicationSettingsStore {
} }
private async persist(next: StoredApplicationSettings): Promise<void> { private async persist(next: StoredApplicationSettings): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true }) await writeJsonFileAtomically(this.filePath, next)
const temporaryPath =
`${this.filePath}.${process.pid}.` +
`${randomBytes(6).toString('hex')}.tmp`
try {
await writeFile(
temporaryPath,
`${JSON.stringify(next, null, 2)}\n`,
{
encoding: 'utf8',
mode: 0o600,
flag: 'wx'
}
)
await rename(temporaryPath, this.filePath)
} finally {
await rm(temporaryPath, { force: true })
}
this.settings = next this.settings = next
} }
@@ -248,6 +228,7 @@ export class ApplicationSettingsStore {
version: CURRENT_SETTINGS_VERSION version: CURRENT_SETTINGS_VERSION
} }
await this.persist(next) await this.persist(next)
this.warnings = []
return { return {
checkUpdatesOnStartup: next.checkUpdatesOnStartup, checkUpdatesOnStartup: next.checkUpdatesOnStartup,
magicNotesEnabled: next.magicNotesEnabled, magicNotesEnabled: next.magicNotesEnabled,
@@ -1386,7 +1386,7 @@ describe('AssistantDatabase', () => {
database.close() database.close()
}) })
it('rebinds persisted conversations whose model profile was removed', async () => { it('repairs unattended channel selections without rebinding ordinary conversations', async () => {
const database = await createDatabase() const database = await createDatabase()
const removedProfileId = const removedProfileId =
'00000000-0000-4000-8000-000000000291' '00000000-0000-4000-8000-000000000291'
@@ -1478,7 +1478,7 @@ describe('AssistantDatabase', () => {
}, },
continueModelSource: { kind: 'platform' } continueModelSource: { kind: 'platform' }
}) })
).toBe(7) ).toBe(4)
expect( expect(
database database
.listConversations() .listConversations()
@@ -1486,9 +1486,9 @@ describe('AssistantDatabase', () => {
.sort((left, right) => left.title.localeCompare(right.title)) .sort((left, right) => left.title.localeCompare(right.title))
.map((conversation) => conversation.runtimeSelection) .map((conversation) => conversation.runtimeSelection)
).toEqual([ ).toEqual([
{ provider: 'model', profileId: defaultProfileId }, { provider: 'model', profileId: removedProfileId },
{ provider: 'opencode', profileId: runtimeProfileId }, { provider: 'opencode', profileId: removedProfileId },
{ provider: 'continue' }, { provider: 'continue', profileId: removedProfileId },
{ provider: 'model', profileId: runtimeProfileId } { provider: 'model', profileId: runtimeProfileId }
]) ])
expect(database.getProject(channelProject.id).runtimeSelection).toEqual({ expect(database.getProject(channelProject.id).runtimeSelection).toEqual({
+3 -5
View File
@@ -42,7 +42,6 @@ import {
import { import {
agentRuntimeSelectionKey, agentRuntimeSelectionKey,
agentRuntimeSelectionSchema, agentRuntimeSelectionSchema,
repairAgentRuntimeSelection,
repairChannelRuntimeSelection, repairChannelRuntimeSelection,
type AgentRuntimeSelection, type AgentRuntimeSelection,
type RuntimeSelectionRepairSettings type RuntimeSelectionRepairSettings
@@ -1363,7 +1362,8 @@ export class AssistantDatabase {
.prepare( .prepare(
`SELECT id, runtime_selection_json, channel `SELECT id, runtime_selection_json, channel
FROM conversations FROM conversations
WHERE runtime_selection_json IS NOT NULL` WHERE runtime_selection_json IS NOT NULL
AND channel IS NOT NULL`
) )
.all() as Array<{ .all() as Array<{
id: string id: string
@@ -1410,9 +1410,7 @@ export class AssistantDatabase {
if (!current) { if (!current) {
continue continue
} }
const next = conversation.channel const next = repairChannelRuntimeSelection(current, settings)
? repairChannelRuntimeSelection(current, settings)
: repairAgentRuntimeSelection(current, settings)
if ( if (
agentRuntimeSelectionKey(next) === agentRuntimeSelectionKey(next) ===
agentRuntimeSelectionKey(current) agentRuntimeSelectionKey(current)
@@ -80,6 +80,32 @@ describe('RemoteDelegationService', () => {
).toHaveLength(2) ).toHaveLength(2)
}) })
it('shares one in-flight poll between concurrent callers', async () => {
let releaseTransport!: () => void
const transportReleased = new Promise<void>((resolve) => {
releaseTransport = resolve
})
const transport = vi.fn(async () => {
await transportReleased
return { status: 204, body: '' }
})
const service = new RemoteDelegationService({
endpoint: 'https://delegate.example',
token: 'test-token',
lookup: async () => [{ address: '1.1.1.1', family: 4 }],
transport,
onTask: vi.fn()
})
const first = service.pollOnce()
const second = service.pollOnce()
await vi.waitFor(() => expect(transport).toHaveBeenCalledOnce())
releaseTransport()
await Promise.all([first, second])
expect(transport).toHaveBeenCalledOnce()
})
it('drains a durable outbox before accepting another task', async () => { it('drains a durable outbox before accepting another task', async () => {
const records = new Map< const records = new Map<
string, string,
@@ -157,7 +183,7 @@ describe('RemoteDelegationService', () => {
const polling = service.pollOnce() const polling = service.pollOnce()
await vi.waitFor(() => expect(observedSignal).toBeDefined()) await vi.waitFor(() => expect(observedSignal).toBeDefined())
service.stop() await service.stop()
await expect(polling).rejects.toBeDefined() await expect(polling).rejects.toBeDefined()
expect(observedSignal?.aborted).toBe(true) expect(observedSignal?.aborted).toBe(true)
@@ -157,7 +157,7 @@ export class RemoteDelegationService {
private readonly pendingResults = new Map<string, RemoteResult>() private readonly pendingResults = new Map<string, RemoteResult>()
private interval?: NodeJS.Timeout private interval?: NodeJS.Timeout
private activeRequest?: AbortController private activeRequest?: AbortController
private polling = false private activePoll?: Promise<void>
constructor(private readonly options: RemoteDelegationOptions) { constructor(private readonly options: RemoteDelegationOptions) {
this.endpoint = normalizeEndpoint(options.endpoint) this.endpoint = normalizeEndpoint(options.endpoint)
@@ -179,19 +179,29 @@ export class RemoteDelegationService {
void this.pollOnce().catch(() => undefined) void this.pollOnce().catch(() => undefined)
} }
stop(): void { async stop(): Promise<void> {
if (this.interval) { if (this.interval) {
clearInterval(this.interval) clearInterval(this.interval)
this.interval = undefined this.interval = undefined
} }
this.activeRequest?.abort() this.activeRequest?.abort()
await this.activePoll?.catch(() => undefined)
} }
async pollOnce(): Promise<void> { pollOnce(): Promise<void> {
if (this.polling) { if (this.activePoll) {
return return this.activePoll
} }
this.polling = true const operation = this.performPoll()
this.activePoll = operation
return operation.finally(() => {
if (this.activePoll === operation) {
this.activePoll = undefined
}
})
}
private async performPoll(): Promise<void> {
const controller = new AbortController() const controller = new AbortController()
this.activeRequest = controller this.activeRequest = controller
try { try {
@@ -260,7 +270,6 @@ export class RemoteDelegationService {
if (this.activeRequest === controller) { if (this.activeRequest === controller) {
this.activeRequest = undefined this.activeRequest = undefined
} }
this.polling = false
} }
} }
@@ -3,10 +3,7 @@ import {
lstat, lstat,
mkdir, mkdir,
readFile, readFile,
realpath, realpath
rename,
rm,
writeFile
} from 'node:fs/promises' } from 'node:fs/promises'
import { isAbsolute, join, relative, resolve } from 'node:path' import { isAbsolute, join, relative, resolve } from 'node:path'
import { z } from 'zod' import { z } from 'zod'
@@ -14,6 +11,10 @@ import {
browserProfileIdSchema, browserProfileIdSchema,
browserProfileNameSchema browserProfileNameSchema
} from '../../shared/capability-contracts' } from '../../shared/capability-contracts'
import {
isMissingFileError,
writeJsonFileAtomically
} from '../settings-file-utils'
const MAX_PROFILES = 32 const MAX_PROFILES = 32
const MAX_REFERENCES = 64 const MAX_REFERENCES = 64
@@ -204,12 +205,7 @@ export class FileBrowserProfileStore implements BrowserProfileStore {
} }
return JSON.parse(await readFile(filePath, 'utf8')) as unknown return JSON.parse(await readFile(filePath, 'utf8')) as unknown
} catch (error) { } catch (error) {
if ( if (isMissingFileError(error)) {
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return undefined return undefined
} }
throw error throw error
@@ -217,36 +213,22 @@ export class FileBrowserProfileStore implements BrowserProfileStore {
} }
async save(state: BrowserProfileState): Promise<void> { async save(state: BrowserProfileState): Promise<void> {
const { root, filePath } = await this.prepareRoot() const { filePath } = await this.prepareRoot()
try { try {
const targetDetails = await lstat(filePath) const targetDetails = await lstat(filePath)
if (targetDetails.isSymbolicLink() || !targetDetails.isFile()) { if (targetDetails.isSymbolicLink() || !targetDetails.isFile()) {
throw new Error('Browser profile storage file must be a regular file') throw new Error('Browser profile storage file must be a regular file')
} }
} catch (error) { } catch (error) {
if ( if (!isMissingFileError(error)) {
!(
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
) {
throw error throw error
} }
} }
const temporaryPath = join(root, `.${this.fileName}.${randomUUID()}.tmp`) await writeJsonFileAtomically(
try { filePath,
await writeFile( browserProfileStateSchema.parse(state)
temporaryPath, )
`${JSON.stringify(browserProfileStateSchema.parse(state), null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600, flag: 'wx' }
)
await rename(temporaryPath, filePath)
} finally {
await rm(temporaryPath, { force: true })
}
} }
} }
@@ -1,4 +1,11 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import {
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { strToU8, zipSync } from 'fflate' import { strToU8, zipSync } from 'fflate'
@@ -847,6 +854,98 @@ describe('CapabilityService', () => {
expect(persisted).toContain('"allowDynamicTools": false') expect(persisted).toContain('"allowDynamicTools": false')
}) })
it('preserves capabilities created by a newer unsupported version', async () => {
const { directory, filePath, builtinRoot, importedRoot } =
await createService()
const futureCapabilities = JSON.stringify({
version: 99,
skills: {
'document-writing': {
enabled: false,
assignments: ['model']
}
},
mcpServers: [{ futureTransport: 'keep-me' }],
webSearch: { enabled: false },
futureField: 'keep-me'
})
await writeFile(filePath, futureCapabilities, 'utf8')
const service = new CapabilityService(
filePath,
builtinRoot,
importedRoot,
cipher
)
await expect(service.getSnapshot()).rejects.toThrow(
'不支持能力设置版本 99'
)
expect(await readFile(filePath, 'utf8')).toBe(futureCapabilities)
expect(
(await readdir(directory)).some((name) =>
name.startsWith('capabilities.json.corrupt-')
)
).toBe(false)
})
it('continues isolating truly corrupt capability settings', async () => {
const { directory, filePath, service } = await createService()
await writeFile(filePath, '{not-json', 'utf8')
await expect(service.getSnapshot()).resolves.toMatchObject({
webSearch: { enabled: false },
mcpServers: [],
warnings: [{ code: 'capability-settings-recovered' }]
})
const entries = await readdir(directory)
expect(
entries.some((name) =>
name.startsWith('capabilities.json.corrupt-')
)
).toBe(true)
})
it('clears the recovery warning after a reviewed capability change', async () => {
const { filePath, service } = await createService()
await writeFile(filePath, '{not-json', 'utf8')
await expect(service.getSnapshot()).resolves.toMatchObject({
warnings: [{ code: 'capability-settings-recovered' }]
})
await expect(
service.setWebSearchEnabled(true)
).resolves.not.toHaveProperty('warnings')
})
it('preserves corrupt capability settings when isolation fails', async () => {
const { directory, filePath } = await createService()
const corruptContents = '{not-json'
await writeFile(filePath, corruptContents, 'utf8')
const service = new CapabilityService(
filePath,
join(directory, 'builtin'),
join(directory, 'imported'),
cipher,
{
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
),
settingsFileOperations: {
rename: vi.fn(async () => {
throw Object.assign(new Error('rename denied'), {
code: 'EACCES'
})
})
}
}
)
await expect(service.getSnapshot()).rejects.toThrow(
'能力设置已损坏且无法隔离'
)
expect(await readFile(filePath, 'utf8')).toBe(corruptContents)
})
it('gates enablement on the supported platform and architecture', async () => { it('gates enablement on the supported platform and architecture', async () => {
const { service } = await createService({ const { service } = await createService({
platform: 'darwin', platform: 'darwin',
+82 -65
View File
@@ -38,6 +38,21 @@ import {
type RuntimeTarget, type RuntimeTarget,
type SkillSummary type SkillSummary
} from '../../shared/capability-contracts' } from '../../shared/capability-contracts'
import type { SettingsWarning } from '../../shared/settings-warning-contracts'
import {
assertSupportedSettingsVersion,
isolateCorruptSettingsFile,
isMissingFileError,
type SettingsFileOperations,
UnsupportedSettingsVersionError,
writeJsonFileAtomically
} from '../settings-file-utils'
import {
decryptSettingsCredential,
encryptedSettingsCredentialSchema,
encryptSettingsCredential,
type SettingsCredentialCipher
} from '../settings-credential-cipher'
import { import {
BrowserProfileService, BrowserProfileService,
FileBrowserProfileStore, FileBrowserProfileStore,
@@ -90,13 +105,8 @@ const skillStateSchema = z
}) })
.strict() .strict()
const encryptedSecretSchema = z const encryptedSecretSchema =
.object({ encryptedSettingsCredentialSchema.optional()
formatVersion: z.literal(1),
scheme: z.literal('electron-safe-storage'),
ciphertextBase64: z.string()
})
.optional()
const storedMcpCommonShape = { const storedMcpCommonShape = {
id: mcpServerIdSchema, id: mcpServerIdSchema,
@@ -199,11 +209,7 @@ const secretPayloadSchema = z
}) })
.strict() .strict()
export type CapabilityCipher = { export type CapabilityCipher = SettingsCredentialCipher
isAvailable: () => boolean
encrypt: (value: string) => Buffer
decrypt: (value: Buffer) => string
}
export type ResolvedMcpServer = McpServerSummary & { export type ResolvedMcpServer = McpServerSummary & {
secret?: string secret?: string
@@ -226,6 +232,7 @@ export type CapabilityServiceOptions = Readonly<{
browserProfiles?: BrowserProfileService browserProfiles?: BrowserProfileService
diagnostics?: CapabilityDiagnostics diagnostics?: CapabilityDiagnostics
availableComputerCapabilityImplementations?: readonly ComputerCapabilityImplementationKind[] availableComputerCapabilityImplementations?: readonly ComputerCapabilityImplementationKind[]
settingsFileOperations?: Partial<SettingsFileOperations>
}> }>
function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabilities'] { function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabilities'] {
@@ -241,12 +248,14 @@ function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabili
} }
} }
function emptyStoredCapabilities(): StoredCapabilities { function emptyStoredCapabilities(
webSearchEnabled = true
): StoredCapabilities {
return { return {
version: 4, version: 4,
skills: {}, skills: {},
mcpServers: [], mcpServers: [],
webSearch: { enabled: true }, webSearch: { enabled: webSearchEnabled },
computerCapabilities: defaultComputerCapabilityStates() computerCapabilities: defaultComputerCapabilityStates()
} }
} }
@@ -303,12 +312,7 @@ async function listSkills(
try { try {
entries = await readdir(root, { withFileTypes: true }) entries = await readdir(root, { withFileTypes: true })
} catch (error) { } catch (error) {
if ( if (isMissingFileError(error)) {
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return [] return []
} }
throw error throw error
@@ -550,12 +554,14 @@ async function extractSkillZip(
export class CapabilityService { export class CapabilityService {
private state?: StoredCapabilities private state?: StoredCapabilities
private loadPromise?: Promise<StoredCapabilities> private loadPromise?: Promise<StoredCapabilities>
private warnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve() private updateQueue: Promise<void> = Promise.resolve()
private readonly platform: NodeJS.Platform private readonly platform: NodeJS.Platform
private readonly architecture: string private readonly architecture: string
private readonly electronTarget: boolean private readonly electronTarget: boolean
private readonly browserProfiles: BrowserProfileService private readonly browserProfiles: BrowserProfileService
private readonly diagnostics: CapabilityDiagnostics private readonly diagnostics: CapabilityDiagnostics
private readonly settingsFileOperations?: Partial<SettingsFileOperations>
private readonly availableComputerCapabilityImplementations: ReadonlySet<ComputerCapabilityImplementationKind> private readonly availableComputerCapabilityImplementations: ReadonlySet<ComputerCapabilityImplementationKind>
constructor( constructor(
@@ -574,6 +580,7 @@ export class CapabilityService {
'managed-browser-driver' 'managed-browser-driver'
] ]
) )
this.settingsFileOperations = options.settingsFileOperations
this.browserProfiles = this.browserProfiles =
options.browserProfiles ?? options.browserProfiles ??
new BrowserProfileService( new BrowserProfileService(
@@ -635,6 +642,9 @@ export class CapabilityService {
let shouldPersist = false let shouldPersist = false
try { try {
const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown
assertSupportedSettingsVersion(raw, 4, (version) =>
`当前 GoodBuddy 不支持能力设置版本 ${version},请升级应用后重试`
)
const version = z const version = z
.object({ .object({
version: z.union([ version: z.union([
@@ -676,19 +686,21 @@ export class CapabilityService {
loaded = storedCapabilitiesSchema.parse(raw) loaded = storedCapabilitiesSchema.parse(raw)
} }
} catch (error) { } catch (error) {
if ( if (error instanceof UnsupportedSettingsVersionError) {
error && throw error
typeof error === 'object' && }
'code' in error && if (isMissingFileError(error)) {
error.code === 'ENOENT'
) {
loaded = emptyStoredCapabilities() loaded = emptyStoredCapabilities()
} else { } else {
await rename( await isolateCorruptSettingsFile(
this.filePath, this.filePath,
`${this.filePath}.corrupt-${Date.now()}` '能力设置已损坏且无法隔离',
).catch(() => undefined) Date.now,
loaded = emptyStoredCapabilities() this.settingsFileOperations
)
this.warnings = [{ code: 'capability-settings-recovered' }]
loaded = emptyStoredCapabilities(false)
shouldPersist = true
} }
} }
const migrateMcpAssignments = loaded.mcpServers.some((server) => const migrateMcpAssignments = loaded.mcpServers.some((server) =>
@@ -741,17 +753,27 @@ export class CapabilityService {
private async persist(state: StoredCapabilities): Promise<void> { private async persist(state: StoredCapabilities): Promise<void> {
const validated = storedCapabilitiesSchema.parse(state) const validated = storedCapabilitiesSchema.parse(state)
await mkdir(dirname(this.filePath), { recursive: true }) await writeJsonFileAtomically(
const temporaryPath = `${this.filePath}.${process.pid}.tmp` this.filePath,
await writeFile( validated,
temporaryPath, this.settingsFileOperations
`${JSON.stringify(validated, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 }
) )
await rename(temporaryPath, this.filePath)
this.state = validated this.state = validated
} }
private clearRecoveryWarnings(): void {
this.warnings = this.warnings.filter(
(warning) => warning.code !== 'capability-settings-recovered'
)
}
private async persistUserChange(
state: StoredCapabilities
): Promise<void> {
await this.persist(state)
this.clearRecoveryWarnings()
}
private async getSkillCatalog(): Promise< private async getSkillCatalog(): Promise<
Array<Omit<SkillSummary, 'enabled' | 'assignments'>> Array<Omit<SkillSummary, 'enabled' | 'assignments'>>
> { > {
@@ -823,7 +845,10 @@ export class CapabilityService {
riskSummary: capability.riskSummary riskSummary: capability.riskSummary
}) })
), ),
browserProfiles: this.toBrowserProfilesSummary(browserProfileState) browserProfiles: this.toBrowserProfilesSummary(browserProfileState),
...(this.warnings.length > 0
? { warnings: [...this.warnings] }
: {})
} }
} }
@@ -835,7 +860,7 @@ export class CapabilityService {
setWebSearchEnabled(enabled: boolean): Promise<CapabilitySnapshot> { setWebSearchEnabled(enabled: boolean): Promise<CapabilitySnapshot> {
return this.queue(async () => { return this.queue(async () => {
const state = await this.load() const state = await this.load()
await this.persist({ await this.persistUserChange({
...state, ...state,
webSearch: { enabled } webSearch: { enabled }
}) })
@@ -898,7 +923,7 @@ export class CapabilityService {
} }
} }
const state = await this.load() const state = await this.load()
await this.persist({ await this.persistUserChange({
...state, ...state,
computerCapabilities: { computerCapabilities: {
...state.computerCapabilities, ...state.computerCapabilities,
@@ -965,7 +990,7 @@ export class CapabilityService {
} }
} }
try { try {
await this.persist(nextState) await this.persistUserChange(nextState)
} catch (error) { } catch (error) {
if (profileId) { if (profileId) {
try { try {
@@ -992,7 +1017,7 @@ export class CapabilityService {
previousProfileId, previousProfileId,
reference reference
) )
await this.persist(state) await this.persistUserChange(state)
if (profileId) { if (profileId) {
await this.browserProfiles.removeReference( await this.browserProfiles.removeReference(
profileId, profileId,
@@ -1064,6 +1089,7 @@ export class CapabilityService {
await this.browserProfiles.createProfile( await this.browserProfiles.createProfile(
browserProfileNameSchema.parse(name) browserProfileNameSchema.parse(name)
) )
this.clearRecoveryWarnings()
return this.getSnapshot() return this.getSnapshot()
}) })
} }
@@ -1077,6 +1103,7 @@ export class CapabilityService {
browserProfileIdSchema.parse(profileId), browserProfileIdSchema.parse(profileId),
browserProfileNameSchema.parse(name) browserProfileNameSchema.parse(name)
) )
this.clearRecoveryWarnings()
return this.getSnapshot() return this.getSnapshot()
}) })
} }
@@ -1086,6 +1113,7 @@ export class CapabilityService {
await this.browserProfiles.setDefaultProfile( await this.browserProfiles.setDefaultProfile(
browserProfileIdSchema.parse(profileId) browserProfileIdSchema.parse(profileId)
) )
this.clearRecoveryWarnings()
return this.getSnapshot() return this.getSnapshot()
}) })
} }
@@ -1095,6 +1123,7 @@ export class CapabilityService {
await this.browserProfiles.deleteProfile( await this.browserProfiles.deleteProfile(
browserProfileIdSchema.parse(profileId) browserProfileIdSchema.parse(profileId)
) )
this.clearRecoveryWarnings()
return this.getSnapshot() return this.getSnapshot()
}) })
} }
@@ -1125,7 +1154,7 @@ export class CapabilityService {
await readSkill(temporaryPath, 'imported', skill.id) await readSkill(temporaryPath, 'imported', skill.id)
await rename(temporaryPath, targetPath) await rename(temporaryPath, targetPath)
const state = await this.load() const state = await this.load()
await this.persist({ await this.persistUserChange({
...state, ...state,
skills: { skills: {
...state.skills, ...state.skills,
@@ -1223,7 +1252,7 @@ export class CapabilityService {
const state = await this.load() const state = await this.load()
const skills = { ...state.skills } const skills = { ...state.skills }
delete skills[id] delete skills[id]
await this.persist({ ...state, skills }) await this.persistUserChange({ ...state, skills })
return this.getSnapshot() return this.getSnapshot()
}) })
} }
@@ -1255,7 +1284,7 @@ export class CapabilityService {
throw new Error('Skill 不存在') throw new Error('Skill 不存在')
} }
const state = await this.load() const state = await this.load()
await this.persist({ await this.persistUserChange({
...state, ...state,
skills: { skills: {
...state.skills, ...state.skills,
@@ -1311,19 +1340,11 @@ export class CapabilityService {
if (!this.cipher.isAvailable()) { if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,MCP 访问令牌未保存') throw new Error('系统安全存储不可用,MCP 访问令牌未保存')
} }
credential = { credential = encryptSettingsCredential(this.cipher, {
formatVersion: 1 as const, version: 1,
scheme: 'electron-safe-storage' as const, serverId: id,
ciphertextBase64: this.cipher secret: value.secret.value
.encrypt( })
JSON.stringify({
version: 1,
serverId: id,
secret: value.secret.value
})
)
.toString('base64')
}
} }
const stored: StoredMcpServer = const stored: StoredMcpServer =
value.transport === 'stdio' value.transport === 'stdio'
@@ -1354,7 +1375,7 @@ export class CapabilityService {
server.id === id ? stored : server server.id === id ? stored : server
) )
: [...state.mcpServers, stored] : [...state.mcpServers, stored]
await this.persist({ ...state, mcpServers: nextServers }) await this.persistUserChange({ ...state, mcpServers: nextServers })
return this.getSnapshot() return this.getSnapshot()
}) })
} }
@@ -1366,7 +1387,7 @@ export class CapabilityService {
if (!state.mcpServers.some((server) => server.id === id)) { if (!state.mcpServers.some((server) => server.id === id)) {
throw new Error('MCP Server 不存在') throw new Error('MCP Server 不存在')
} }
await this.persist({ await this.persistUserChange({
...state, ...state,
mcpServers: state.mcpServers.filter((server) => server.id !== id) mcpServers: state.mcpServers.filter((server) => server.id !== id)
}) })
@@ -1388,11 +1409,7 @@ export class CapabilityService {
} }
try { try {
const payload = secretPayloadSchema.parse( const payload = secretPayloadSchema.parse(
JSON.parse( decryptSettingsCredential(this.cipher, server.credential)
this.cipher.decrypt(
Buffer.from(server.credential.ciphertextBase64, 'base64')
)
)
) )
if (payload.serverId === id) { if (payload.serverId === id) {
secret = payload.secret secret = payload.secret
@@ -192,10 +192,14 @@ describe('ChannelSettingsStore', () => {
) )
const initial = await store.snapshot() const initial = await store.snapshot()
expect(initial.warning).toContain('已损坏') expect(initial.warnings).toContainEqual({
code: 'channel-settings-recovered'
})
expect( expect(
await readdir(join(filePath, '..')) (await readdir(join(filePath, '..'))).some((name) =>
).toContain('channel-settings.json.corrupt-1234') name.startsWith('channel-settings.json.corrupt-1234-')
)
).toBe(true)
await store.apply({ await store.apply({
dingtalk: { dingtalk: {
@@ -215,6 +219,9 @@ describe('ChannelSettingsStore', () => {
expect((await readdir(join(filePath, '..'))).some( expect((await readdir(join(filePath, '..'))).some(
(name) => name.endsWith('.tmp') (name) => name.endsWith('.tmp')
)).toBe(false) )).toBe(false)
await expect(store.snapshot()).resolves.not.toHaveProperty(
'warnings'
)
}) })
it('encrypts Weixin binding credentials and removes them on disconnect', async () => { it('encrypts Weixin binding credentials and removes them on disconnect', async () => {
@@ -251,4 +258,293 @@ describe('ChannelSettingsStore', () => {
}) })
expect((await store.resolve('weixin')).token).toBeUndefined() expect((await store.resolve('weixin')).token).toBeUndefined()
}) })
it('defers version 2 Weixin migration until safe storage recovers', async () => {
const filePath = await settingsPath()
let available = false
const cipher = createCipher()
const dynamicCipher: ChannelCredentialCipher = {
...cipher,
isAvailable: () => available
}
const legacyCredential = {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: cipher
.encrypt(
JSON.stringify({
version: 1,
channel: 'weixin',
secret: 'legacy-weixin-token'
})
)
.toString('base64')
}
const legacySettings = JSON.stringify({
version: 2,
weixin: {
enabled: true,
credential: legacyCredential,
accountId: 'account-legacy',
userId: 'user-legacy',
baseUrl: 'https://ilinkai.weixin.qq.com'
},
wecom: {
enabled: false,
botId: '',
allowedSenderIds: [],
allowGroupMessages: false
},
dingtalk: {
enabled: false,
clientId: '',
allowedSenderIds: [],
allowGroupMessages: false
}
})
await writeFile(filePath, legacySettings, 'utf8')
const store = new ChannelSettingsStore(filePath, dynamicCipher, {})
await expect(store.snapshot()).rejects.toThrow(
'安全存储暂不可用'
)
expect(await readFile(filePath, 'utf8')).toBe(legacySettings)
expect(
(await readdir(join(filePath, '..'))).some((name) =>
name.startsWith('channel-settings.json.corrupt-')
)
).toBe(false)
available = true
await expect(store.snapshot()).resolves.toMatchObject({
weixin: {
enabled: true,
bindingConfigured: true,
source: 'encrypted'
}
})
await expect(store.resolve('weixin')).resolves.toMatchObject({
accountId: 'account-legacy',
userId: 'user-legacy',
token: 'legacy-weixin-token'
})
expect(
JSON.parse(await readFile(filePath, 'utf8'))
).toMatchObject({
version: 3,
weixin: {
enabled: true,
credential: expect.any(Object)
}
})
})
it('preserves settings created by a newer unsupported version', async () => {
const filePath = await settingsPath()
const futureSettings = JSON.stringify({
version: 99,
futureField: 'keep-me'
})
await writeFile(filePath, futureSettings, 'utf8')
const store = new ChannelSettingsStore(
filePath,
createCipher(),
{}
)
await expect(store.snapshot()).rejects.toThrow(
'不支持通道设置版本 99'
)
expect(await readFile(filePath, 'utf8')).toBe(futureSettings)
expect(
(await readdir(join(filePath, '..'))).some((name) =>
name.startsWith('channel-settings.json.corrupt-')
)
).toBe(false)
})
it('does not start Weixin with a temporarily unavailable credential', async () => {
const filePath = await settingsPath()
const availableStore = new ChannelSettingsStore(
filePath,
createCipher(),
{}
)
await availableStore.saveWeixinBinding({
accountId: 'account-123',
userId: 'user-123',
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'private-token'
})
const unavailableStore = new ChannelSettingsStore(
filePath,
createCipher(false),
{}
)
await expect(unavailableStore.resolve('weixin')).resolves.toMatchObject({
enabled: false,
source: 'none'
})
await expect(unavailableStore.snapshot()).resolves.toMatchObject({
weixin: {
enabled: false,
bindingConfigured: false
},
warnings: expect.arrayContaining([
{ code: 'channel-weixin-secure-storage-unavailable' }
])
})
expect(
JSON.parse(await readFile(filePath, 'utf8'))
).toMatchObject({
version: 3,
weixin: {
enabled: true,
credential: expect.any(Object)
}
})
await unavailableStore.apply({
wecom: {
enabled: false,
botId: 'bot-id',
secret: { action: 'keep' },
allowedSenderIds: [],
allowGroupMessages: false
}
})
expect(
JSON.parse(await readFile(filePath, 'utf8'))
).toMatchObject({
weixin: {
enabled: true,
credential: expect.any(Object)
}
})
})
it('distinguishes unreadable channel credentials from missing secrets', async () => {
const filePath = await settingsPath()
const availableStore = new ChannelSettingsStore(
filePath,
createCipher(),
{}
)
await availableStore.apply({
wecom: {
enabled: false,
botId: 'bot-id',
secret: { action: 'replace', value: 'private-secret' },
allowedSenderIds: ['sender-a'],
allowGroupMessages: false
}
})
const unreadableStore = new ChannelSettingsStore(
filePath,
{
...createCipher(),
decrypt: () => {
throw new Error('cannot decrypt')
}
},
{}
)
await expect(unreadableStore.snapshot()).resolves.toMatchObject({
wecom: {
secretConfigured: false,
source: 'unreadable'
},
warnings: expect.arrayContaining([
{ code: 'channel-wecom-credential-unreadable' }
])
})
await unreadableStore.apply({
wecom: {
enabled: false,
botId: 'replacement-bot',
secret: { action: 'clear' },
allowedSenderIds: ['sender-a'],
allowGroupMessages: false
}
})
await expect(unreadableStore.snapshot()).resolves.toMatchObject({
wecom: {
source: 'none'
}
})
expect(
(await unreadableStore.snapshot()).warnings ?? []
).not.toContainEqual({
code: 'channel-wecom-credential-unreadable'
})
})
}) })
it.each(['wecom', 'dingtalk'] as const)(
'clears an unreadable %s credential warning after decryption recovers',
async (channel) => {
const filePath = await settingsPath()
const availableCipher = createCipher()
const availableStore = new ChannelSettingsStore(
filePath,
availableCipher,
{}
)
await availableStore.apply(
channel === 'wecom'
? {
wecom: {
enabled: false,
botId: 'bot-id',
secret: { action: 'replace', value: 'private-secret' },
allowedSenderIds: ['sender-a'],
allowGroupMessages: false
}
}
: {
dingtalk: {
enabled: false,
clientId: 'client-id',
secret: { action: 'replace', value: 'private-secret' },
allowedSenderIds: ['sender-a'],
allowGroupMessages: false
}
}
)
let decryptAvailable = false
const recoveringStore = new ChannelSettingsStore(
filePath,
{
...availableCipher,
decrypt: (value) => {
if (!decryptAvailable) {
throw new Error('secure storage is temporarily unavailable')
}
return availableCipher.decrypt(value)
}
},
{}
)
const warningCode =
channel === 'wecom'
? 'channel-wecom-credential-unreadable'
: 'channel-dingtalk-credential-unreadable'
await expect(recoveringStore.snapshot()).resolves.toMatchObject({
[channel]: { source: 'unreadable' },
warnings: expect.arrayContaining([{ code: warningCode }])
})
decryptAvailable = true
await expect(recoveringStore.resolve(channel)).resolves.toMatchObject({
source: 'encrypted',
secret: 'private-secret'
})
expect((await recoveringStore.snapshot()).warnings ?? []).not.toContainEqual(
{ code: warningCode }
)
}
)
+287 -167
View File
@@ -1,12 +1,4 @@
import { randomUUID } from 'node:crypto' import { readFile } from 'node:fs/promises'
import {
mkdir,
readFile,
rename,
rm,
writeFile
} from 'node:fs/promises'
import { dirname } from 'node:path'
import { z } from 'zod' import { z } from 'zod'
import { import {
CHANNEL_SETTINGS_LIMITS, CHANNEL_SETTINGS_LIMITS,
@@ -21,17 +13,28 @@ import {
type WeComChannelSettingsInput type WeComChannelSettingsInput
} from '../../shared/channel-settings-contracts' } from '../../shared/channel-settings-contracts'
import { weixinAccountDisplay } from '../../shared/weixin-channel-contracts' import { weixinAccountDisplay } from '../../shared/weixin-channel-contracts'
import {
settingsWarningsEqual,
type SettingsWarning
} from '../../shared/settings-warning-contracts'
import {
assertSupportedSettingsVersion,
isolateCorruptSettingsFile,
isMissingFileError,
UnsupportedSettingsVersionError,
writeJsonFileAtomically
} from '../settings-file-utils'
import {
decryptSettingsCredential,
encryptedSettingsCredentialSchema,
encryptSettingsCredential,
type SettingsCredentialCipher
} from '../settings-credential-cipher'
export interface ChannelCredentialCipher { export type ChannelCredentialCipher = SettingsCredentialCipher
isAvailable(): boolean
encrypt(value: string): Buffer
decrypt(value: Buffer): string
}
const encryptedCredentialSchema = z const encryptedCredentialSchema = encryptedSettingsCredentialSchema
.object({ .extend({
formatVersion: z.literal(1),
scheme: z.literal('electron-safe-storage'),
ciphertextBase64: z ciphertextBase64: z
.string() .string()
.min(1) .min(1)
@@ -112,6 +115,8 @@ type StoredEncryptedCredential = z.infer<
typeof encryptedCredentialSchema typeof encryptedCredentialSchema
> >
class DeferredWeixinMigrationError extends Error {}
const credentialPayloadSchema = z const credentialPayloadSchema = z
.object({ .object({
version: z.literal(1), version: z.literal(1),
@@ -161,7 +166,7 @@ type EnvironmentChannel = {
secret?: string secret?: string
allowedSenderIds: readonly string[] allowedSenderIds: readonly string[]
allowGroupMessages: boolean allowGroupMessages: boolean
error?: string warning?: SettingsWarning
} }
export type ResolvedChannelSettings = export type ResolvedChannelSettings =
@@ -184,7 +189,7 @@ export type ResolvedChannelSettings =
secret?: string secret?: string
allowedSenderIds: readonly string[] allowedSenderIds: readonly string[]
allowGroupMessages: boolean allowGroupMessages: boolean
source: 'none' | 'encrypted' | 'environment' source: 'none' | 'encrypted' | 'environment' | 'unreadable'
readOnly: boolean readOnly: boolean
} }
| { | {
@@ -194,7 +199,7 @@ export type ResolvedChannelSettings =
secret?: string secret?: string
allowedSenderIds: readonly string[] allowedSenderIds: readonly string[]
allowGroupMessages: boolean allowGroupMessages: boolean
source: 'none' | 'encrypted' | 'environment' source: 'none' | 'encrypted' | 'environment' | 'unreadable'
readOnly: boolean readOnly: boolean
} }
@@ -221,15 +226,6 @@ const defaultStatus = (enabled: boolean): ChannelRuntimeStatus => ({
state: enabled ? 'stopped' : 'disabled' state: enabled ? 'stopped' : 'disabled'
}) })
function isMissingFile(error: unknown): boolean {
return (
error !== null &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
}
function boundedEnvironmentValue( function boundedEnvironmentValue(
environment: NodeJS.ProcessEnv, environment: NodeJS.ProcessEnv,
name: string, name: string,
@@ -319,15 +315,27 @@ export type WeixinBinding = z.infer<typeof weixinBindingSchema>
export class ChannelSettingsStore { export class ChannelSettingsStore {
private settings?: StoredSettings private settings?: StoredSettings
private warning?: string private settingsLoad?: Promise<StoredSettings>
private temporarilyDisabledWeixin = false
private warnings: SettingsWarning[] = []
private runtimeRepairWarning?: SettingsWarning
private updateQueue: Promise<void> = Promise.resolve() private updateQueue: Promise<void> = Promise.resolve()
private readonly environmentChannels: Record<
CredentialChannel,
EnvironmentChannel
>
constructor( constructor(
private readonly filePath: string, private readonly filePath: string,
private readonly cipher: ChannelCredentialCipher, private readonly cipher: ChannelCredentialCipher,
private readonly environment: NodeJS.ProcessEnv = process.env, private readonly environment: NodeJS.ProcessEnv = process.env,
private readonly now: () => number = Date.now private readonly now: () => number = Date.now
) {} ) {
this.environmentChannels = {
wecom: this.readEnvironmentChannel('wecom'),
dingtalk: this.readEnvironmentChannel('dingtalk')
}
}
async snapshot( async snapshot(
statuses: Partial<Record<ManagedChannel, ChannelRuntimeStatus>> = {} statuses: Partial<Record<ManagedChannel, ChannelRuntimeStatus>> = {}
@@ -339,9 +347,17 @@ export class ChannelSettingsStore {
]) ])
const weComEnvironment = this.environmentChannel('wecom') const weComEnvironment = this.environmentChannel('wecom')
const dingTalkEnvironment = this.environmentChannel('dingtalk') const dingTalkEnvironment = this.environmentChannel('dingtalk')
const environmentWarning = const warnings = [
weComEnvironment.error ?? dingTalkEnvironment.error ...this.warnings,
const warning = this.warning ?? environmentWarning ...(this.runtimeRepairWarning ? [this.runtimeRepairWarning] : []),
...(weComEnvironment.warning ? [weComEnvironment.warning] : []),
...(dingTalkEnvironment.warning ? [dingTalkEnvironment.warning] : [])
].filter(
(warning, index, values) =>
values.findIndex(
(candidate) => settingsWarningsEqual(candidate, warning)
) === index
)
return { return {
weixin: { weixin: {
enabled: weixin.enabled, enabled: weixin.enabled,
@@ -360,12 +376,9 @@ export class ChannelSettingsStore {
allowGroupMessages: wecom.allowGroupMessages, allowGroupMessages: wecom.allowGroupMessages,
status: status:
statuses.wecom ?? statuses.wecom ??
(weComEnvironment.error === undefined (weComEnvironment.warning === undefined
? defaultStatus(wecom.enabled) ? defaultStatus(wecom.enabled)
: { : { state: 'error' })
state: 'error',
lastError: weComEnvironment.error
})
}, },
dingtalk: { dingtalk: {
enabled: dingtalk.enabled, enabled: dingtalk.enabled,
@@ -377,17 +390,24 @@ export class ChannelSettingsStore {
allowGroupMessages: dingtalk.allowGroupMessages, allowGroupMessages: dingtalk.allowGroupMessages,
status: status:
statuses.dingtalk ?? statuses.dingtalk ??
(dingTalkEnvironment.error === undefined (dingTalkEnvironment.warning === undefined
? defaultStatus(dingtalk.enabled) ? defaultStatus(dingtalk.enabled)
: { : { state: 'error' })
state: 'error',
lastError: dingTalkEnvironment.error
})
}, },
...(warning === undefined ? {} : { warning }) ...(warnings.length > 0 ? { warnings } : {})
} }
} }
reportRuntimeSelectionRepairs(count: number): void {
this.runtimeRepairWarning =
count > 0
? {
code: 'channel-runtime-selections-repaired',
count
}
: undefined
}
getSnapshot( getSnapshot(
statuses?: Partial<Record<ManagedChannel, ChannelRuntimeStatus>> statuses?: Partial<Record<ManagedChannel, ChannelRuntimeStatus>>
): Promise<ChannelSettingsSnapshot> { ): Promise<ChannelSettingsSnapshot> {
@@ -409,9 +429,16 @@ export class ChannelSettingsStore {
const settings = await this.load() const settings = await this.load()
const stored = settings.weixin const stored = settings.weixin
const binding = this.decryptWeixinBinding(stored) const binding = this.decryptWeixinBinding(stored)
if (this.temporarilyDisabledWeixin && binding) {
this.temporarilyDisabledWeixin = false
this.removeWarnings([
'channel-weixin-credential-unreadable',
'channel-weixin-secure-storage-unavailable'
])
}
return { return {
channel, channel,
enabled: stored.enabled, enabled: stored.enabled && !this.temporarilyDisabledWeixin,
accountId: binding?.accountId ?? '', accountId: binding?.accountId ?? '',
userId: binding?.userId ?? '', userId: binding?.userId ?? '',
baseUrl: binding?.baseUrl ?? '', baseUrl: binding?.baseUrl ?? '',
@@ -448,12 +475,18 @@ export class ChannelSettingsStore {
const settings = await this.load() const settings = await this.load()
const stored = settings[channel] const stored = settings[channel]
const secret = this.decryptCredential(channel, stored) const secret = this.decryptCredential(channel, stored)
const credentialUnreadable =
stored.credential !== undefined && secret === undefined
const common = { const common = {
enabled: stored.enabled, enabled: stored.enabled,
...(secret === undefined ? {} : { secret }), ...(secret === undefined ? {} : { secret }),
allowedSenderIds: [...stored.allowedSenderIds], allowedSenderIds: [...stored.allowedSenderIds],
allowGroupMessages: stored.allowGroupMessages, allowGroupMessages: stored.allowGroupMessages,
source: secret === undefined ? ('none' as const) : ('encrypted' as const), source: credentialUnreadable
? ('unreadable' as const)
: secret === undefined
? ('none' as const)
: ('encrypted' as const),
readOnly: false readOnly: false
} }
return channel === 'wecom' return channel === 'wecom'
@@ -484,7 +517,12 @@ export class ChannelSettingsStore {
} }
await this.persist(current) await this.persist(current)
this.settings = current this.settings = current
this.warning = undefined this.temporarilyDisabledWeixin = false
this.removeWarnings([
'channel-weixin-credential-unreadable',
'channel-weixin-secure-storage-unavailable',
'channel-weixin-legacy-binding-invalid'
])
snapshot = await this.snapshot() snapshot = await this.snapshot()
} }
const operation = this.updateQueue.then(update, update) const operation = this.updateQueue.then(update, update)
@@ -504,7 +542,12 @@ export class ChannelSettingsStore {
} }
await this.persist(current) await this.persist(current)
this.settings = current this.settings = current
this.warning = undefined this.temporarilyDisabledWeixin = false
this.removeWarnings([
'channel-weixin-credential-unreadable',
'channel-weixin-secure-storage-unavailable',
'channel-weixin-legacy-binding-invalid'
])
snapshot = await this.snapshot() snapshot = await this.snapshot()
} }
const operation = this.updateQueue.then(update, update) const operation = this.updateQueue.then(update, update)
@@ -557,12 +600,30 @@ export class ChannelSettingsStore {
) )
} }
this.validateEnabledWeixin(current.weixin) if (!this.temporarilyDisabledWeixin || input.weixin !== undefined) {
this.validateEnabledWeixin(current.weixin)
}
this.validateEnabledCredentialChannel('wecom', current.wecom) this.validateEnabledCredentialChannel('wecom', current.wecom)
this.validateEnabledCredentialChannel('dingtalk', current.dingtalk) this.validateEnabledCredentialChannel('dingtalk', current.dingtalk)
await this.persist(current) await this.persist(current)
this.settings = current this.settings = current
this.warning = undefined if (!this.temporarilyDisabledWeixin) {
this.removeWarnings([
'channel-weixin-credential-unreadable',
'channel-weixin-secure-storage-unavailable',
'channel-weixin-legacy-binding-invalid'
])
}
const resolvedWarningCodes: SettingsWarning['code'][] = [
'channel-settings-recovered'
]
if (input.wecom !== undefined) {
resolvedWarningCodes.push('channel-wecom-credential-unreadable')
}
if (input.dingtalk !== undefined) {
resolvedWarningCodes.push('channel-dingtalk-credential-unreadable')
}
this.removeWarnings(resolvedWarningCodes)
return this.snapshot() return this.snapshot()
} }
@@ -652,34 +713,47 @@ export class ChannelSettingsStore {
if (!this.cipher.isAvailable()) { if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,无法保存通道 Secret') throw new Error('系统安全存储不可用,无法保存通道 Secret')
} }
const encrypted = this.cipher.encrypt( return encryptSettingsCredential(this.cipher, {
JSON.stringify({ version: 1, channel, secret }) version: 1,
) channel,
return { secret
formatVersion: 1, })
scheme: 'electron-safe-storage',
ciphertextBase64: encrypted.toString('base64')
}
} }
private decryptCredential( private decryptCredential(
channel: CredentialChannel, channel: CredentialChannel,
stored: StoredCredentialChannel stored: StoredCredentialChannel
): string | undefined { ): string | undefined {
if (stored.credential === undefined || !this.cipher.isAvailable()) { if (stored.credential === undefined) {
return undefined return undefined
} }
const warn = (): undefined => {
this.addWarning({
code:
channel === 'wecom'
? 'channel-wecom-credential-unreadable'
: 'channel-dingtalk-credential-unreadable'
})
return undefined
}
if (!this.cipher.isAvailable()) {
return warn()
}
try { try {
const payload = credentialPayloadSchema.parse( const payload = credentialPayloadSchema.parse(
JSON.parse( decryptSettingsCredential(this.cipher, stored.credential)
this.cipher.decrypt(
Buffer.from(stored.credential.ciphertextBase64, 'base64')
)
)
) )
return payload.channel === channel ? payload.secret : undefined if (payload.channel !== channel) {
return warn()
}
this.removeWarnings([
channel === 'wecom'
? 'channel-wecom-credential-unreadable'
: 'channel-dingtalk-credential-unreadable'
])
return payload.secret
} catch { } catch {
return undefined return warn()
} }
} }
@@ -689,21 +763,14 @@ export class ChannelSettingsStore {
if (!this.cipher.isAvailable()) { if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,无法保存微信绑定') throw new Error('系统安全存储不可用,无法保存微信绑定')
} }
const encrypted = this.cipher.encrypt( return encryptSettingsCredential(this.cipher, {
JSON.stringify({ version: 2,
version: 2, channel: 'weixin',
channel: 'weixin', accountId: binding.accountId,
accountId: binding.accountId, userId: binding.userId,
userId: binding.userId, baseUrl: binding.baseUrl,
baseUrl: binding.baseUrl, token: binding.token
token: binding.token })
})
)
return {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: encrypted.toString('base64')
}
} }
private decryptWeixinBinding( private decryptWeixinBinding(
@@ -714,81 +781,38 @@ export class ChannelSettingsStore {
} }
try { try {
return weixinCredentialPayloadSchema.parse( return weixinCredentialPayloadSchema.parse(
JSON.parse( decryptSettingsCredential(this.cipher, stored.credential)
this.cipher.decrypt(
Buffer.from(stored.credential.ciphertextBase64, 'base64')
)
)
) )
} catch { } catch {
return undefined return undefined
} }
} }
private async load(): Promise<StoredSettings> { private load(): Promise<StoredSettings> {
if (this.settings !== undefined) { if (this.settings !== undefined) {
return this.settings return Promise.resolve(this.settings)
} }
if (!this.settingsLoad) {
this.settingsLoad = this.readSettings().finally(() => {
this.settingsLoad = undefined
})
}
return this.settingsLoad
}
private async readSettings(): Promise<StoredSettings> {
try { try {
const raw: unknown = JSON.parse(await readFile(this.filePath, 'utf8')) const raw: unknown = JSON.parse(await readFile(this.filePath, 'utf8'))
assertSupportedSettingsVersion(raw, 3, (version) =>
`当前 GoodBuddy 不支持通道设置版本 ${version},请升级应用后重试`
)
const current = storedSettingsSchema.safeParse(raw) const current = storedSettingsSchema.safeParse(raw)
if (current.success) { if (current.success) {
this.settings = current.data this.settings = this.normalizeStoredSettings(current.data)
} else { } else {
const versionTwo = versionTwoStoredSettingsSchema.safeParse(raw) const versionTwo = versionTwoStoredSettingsSchema.safeParse(raw)
if (versionTwo.success) { if (versionTwo.success) {
const legacyWeixin = versionTwo.data.weixin this.settings = this.migrateVersionTwo(versionTwo.data)
let token: string | undefined
if (
legacyWeixin.credential &&
this.cipher.isAvailable()
) {
try {
const payload = credentialPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(
legacyWeixin.credential.ciphertextBase64,
'base64'
)
)
)
)
token =
payload.channel === 'weixin'
? payload.secret
: undefined
} catch {
token = undefined
}
}
const binding =
token &&
legacyWeixin.accountId &&
legacyWeixin.userId &&
legacyWeixin.baseUrl
? {
accountId: legacyWeixin.accountId,
userId: legacyWeixin.userId,
baseUrl: legacyWeixin.baseUrl,
token
}
: undefined
this.settings = {
version: 3,
weixin: {
enabled: binding ? legacyWeixin.enabled : false,
...(binding
? { credential: this.encryptWeixinBinding(binding) }
: {})
},
wecom: versionTwo.data.wecom,
dingtalk: versionTwo.data.dingtalk
}
if (legacyWeixin.enabled && !binding) {
this.warning =
'旧版微信绑定无法安全迁移,请重新扫码绑定'
}
} else { } else {
const legacy = legacyStoredSettingsSchema.parse(raw) const legacy = legacyStoredSettingsSchema.parse(raw)
this.settings = { this.settings = {
@@ -803,38 +827,114 @@ export class ChannelSettingsStore {
await this.persist(this.settings) await this.persist(this.settings)
} }
} catch (error) { } catch (error) {
if (!isMissingFile(error)) { if (
this.warning = '通道设置文件已损坏,已隔离原文件并恢复默认设置' error instanceof UnsupportedSettingsVersionError ||
await rename( error instanceof DeferredWeixinMigrationError
) {
throw error
}
if (!isMissingFileError(error)) {
await isolateCorruptSettingsFile(
this.filePath, this.filePath,
`${this.filePath}.corrupt-${this.now()}` '通道设置已损坏且无法隔离',
).catch(() => undefined) this.now
)
this.warnings = [{ code: 'channel-settings-recovered' }]
} }
this.settings = cloneStored(defaultStoredSettings) this.settings = cloneStored(defaultStoredSettings)
} }
return this.settings return this.settings
} }
private async persist(settings: StoredSettings): Promise<void> { private normalizeStoredSettings(settings: StoredSettings): StoredSettings {
await mkdir(dirname(this.filePath), { recursive: true }) if (
const temporaryPath = `${this.filePath}.${process.pid}.${randomUUID()}.tmp` settings.weixin.credential &&
try { this.decryptWeixinBinding(settings.weixin) === undefined
await writeFile( ) {
temporaryPath, this.temporarilyDisabledWeixin = true
`${JSON.stringify(settings, null, 2)}\n`, this.addWarning({
{ code: this.cipher.isAvailable()
encoding: 'utf8', ? 'channel-weixin-credential-unreadable'
mode: 0o600, : 'channel-weixin-secure-storage-unavailable'
flag: 'wx' })
} } else {
this.temporarilyDisabledWeixin = false
}
return settings
}
private migrateVersionTwo(
settings: z.infer<typeof versionTwoStoredSettingsSchema>
): StoredSettings {
const legacyWeixin = settings.weixin
if (legacyWeixin.credential && !this.cipher.isAvailable()) {
throw new DeferredWeixinMigrationError(
'系统安全存储暂不可用,旧版微信绑定尚未迁移;原设置已保留,请恢复安全存储后重试'
) )
await rename(temporaryPath, this.filePath) }
} finally { let token: string | undefined
await rm(temporaryPath, { force: true }) if (legacyWeixin.credential) {
try {
const payload = credentialPayloadSchema.parse(
decryptSettingsCredential(
this.cipher,
legacyWeixin.credential
)
)
token =
payload.channel === 'weixin' ? payload.secret : undefined
} catch {
throw new DeferredWeixinMigrationError(
'旧版微信绑定无法解密,原设置已保留;请恢复原安全存储后重试'
)
}
}
const binding =
token &&
legacyWeixin.accountId &&
legacyWeixin.userId &&
legacyWeixin.baseUrl
? {
accountId: legacyWeixin.accountId,
userId: legacyWeixin.userId,
baseUrl: legacyWeixin.baseUrl,
token
}
: undefined
if (legacyWeixin.credential && !binding) {
throw new DeferredWeixinMigrationError(
'旧版微信绑定信息不完整或无法验证,原设置已保留;请恢复原配置后重试'
)
}
if (legacyWeixin.enabled && !binding) {
this.addWarning({
code: 'channel-weixin-legacy-binding-invalid'
})
}
return {
version: 3,
weixin: {
enabled: binding ? legacyWeixin.enabled : false,
...(binding
? { credential: this.encryptWeixinBinding(binding) }
: {})
},
wecom: settings.wecom,
dingtalk: settings.dingtalk
} }
} }
private async persist(settings: StoredSettings): Promise<void> {
await writeJsonFileAtomically(this.filePath, settings)
}
private environmentChannel(channel: CredentialChannel): EnvironmentChannel { private environmentChannel(channel: CredentialChannel): EnvironmentChannel {
return this.environmentChannels[channel]
}
private readEnvironmentChannel(
channel: CredentialChannel
): EnvironmentChannel {
const prefix = const prefix =
channel === 'wecom' ? 'GOODBUDDY_WECOM' : 'GOODBUDDY_DINGTALK' channel === 'wecom' ? 'GOODBUDDY_WECOM' : 'GOODBUDDY_DINGTALK'
const idName = const idName =
@@ -903,11 +1003,31 @@ export class ChannelSettingsStore {
senders.value.length > 0 senders.value.length > 0
? {} ? {}
: { : {
error: warning: {
channel === 'wecom' code:
? '企业微信环境变量配置无效或不完整' channel === 'wecom'
: '钉钉环境变量配置无效或不完整' ? 'channel-wecom-environment-invalid'
: 'channel-dingtalk-environment-invalid'
}
}) })
} }
} }
private addWarning(warning: SettingsWarning): void {
if (
!this.warnings.some(
(current) => settingsWarningsEqual(current, warning)
)
) {
this.warnings.push(warning)
}
}
private removeWarnings(
codes: readonly SettingsWarning['code'][]
): void {
this.warnings = this.warnings.filter(
(warning) => !codes.includes(warning.code)
)
}
} }
@@ -0,0 +1,171 @@
import { describe, expect, it, vi } from 'vitest'
import { WechatBindingController } from './wechat-binding-controller'
import type { WechatSidecarChild } from './wechat-sidecar-client'
function createDeferred(): {
promise: Promise<void>
resolve: () => void
} {
let resolve!: () => void
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
describe('WechatBindingController', () => {
it('coalesces duplicate credential messages from the same login', async () => {
const saveReleased = createDeferred()
const saveWeixinBinding = vi.fn(async () => {
await saveReleased.promise
return {} as never
})
let messageListener: ((message: unknown) => void) | undefined
const child: WechatSidecarChild = {
postMessage: vi.fn(),
kill: vi.fn(() => true),
on: vi.fn((_event, listener) => {
messageListener = listener
return child
}),
once: vi.fn(() => child)
}
const onChanged = vi.fn(async () => undefined)
const controller = new WechatBindingController(
{ saveWeixinBinding } as never,
() => child,
onChanged,
vi.fn()
)
const credential = {
type: 'credential' as const,
accountId: 'account-1',
userId: 'user-1',
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'binding-token'
}
controller.start()
messageListener?.(credential)
messageListener?.(credential)
await vi.waitFor(() =>
expect(saveWeixinBinding).toHaveBeenCalledOnce()
)
saveReleased.resolve()
await controller.stop()
expect(saveWeixinBinding).toHaveBeenCalledOnce()
expect(onChanged).not.toHaveBeenCalled()
})
it('accepts only the first credential from one login generation', async () => {
const firstSaveStarted = createDeferred()
const firstSaveReleased = createDeferred()
const saveWeixinBinding = vi
.fn()
.mockImplementationOnce(async () => {
firstSaveStarted.resolve()
await firstSaveReleased.promise
return {} as never
})
let messageListener: ((message: unknown) => void) | undefined
const child: WechatSidecarChild = {
postMessage: vi.fn(),
kill: vi.fn(() => true),
on: vi.fn((_event, listener) => {
messageListener = listener
return child
}),
once: vi.fn(() => child)
}
const onChanged = vi.fn(async () => undefined)
const controller = new WechatBindingController(
{ saveWeixinBinding } as never,
() => child,
onChanged,
vi.fn()
)
controller.start()
messageListener?.({
type: 'credential',
accountId: 'account-1',
userId: 'user-1',
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'binding-token-1'
})
messageListener?.({
type: 'credential',
accountId: 'account-2',
userId: 'user-2',
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'binding-token-2'
})
await firstSaveStarted.promise
expect(() => controller.start()).toThrow(
'微信绑定凭据正在保存,请稍后重试'
)
let stopped = false
const stop = controller.stop().then(() => {
stopped = true
})
await Promise.resolve()
expect(stopped).toBe(false)
firstSaveReleased.resolve()
await stop
expect(saveWeixinBinding).toHaveBeenCalledOnce()
expect(onChanged).not.toHaveBeenCalled()
})
it('waits for an in-flight credential save when stopping', async () => {
const saveStarted = createDeferred()
const saveReleased = createDeferred()
const saveWeixinBinding = vi.fn(async () => {
saveStarted.resolve()
await saveReleased.promise
return {} as never
})
let messageListener: ((message: unknown) => void) | undefined
const child: WechatSidecarChild = {
postMessage: vi.fn(),
kill: vi.fn(() => true),
on: vi.fn((_event, listener) => {
messageListener = listener
return child
}),
once: vi.fn(() => child)
}
const onChanged = vi.fn(async () => undefined)
const controller = new WechatBindingController(
{ saveWeixinBinding } as never,
() => child,
onChanged,
vi.fn()
)
controller.start()
messageListener?.({
type: 'credential',
accountId: 'account-1',
userId: 'user-1',
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'binding-token'
})
await saveStarted.promise
let stopped = false
const stop = controller.stop().then(() => {
stopped = true
})
await Promise.resolve()
expect(stopped).toBe(false)
saveReleased.resolve()
await stop
expect(saveWeixinBinding).toHaveBeenCalledOnce()
expect(onChanged).not.toHaveBeenCalled()
})
})
+12 -6
View File
@@ -69,10 +69,11 @@ export class WechatBindingController {
return this.snapshot() return this.snapshot()
} }
stop(): void { async stop(): Promise<void> {
this.generation += 1 this.generation += 1
this.stopClient() this.stopClient()
this.snapshotValue = { status: 'stopped' } this.snapshotValue = { status: 'stopped' }
await this.credentialSave
} }
private handleMessage( private handleMessage(
@@ -83,12 +84,13 @@ export class WechatBindingController {
return return
} }
if (message.type === 'credential') { if (message.type === 'credential') {
if (this.savingCredential) {
return
}
this.savingCredential = true this.savingCredential = true
this.credentialSave = this.credentialSave this.stopClient()
const save = this.credentialSave
.then(async () => { .then(async () => {
if (generation !== this.generation) {
return
}
this.stopClient() this.stopClient()
await this.store.saveWeixinBinding({ await this.store.saveWeixinBinding({
accountId: message.accountId, accountId: message.accountId,
@@ -120,9 +122,13 @@ export class WechatBindingController {
: '微信绑定保存失败' : '微信绑定保存失败'
}) })
}) })
const trackedSave = save
.finally(() => { .finally(() => {
this.savingCredential = false if (this.credentialSave === trackedSave) {
this.savingCredential = false
}
}) })
this.credentialSave = trackedSave
return return
} }
if (message.type === 'qr') { if (message.type === 'qr') {
+4 -1
View File
@@ -170,7 +170,10 @@ export class DocumentParsingService {
conversionAvailable: false, conversionAvailable: false,
localOcr localOcr
}, },
ocrModels ocrModels,
...(this.settingsStore.getWarnings().length > 0
? { warnings: [...this.settingsStore.getWarnings()] }
: {})
}) })
} }
@@ -47,6 +47,7 @@ describe('DocumentParsingSettingsStore', () => {
await expect(store.get()).resolves.toEqual( await expect(store.get()).resolves.toEqual(
defaultDocumentParsingSettings defaultDocumentParsingSettings
) )
expect(store.getWarnings()).toEqual([])
await expect(readdir(directory)).resolves.toEqual([]) await expect(readdir(directory)).resolves.toEqual([])
}) })
@@ -143,10 +144,32 @@ describe('DocumentParsingSettingsStore', () => {
await expect(store.get()).resolves.toEqual( await expect(store.get()).resolves.toEqual(
defaultDocumentParsingSettings defaultDocumentParsingSettings
) )
expect(store.getWarnings()).toEqual([
{ code: 'document-parsing-settings-recovered' }
])
const entries = await readdir(directory) const entries = await readdir(directory)
expect(entries).toHaveLength(1) expect(entries).toHaveLength(1)
expect(entries[0]).toMatch( expect(entries[0]).toMatch(
/^document-parsing-settings\.json\.corrupt-\d+-[a-f0-9]{12}$/u /^document-parsing-settings\.json\.corrupt-\d+-[a-f0-9]{12}$/u
) )
}) })
it('preserves settings created by a newer unsupported version', async () => {
const { directory, filePath, store } = await createStore()
const futureSettings = JSON.stringify({
version: 99,
futureField: 'keep-me'
})
await writeFile(filePath, futureSettings, 'utf8')
await expect(store.get()).rejects.toThrow(
'不支持文档解析设置版本 99'
)
expect(await readFile(filePath, 'utf8')).toBe(futureSettings)
expect(
(await readdir(directory)).some((name) =>
name.startsWith('document-parsing-settings.json.corrupt-')
)
).toBe(false)
})
}) })
+44 -51
View File
@@ -1,18 +1,18 @@
import { randomBytes } from 'node:crypto' import { readFile } from 'node:fs/promises'
import {
mkdir,
readFile,
rename,
rm,
writeFile
} from 'node:fs/promises'
import { dirname } from 'node:path'
import { z } from 'zod' import { z } from 'zod'
import { import {
documentParsingSettingsSchema, documentParsingSettingsSchema,
documentParsingSettingsUpdateSchema, documentParsingSettingsUpdateSchema,
type DocumentParsingSettings type DocumentParsingSettings
} from '../shared/document-parsing-contracts' } from '../shared/document-parsing-contracts'
import type { SettingsWarning } from '../shared/settings-warning-contracts'
import {
assertSupportedSettingsVersion,
isolateCorruptSettingsFile,
isMissingFileError,
UnsupportedSettingsVersionError,
writeJsonFileAtomically
} from './settings-file-utils'
const CURRENT_SETTINGS_VERSION = 3 const CURRENT_SETTINGS_VERSION = 3
@@ -96,40 +96,34 @@ function migrateLegacySettings(
} }
} }
function isMissingFile(error: unknown): boolean {
return (
error !== null &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
}
export class DocumentParsingSettingsStore { export class DocumentParsingSettingsStore {
private settings?: StoredDocumentParsingSettings private settings?: StoredDocumentParsingSettings
private settingsLoad?: Promise<StoredDocumentParsingSettings>
private warnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve() private updateQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {} constructor(private readonly filePath: string) {}
private async isolateCorruptFile(): Promise<void> { private async isolateCorruptFile(): Promise<void> {
const isolatedPath = await isolateCorruptSettingsFile(
`${this.filePath}.corrupt-${Date.now()}-` + this.filePath,
randomBytes(6).toString('hex') '文档解析设置损坏且无法隔离'
try { )
await rename(this.filePath, isolatedPath)
} catch (error) {
if (!isMissingFile(error)) {
throw new Error('文档解析设置损坏且无法隔离', {
cause: error
})
}
}
} }
private async loadStored(): Promise<StoredDocumentParsingSettings> { private loadStored(): Promise<StoredDocumentParsingSettings> {
if (this.settings) { if (this.settings) {
return this.settings return Promise.resolve(this.settings)
} }
if (!this.settingsLoad) {
this.settingsLoad = this.readStored().finally(() => {
this.settingsLoad = undefined
})
}
return this.settingsLoad
}
private async readStored(): Promise<StoredDocumentParsingSettings> {
try { try {
const contents = await readFile(this.filePath, 'utf8') const contents = await readFile(this.filePath, 'utf8')
let parsed: unknown let parsed: unknown
@@ -137,12 +131,19 @@ export class DocumentParsingSettingsStore {
parsed = JSON.parse(contents) as unknown parsed = JSON.parse(contents) as unknown
} catch { } catch {
await this.isolateCorruptFile() await this.isolateCorruptFile()
this.warnings = [{ code: 'document-parsing-settings-recovered' }]
this.settings = { this.settings = {
version: CURRENT_SETTINGS_VERSION, version: CURRENT_SETTINGS_VERSION,
...defaultDocumentParsingSettings ...defaultDocumentParsingSettings
} }
return this.settings return this.settings
} }
assertSupportedSettingsVersion(
parsed,
CURRENT_SETTINGS_VERSION,
(version) =>
`当前 GoodBuddy 不支持文档解析设置版本 ${version},请升级应用后重试`
)
const result = const result =
storedDocumentParsingSettingsSchema.safeParse(parsed) storedDocumentParsingSettingsSchema.safeParse(parsed)
if (!result.success) { if (!result.success) {
@@ -170,6 +171,7 @@ export class DocumentParsingSettingsStore {
return this.settings return this.settings
} }
await this.isolateCorruptFile() await this.isolateCorruptFile()
this.warnings = [{ code: 'document-parsing-settings-recovered' }]
this.settings = { this.settings = {
version: CURRENT_SETTINGS_VERSION, version: CURRENT_SETTINGS_VERSION,
...defaultDocumentParsingSettings ...defaultDocumentParsingSettings
@@ -178,7 +180,10 @@ export class DocumentParsingSettingsStore {
} }
this.settings = result.data this.settings = result.data
} catch (error) { } catch (error) {
if (!isMissingFile(error)) { if (error instanceof UnsupportedSettingsVersionError) {
throw error
}
if (!isMissingFileError(error)) {
throw new Error('无法读取文档解析设置', { cause: error }) throw new Error('无法读取文档解析设置', { cause: error })
} }
this.settings = { this.settings = {
@@ -195,6 +200,10 @@ export class DocumentParsingSettingsStore {
return documentParsingSettingsSchema.parse(settings) return documentParsingSettingsSchema.parse(settings)
} }
getWarnings(): readonly SettingsWarning[] {
return this.warnings
}
update(input: unknown): Promise<DocumentParsingSettings> { update(input: unknown): Promise<DocumentParsingSettings> {
const operation = this.updateQueue.then(async () => { const operation = this.updateQueue.then(async () => {
const updates = documentParsingSettingsUpdateSchema.parse(input) const updates = documentParsingSettingsUpdateSchema.parse(input)
@@ -202,25 +211,9 @@ export class DocumentParsingSettingsStore {
version: CURRENT_SETTINGS_VERSION, version: CURRENT_SETTINGS_VERSION,
...updates ...updates
} }
await mkdir(dirname(this.filePath), { recursive: true }) await writeJsonFileAtomically(this.filePath, next)
const temporaryPath =
`${this.filePath}.${process.pid}.` +
`${randomBytes(6).toString('hex')}.tmp`
try {
await writeFile(
temporaryPath,
`${JSON.stringify(next, null, 2)}\n`,
{
encoding: 'utf8',
mode: 0o600,
flag: 'wx'
}
)
await rename(temporaryPath, this.filePath)
} finally {
await rm(temporaryPath, { force: true })
}
this.settings = next this.settings = next
this.warnings = []
return this.get() return this.get()
}) })
this.updateQueue = operation.then( this.updateQueue = operation.then(
+68 -43
View File
@@ -65,7 +65,10 @@ import { SpeechModelManager } from './speech/speech-model-manager'
import { SpeechTranscriptionService } from './speech/speech-transcription-service' import { SpeechTranscriptionService } from './speech/speech-transcription-service'
import { GlobalTlsPolicy } from './global-tls-policy' import { GlobalTlsPolicy } from './global-tls-policy'
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts' import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
import { waitForCleanup } from './shutdown' import {
runCleanupBeforeDeadline,
settleCleanupPhases
} from './shutdown'
import { DocumentParsingSettingsStore } from './document-parsing-settings-store' import { DocumentParsingSettingsStore } from './document-parsing-settings-store'
import { DocumentOcrModelManager } from './document-ocr-model-manager' import { DocumentOcrModelManager } from './document-ocr-model-manager'
import { DocumentOcrBroker } from './document-ocr-broker' import { DocumentOcrBroker } from './document-ocr-broker'
@@ -104,6 +107,7 @@ let browserService: BrowserService | undefined
let globalTlsPolicy: GlobalTlsPolicy | undefined let globalTlsPolicy: GlobalTlsPolicy | undefined
let documentOcrBroker: DocumentOcrBroker | undefined let documentOcrBroker: DocumentOcrBroker | undefined
let documentOcrModelManager: DocumentOcrModelManager | undefined let documentOcrModelManager: DocumentOcrModelManager | undefined
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
function createEmbeddingProvider( function createEmbeddingProvider(
settings: ResolvedRuntimeSettings settings: ResolvedRuntimeSettings
@@ -425,8 +429,10 @@ if (hasSingleInstanceLock) {
defaultWorkspace, defaultWorkspace,
initialRuntimeSettings.defaultModelProfileId initialRuntimeSettings.defaultModelProfileId
) )
assistantDatabase.repairConversationRuntimeSelections( channelSettingsStore.reportRuntimeSelectionRepairs(
initialRuntimeSettings assistantDatabase.repairConversationRuntimeSelections(
initialRuntimeSettings
)
) )
knowledgeGateway = new KnowledgeMcpGateway(knowledgeService, { knowledgeGateway = new KnowledgeMcpGateway(knowledgeService, {
magicNotesDatabase: assistantDatabase magicNotesDatabase: assistantDatabase
@@ -483,8 +489,11 @@ if (hasSingleInstanceLock) {
webSearchEnabled: webSearchCapability?.enabled webSearchEnabled: webSearchCapability?.enabled
}) })
} }
const createConfiguredRuntime = async (): Promise<AgentRuntime> => { const createConfiguredRuntime = async (
const settings = await settingsStore.getResolvedSettings() resolvedSettings?: ResolvedRuntimeSettings
): Promise<AgentRuntime> => {
const settings =
resolvedSettings ?? await settingsStore.getResolvedSettings()
return createRuntimeWithCapabilities( return createRuntimeWithCapabilities(
settings, settings,
getConfiguredRuntimeTarget(settings) getConfiguredRuntimeTarget(settings)
@@ -522,6 +531,41 @@ if (hasSingleInstanceLock) {
} }
}) })
let runtimeReconfigurationQueue: Promise<void> = Promise.resolve()
let runtimeReconfigurationClosing = false
const reconfigureRuntimes = (): Promise<void> => {
const operation = runtimeReconfigurationQueue.then(async () => {
if (runtimeReconfigurationClosing) {
throw new Error('Runtime 配置正在关闭')
}
const settings = await settingsStore.getResolvedSettings()
if (knowledgeService) {
await knowledgeService.setEmbeddingProvider(
createEmbeddingProvider(settings)
)
await knowledgeService.setRerankProvider(
createRerankProvider(settings)
)
}
if (runtime) {
await runtime.replace(
await createConfiguredRuntime(settings)
)
}
await selectedRuntimeManager?.reset()
await subagentService.replaceRuntimes(
createDefaultModelRuntime(defaultWorkspace, settings),
createSubagentProfileRuntimes(defaultWorkspace, settings)
)
})
runtimeReconfigurationQueue = operation.catch(() => undefined)
return operation
}
stopRuntimeReconfiguration = async () => {
runtimeReconfigurationClosing = true
await runtimeReconfigurationQueue
}
removeIpcHandlers = registerIpcHandlers( removeIpcHandlers = registerIpcHandlers(
mainWindow, mainWindow,
runtime, runtime,
@@ -533,27 +577,7 @@ if (hasSingleInstanceLock) {
assistantDatabase, assistantDatabase,
approvalBroker, approvalBroker,
bundledRuntimePaths, bundledRuntimePaths,
async () => { reconfigureRuntimes,
const settings = await settingsStore.getResolvedSettings()
if (knowledgeService) {
void knowledgeService
.setEmbeddingProvider(createEmbeddingProvider(settings))
.catch(() => undefined)
void knowledgeService
.setRerankProvider(createRerankProvider(settings))
.catch(() => undefined)
}
if (runtime) {
await runtime.replace(
await createConfiguredRuntime()
)
}
await selectedRuntimeManager?.reset()
await subagentService.replaceRuntimes(
createDefaultModelRuntime(defaultWorkspace, settings),
createSubagentProfileRuntimes(defaultWorkspace, settings)
)
},
async () => { async () => {
await browserService?.clearSessions() await browserService?.clearSessions()
}, },
@@ -604,27 +628,28 @@ app.on('before-quit', (event) => {
cleanupStarted = true cleanupStarted = true
void (async () => { void (async () => {
try { try {
const cleanup = Promise.allSettled([ const cleanup = settleCleanupPhases([
Promise.resolve().then(() => removeIpcHandlers?.()), [() => removeIpcHandlers?.()],
Promise.resolve().then(() => runtime?.dispose()), [() => stopRuntimeReconfiguration?.()],
Promise.resolve().then(() => selectedRuntimeManager?.dispose()), [
Promise.resolve().then(() => knowledgeGateway?.dispose()), () => runtime?.dispose(),
Promise.resolve().then(() => knowledgeService?.dispose()), () => selectedRuntimeManager?.dispose(),
Promise.resolve().then(() => browserService?.dispose()), () => browserService?.dispose(),
Promise.resolve().then(() => globalTlsPolicy?.dispose()), () => globalTlsPolicy?.dispose(),
Promise.resolve().then(() => documentOcrModelManager?.dispose()), () => documentOcrModelManager?.dispose(),
Promise.resolve().then(() => documentOcrBroker?.dispose()) () => documentOcrBroker?.dispose()
],
[() => knowledgeGateway?.dispose()],
[() => knowledgeService?.dispose()]
]) ])
globalShortcut.unregisterAll() globalShortcut.unregisterAll()
tray?.destroy() tray?.destroy()
await waitForCleanup(cleanup, 8_000) await runCleanupBeforeDeadline(cleanup, 8_000, () => {
} finally {
try {
assistantDatabase?.close() assistantDatabase?.close()
} finally { })
cleanupComplete = true } finally {
app.exit(0) cleanupComplete = true
} app.exit(0)
} }
})() })()
}) })
+697 -6
View File
@@ -10,6 +10,12 @@ import { AssistantDatabase } from './assistant/assistant-database'
import { registerIpcHandlers } from './ipc' import { registerIpcHandlers } from './ipc'
type InvokeHandler = (event: unknown, input?: unknown) => unknown type InvokeHandler = (event: unknown, input?: unknown) => unknown
type KnowledgeGrantMock = (
requestId: string,
libraryIds: readonly string[],
signal: AbortSignal,
access: 'read' | 'write'
) => string
const electronMocks = vi.hoisted(() => { const electronMocks = vi.hoisted(() => {
const handlers = new Map<string, InvokeHandler>() const handlers = new Map<string, InvokeHandler>()
@@ -274,6 +280,18 @@ describe('registerIpcHandlers computer capabilities', () => {
).toThrow() ).toThrow()
expect(capabilityService.createBrowserProfile).not.toHaveBeenCalled() expect(capabilityService.createBrowserProfile).not.toHaveBeenCalled()
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesCreateBrowserProfile
)?.(event, {
name: '工作配置'
})
).resolves.toEqual(snapshot)
expect(capabilityService.createBrowserProfile).toHaveBeenCalledWith(
'工作配置'
)
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(3)
expect(() => expect(() =>
electronMocks.handlers.get( electronMocks.handlers.get(
ipcChannels.capabilitiesDiagnoseComputer ipcChannels.capabilitiesDiagnoseComputer
@@ -339,6 +357,163 @@ vi.mock('./channels/channel-env', () => ({
) )
})) }))
describe('registerIpcHandlers lifecycle tracking', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
channelMocks.stop.mockResolvedValue(undefined)
})
it('waits for a pending settings update and Runtime reload during cleanup', async () => {
let releaseUpdate!: () => void
const updateReleased = new Promise<void>((resolve) => {
releaseUpdate = resolve
})
const workspace = await mkdtemp(
join(tmpdir(), 'goodbuddy-ipc-settings-')
)
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const savedSettings = {
provider: 'model',
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
modelProtocol: 'anthropic-messages',
modelAuthentication: 'api-key',
imageGenerationQuality: 'auto',
opencodeBaseUrl: '',
opencodeEmbedded: true,
opencodeBinaryPath: '',
opencodeConfigPath: '',
continueBinaryPath: '',
continueConfigPath: '',
continueMode: 'chat',
runtimeSandboxMode: 'auto',
subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl:
'http://127.0.0.1:11434/v1/embeddings',
knowledgeEmbeddingModel: 'nomic-embed-text',
knowledgeEmbeddingApiKeyConfigured: false,
knowledgeEmbeddingCredentialSource: 'none',
knowledgeRerankEnabled: false,
knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank',
knowledgeRerankModel: 'rerank-v3.5',
knowledgeRerankApiKeyConfigured: false,
knowledgeRerankCredentialSource: 'none',
workspacePath: workspace,
apiKeyConfigured: false,
credentialSource: 'none',
modelProfiles: [],
defaultModelProfileId: '00000000-0000-4000-8000-000000000001',
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' },
secureStorageAvailable: true,
toolApproval: 'always'
}
const update = vi.fn(async () => {
await updateReleased
return savedSettings
})
let releaseReload!: () => void
const reloadReleased = new Promise<void>((resolve) => {
releaseReload = resolve
})
const onRuntimeSettingsChanged = vi.fn(async () => {
await reloadReleased
})
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{ update } as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
claimDueSchedules: vi.fn(() => []),
repairConversationRuntimeSelections: vi.fn()
} as never,
{ clear: vi.fn() } as never,
{} as never,
onRuntimeSettingsChanged
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const input = {
provider: savedSettings.provider,
modelBaseUrl: savedSettings.modelBaseUrl,
modelName: savedSettings.modelName,
modelProtocol: savedSettings.modelProtocol,
modelAuthentication: savedSettings.modelAuthentication,
imageGenerationQuality: savedSettings.imageGenerationQuality,
opencodeBaseUrl: savedSettings.opencodeBaseUrl,
opencodeEmbedded: savedSettings.opencodeEmbedded,
opencodeBinaryPath: savedSettings.opencodeBinaryPath,
opencodeConfigPath: savedSettings.opencodeConfigPath,
continueBinaryPath: savedSettings.continueBinaryPath,
continueConfigPath: savedSettings.continueConfigPath,
continueMode: savedSettings.continueMode,
runtimeSandboxMode: savedSettings.runtimeSandboxMode,
knowledgeEmbeddingEnabled:
savedSettings.knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl:
savedSettings.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: savedSettings.knowledgeEmbeddingModel,
knowledgeRerankEnabled: savedSettings.knowledgeRerankEnabled,
knowledgeRerankEndpoint: savedSettings.knowledgeRerankEndpoint,
knowledgeRerankModel: savedSettings.knowledgeRerankModel,
workspacePath: savedSettings.workspacePath,
apiKey: { action: 'keep' as const },
toolApproval: savedSettings.toolApproval
}
try {
const pendingUpdate = Promise.resolve(
electronMocks.handlers.get(ipcChannels.runtimeSettingsUpdate)?.(
event,
input
)
)
await vi.waitFor(() => expect(update).toHaveBeenCalledOnce())
let cleanupComplete = false
const cleanup = dispose().then(() => {
cleanupComplete = true
})
await Promise.resolve()
expect(cleanupComplete).toBe(false)
releaseUpdate()
await vi.waitFor(() =>
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
)
expect(cleanupComplete).toBe(false)
releaseReload()
await expect(pendingUpdate).resolves.toBe(savedSettings)
await cleanup
expect(cleanupComplete).toBe(true)
} finally {
releaseUpdate()
releaseReload()
await rm(workspace, { recursive: true, force: true })
}
})
})
describe('registerIpcHandlers knowledge snapshot ontology', () => { describe('registerIpcHandlers knowledge snapshot ontology', () => {
afterEach(() => { afterEach(() => {
electronMocks.handlers.clear() electronMocks.handlers.clear()
@@ -1193,7 +1368,11 @@ describe('registerIpcHandlers Runtime config actions', () => {
await writeFile(configPath, 'name: Test', 'utf8') await writeFile(configPath, 'name: Test', 'utf8')
const getPublicSettings = vi.fn(async () => ({ const getPublicSettings = vi.fn(async () => ({
opencodeConfigPath: '', opencodeConfigPath: '',
continueConfigPath: configPath continueConfigPath: process.execPath,
configured: {
opencodeConfigPath: '',
continueConfigPath: configPath
}
})) }))
const webContents = { const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' }, mainFrame: { url: 'file:///goodbuddy/index.html' },
@@ -1259,7 +1438,11 @@ describe('registerIpcHandlers Runtime config actions', () => {
getPublicSettings.mockResolvedValueOnce({ getPublicSettings.mockResolvedValueOnce({
opencodeConfigPath: '', opencodeConfigPath: '',
continueConfigPath: process.execPath continueConfigPath: configPath,
configured: {
opencodeConfigPath: '',
continueConfigPath: process.execPath
}
}) })
await expect( await expect(
electronMocks.handlers.get( electronMocks.handlers.get(
@@ -1571,7 +1754,7 @@ describe('registerIpcHandlers agent terminal state', () => {
profileId: '00000000-0000-4000-8000-000000000001' profileId: '00000000-0000-4000-8000-000000000001'
}, },
kind: 'channel', kind: 'channel',
channel: 'wecom', channel: 'wecom',
status: 'active', status: 'active',
createdAt: '2026-08-04T00:00:00.000Z', createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z' updatedAt: '2026-08-04T00:00:00.000Z'
@@ -1633,11 +1816,21 @@ describe('registerIpcHandlers agent terminal state', () => {
subagentSmartRoutingEnabled: smartRoutingEnabled subagentSmartRoutingEnabled: smartRoutingEnabled
}) })
) )
const getPolicySettings = vi.fn(
async (): Promise<Record<string, unknown>> => ({
toolApproval,
subagentSmartRoutingEnabled: smartRoutingEnabled
})
)
const getApplicationSettings = vi.fn(async () => ({
magicNotesEnabled
}))
const dispose = registerIpcHandlers( const dispose = registerIpcHandlers(
window as never, window as never,
runtime as never, runtime as never,
'CommandOrControl+Shift+Space', 'CommandOrControl+Shift+Space',
{ {
getPolicySettings,
getResolvedSettings getResolvedSettings
} as never, } as never,
{} as never, {} as never,
@@ -1654,7 +1847,7 @@ describe('registerIpcHandlers agent terminal state', () => {
subagentService as never, subagentService as never,
undefined, undefined,
{ {
get: vi.fn(async () => ({ magicNotesEnabled })) get: getApplicationSettings
} as never, } as never,
undefined, undefined,
undefined, undefined,
@@ -1668,6 +1861,8 @@ describe('registerIpcHandlers agent terminal state', () => {
assistantDatabase, assistantDatabase,
contextManager, contextManager,
dispose, dispose,
getApplicationSettings,
getPolicySettings,
getResolvedSettings, getResolvedSettings,
clearHandler: electronMocks.handlers.get( clearHandler: electronMocks.handlers.get(
ipcChannels.appClearLocalData ipcChannels.appClearLocalData
@@ -1707,6 +1902,7 @@ describe('registerIpcHandlers agent terminal state', () => {
} }
const knowledgeGateway = { const knowledgeGateway = {
grant: vi.fn(() => 'capability'), grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
drainReferences: vi.fn(() => []), drainReferences: vi.fn(() => []),
revoke: vi.fn() revoke: vi.fn()
} }
@@ -1769,6 +1965,22 @@ describe('registerIpcHandlers agent terminal state', () => {
} }
const knowledgeGateway = { const knowledgeGateway = {
grant: vi.fn(() => 'capability'), grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => {
const grantCallCount = knowledgeGateway.grant.mock.calls.length
return grantCallCount === 1
? ['note_list', 'note_get', 'note_search']
: [
'note_list',
'note_get',
'note_search',
'note_create',
'note_update',
'note_entry_create',
'note_entry_update',
'note_entry_delete',
'note_delete'
]
}),
drainReferences: vi.fn(() => []), drainReferences: vi.fn(() => []),
revoke: vi.fn() revoke: vi.fn()
} }
@@ -1831,6 +2043,74 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose() await harness.dispose()
}) })
it.each(['ask', 'execute'] as const)(
'does not grant or advertise scoped data tools to external OpenCode in %s mode',
async (workMode) => {
let receivedRequest:
| {
knowledgeCapabilityToken?: string
trustedInstructions?: string
}
| undefined
const externalOpenCode = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
supportsScopedDataTools: false,
async *run(request: {
requestId: string
knowledgeCapabilityToken?: string
trustedInstructions?: string
}) {
receivedRequest = request
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn(() => 'must-not-be-granted'),
getAvailableToolNames: vi.fn(() => ['note_list']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const harness = createHarness(
externalOpenCode,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
true
)
const requestId = '00000000-0000-4000-8000-000000000025'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'external-opencode',
prompt: '读取笔记',
workMode,
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
expect(receivedRequest?.knowledgeCapabilityToken).toBeUndefined()
expect(receivedRequest?.trustedInstructions).not.toContain(
'note_list'
)
expect(receivedRequest?.trustedInstructions).not.toContain(
'Available GoodBuddy data tools:'
)
await harness.dispose()
}
)
it('accepts an authorized knowledge library after the first 100 entries', async () => { it('accepts an authorized knowledge library after the first 100 entries', async () => {
const libraries = Array.from({ length: 101 }, (_, index) => ({ const libraries = Array.from({ length: 101 }, (_, index) => ({
id: `00000000-0000-4000-8000-${index id: `00000000-0000-4000-8000-${index
@@ -1841,6 +2121,7 @@ describe('registerIpcHandlers agent terminal state', () => {
const listKnowledgeBases = vi.fn(() => libraries) const listKnowledgeBases = vi.fn(() => libraries)
const knowledgeGateway = { const knowledgeGateway = {
grant: vi.fn(() => 'capability'), grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
drainReferences: vi.fn(() => []), drainReferences: vi.fn(() => []),
revoke: vi.fn() revoke: vi.fn()
} }
@@ -1882,7 +2163,8 @@ describe('registerIpcHandlers agent terminal state', () => {
expect(knowledgeGateway.grant).toHaveBeenCalledWith( expect(knowledgeGateway.grant).toHaveBeenCalledWith(
requestId, requestId,
[libraries[100]!.id], [libraries[100]!.id],
expect.any(AbortSignal) expect.any(AbortSignal),
'none'
) )
await harness.dispose() await harness.dispose()
}) })
@@ -1912,6 +2194,7 @@ describe('registerIpcHandlers agent terminal state', () => {
} }
const knowledgeGateway = { const knowledgeGateway = {
grant: vi.fn(() => 'capability'), grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
drainReferences: vi.fn(() => [reference]), drainReferences: vi.fn(() => [reference]),
revoke: vi.fn() revoke: vi.fn()
} }
@@ -1948,7 +2231,8 @@ describe('registerIpcHandlers agent terminal state', () => {
expect(knowledgeGateway.grant).toHaveBeenCalledWith( expect(knowledgeGateway.grant).toHaveBeenCalledWith(
requestId, requestId,
[libraryId], [libraryId],
expect.any(AbortSignal) expect.any(AbortSignal),
'none'
) )
const publicEvents = harness.webContents.send.mock.calls const publicEvents = harness.webContents.send.mock.calls
.filter(([channel]) => channel === ipcChannels.agentEvent) .filter(([channel]) => channel === ipcChannels.agentEvent)
@@ -2057,6 +2341,7 @@ describe('registerIpcHandlers agent terminal state', () => {
]) ])
const knowledgeGateway = { const knowledgeGateway = {
grant: vi.fn(() => 'capability'), grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
drainReferences: vi.fn(() => []), drainReferences: vi.fn(() => []),
revoke: vi.fn() revoke: vi.fn()
} }
@@ -2333,6 +2618,67 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose() await harness.dispose()
}) })
it('rejects an active duplicate before resolving Runtime or settings again', async () => {
let releaseRun!: () => void
const runReleased = new Promise<void>((resolve) => {
releaseRun = resolve
})
let markRunStarted!: () => void
const runStarted = new Promise<void>((resolve) => {
markRunStarted = resolve
})
const selectedRuntime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
markRunStarted()
await runReleased
yield { requestId: request.requestId, type: 'done' }
}
}
const selectedRuntimes = {
getRuntime: vi.fn(async () => selectedRuntime),
getStatus: vi.fn(),
releaseConversation: vi.fn(async () => undefined)
}
const harness = createHarness(
selectedRuntime,
undefined,
'always',
undefined,
false,
selectedRuntimes
)
const event = trustedEvent(harness.webContents)
const request = {
requestId: '00000000-0000-4000-8000-000000000013',
conversationId: 'duplicate-request',
projectId: '00000000-0000-4000-8000-000000000101',
prompt: 'run once',
workMode: 'ask' as const
}
await harness.handler?.(event, request)
await runStarted
await expect(harness.handler?.(event, request)).rejects.toThrow(
'请求正在执行'
)
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
expect(harness.getApplicationSettings).toHaveBeenCalledOnce()
expect(harness.contextManager.enrichRequest).toHaveBeenCalledOnce()
releaseRun()
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
request.requestId,
'completed'
)
)
await harness.dispose()
})
it('aborts active work and clears browser sessions before assistant data', async () => { it('aborts active work and clears browser sessions before assistant data', async () => {
const lifecycle: string[] = [] const lifecycle: string[] = []
let markStarted!: () => void let markStarted!: () => void
@@ -2389,6 +2735,48 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose() await harness.dispose()
}) })
it('coalesces concurrent local-data clear requests', async () => {
let releaseClear!: () => void
const clearBlocked = new Promise<void>((resolve) => {
releaseClear = resolve
})
const onBeforeClearLocalData = vi.fn(async () => {
await clearBlocked
})
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn()
}
const harness = createHarness(runtime, onBeforeClearLocalData)
const event = trustedEvent(harness.webContents)
const firstClear = harness.clearHandler?.(event)
const secondClear = harness.clearHandler?.(event)
expect(firstClear).toBe(secondClear)
await vi.waitFor(() => {
expect(onBeforeClearLocalData).toHaveBeenCalledOnce()
})
await expect(
harness.handler?.(event, {
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-during-clear',
prompt: 'do not start',
workMode: 'execute'
})
).rejects.toThrow('本地数据维护期间暂不接受新任务')
releaseClear()
await expect(firstClear).resolves.toBeUndefined()
expect(
harness.assistantDatabase.clearAssistantData
).toHaveBeenCalledOnce()
await harness.dispose()
})
it('marks a request failed when a tool fails before runtime done', async () => { it('marks a request failed when a tool fails before runtime done', async () => {
const runtime = { const runtime = {
capability: 'chat', capability: 'chat',
@@ -2655,6 +3043,8 @@ describe('registerIpcHandlers agent terminal state', () => {
) )
) )
expect(runtime.run).not.toHaveBeenCalled() expect(runtime.run).not.toHaveBeenCalled()
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(subagentService.run).toHaveBeenCalledWith( expect(subagentService.run).toHaveBeenCalledWith(
expect.objectContaining({ expert, routingMode: 'smart' }) expect.objectContaining({ expert, routingMode: 'smart' })
) )
@@ -2899,6 +3289,8 @@ describe('registerIpcHandlers agent terminal state', () => {
}) })
).resolves.toBe('deny') ).resolves.toBe('deny')
expect(harness.approvalBroker.request).not.toHaveBeenCalled() expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(harness.getPolicySettings).not.toHaveBeenCalled()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(harness.assistantDatabase.createTask).toHaveBeenCalledWith( expect(harness.assistantDatabase.createTask).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
title: '企业微信远程请求', title: '企业微信远程请求',
@@ -2910,6 +3302,128 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose() await harness.dispose()
}) })
it.each([
['weixin', '微信 ClawBot'],
['wecom', '企业微信'],
['dingtalk', '钉钉']
] as const)(
'grants read-only Magic Notes tools to %s channel Ask requests',
async (channel, channelLabel) => {
let receivedRequest:
| {
knowledgeCapabilityToken?: string
prompt: string
trustedInstructions?: string
workMode: string
}
| undefined
const runtime = {
capability: 'chat',
async *run(request: {
requestId: string
knowledgeCapabilityToken?: string
prompt: string
trustedInstructions?: string
workMode: string
}) {
receivedRequest = request
yield {
requestId: request.requestId,
type: 'tool',
callId: `call-${channel}-note-list`,
name: 'note_list',
state: 'completed',
summary: '读取笔记列表'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn<KnowledgeGrantMock>(() =>
'channel-notes-capability'
),
getAvailableToolNames: vi.fn(() => [
'note_list',
'note_get',
'note_search'
]),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
true
)
vi.mocked(
harness.assistantDatabase.listProjects
).mockReturnValue([
{
id: '00000000-0000-4000-8000-000000000401',
name: channelLabel,
description: `${channelLabel}远程消息与受控任务`,
rootPath: 'C:\\ProjectWorkspace',
defaultWorkMode: 'ask',
runtimeSelection: {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000001'
},
kind: 'channel',
channel,
status: 'active',
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
}
])
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
await expect(
executor(
{
channel,
eventId: `event-${channel}-notes`,
senderId: 'user-1',
conversationId: `conversation-${channel}-notes`,
conversationType: 'direct',
text: '读取我的笔记',
mentioned: false,
workMode: 'ask'
},
new AbortController().signal
)
).resolves.toMatchObject({ status: 'completed' })
const requestId =
vi.mocked(knowledgeGateway.grant).mock.calls[0]?.[0]
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
requestId,
[],
expect.any(AbortSignal),
'read'
)
expect(receivedRequest).toMatchObject({
knowledgeCapabilityToken: 'channel-notes-capability',
workMode: 'ask',
trustedInstructions: expect.stringContaining(
'note_list, note_get, note_search'
)
})
expect(receivedRequest?.prompt).toContain('读取我的笔记')
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
'channel-notes-capability'
)
await harness.dispose()
}
)
it('persists remote media and passes it through the existing context path', async () => { it('persists remote media and passes it through the existing context path', async () => {
let receivedRequest: let receivedRequest:
| { | {
@@ -3191,6 +3705,179 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose() await harness.dispose()
}) })
it('grants Magic Notes write tools to channel Execute requests', async () => {
let receivedRequest:
| {
knowledgeCapabilityToken?: string
trustedInstructions?: string
workMode: string
}
| undefined
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
getStatus: vi.fn(async () => ({
id: 'model',
label: 'Direct model',
available: true,
supportsToolExecution: true
})),
async *run(request: {
requestId: string
knowledgeCapabilityToken?: string
trustedInstructions?: string
workMode: string
}) {
receivedRequest = request
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn<KnowledgeGrantMock>(() =>
'channel-notes-write-capability'
),
getAvailableToolNames: vi.fn(() => [
'note_list',
'note_get',
'note_search',
'note_create',
'note_update',
'note_entry_create',
'note_entry_update',
'note_entry_delete',
'note_delete'
]),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
true
)
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
await expect(
executor(
{
channel: 'wecom',
eventId: 'event-execute-notes',
senderId: 'user-1',
conversationId: 'conversation-execute-notes',
conversationType: 'direct',
text: '/execute 创建一条笔记',
mentioned: false,
workMode: 'ask'
},
new AbortController().signal
)
).resolves.toMatchObject({ status: 'completed' })
const requestId =
vi.mocked(knowledgeGateway.grant).mock.calls[0]?.[0]
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
requestId,
[],
expect.any(AbortSignal),
'write'
)
expect(receivedRequest).toMatchObject({
knowledgeCapabilityToken: 'channel-notes-write-capability',
workMode: 'execute',
trustedInstructions: expect.stringContaining('note_create')
})
expect(receivedRequest?.trustedInstructions).toContain(
'note_delete'
)
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
'channel-notes-write-capability'
)
await harness.dispose()
})
it('does not grant or advertise Magic Notes to external OpenCode channels', async () => {
let receivedRequest:
| {
knowledgeCapabilityToken?: string
trustedInstructions?: string
}
| undefined
const selectedRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
supportsScopedDataTools: false,
getStatus: vi.fn(async () => ({
id: 'opencode',
label: 'External OpenCode',
available: true,
supportsToolExecution: true
})),
async *run(request: {
requestId: string
knowledgeCapabilityToken?: string
trustedInstructions?: string
}) {
receivedRequest = request
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn<KnowledgeGrantMock>(() => 'must-not-be-granted'),
getAvailableToolNames: vi.fn(() => ['note_list']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const harness = createHarness(
selectedRuntime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
true
)
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
const result = await executor(
{
channel: 'wecom',
eventId: 'event-external-opencode',
senderId: 'friend',
conversationId: 'external-opencode',
conversationType: 'direct',
text: '读取我的笔记',
mentioned: true,
workMode: 'ask'
},
new AbortController().signal
)
if (result.status === 'failed') {
throw new Error(result.error)
}
expect(result).toMatchObject({ status: 'completed' })
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
expect(receivedRequest?.knowledgeCapabilityToken).toBeUndefined()
expect(receivedRequest?.trustedInstructions).not.toContain(
'note_list'
)
await harness.dispose()
})
it('routes remote Execute to a configured Agent Runtime without a GoodBuddy approval callback', async () => { it('routes remote Execute to a configured Agent Runtime without a GoodBuddy approval callback', async () => {
let receivedAuthorize: unknown = 'not-called' let receivedAuthorize: unknown = 'not-called'
const configuredProfileId = const configuredProfileId =
@@ -3374,6 +4061,8 @@ describe('registerIpcHandlers agent terminal state', () => {
expect(receivedAuthorize).toEqual(expect.any(Function)) expect(receivedAuthorize).toEqual(expect.any(Function))
expect(decision).toBe('once') expect(decision).toBe('once')
expect(harness.approvalBroker.request).not.toHaveBeenCalled() expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect( expect(
harness.assistantDatabase.updateTaskStatus harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(requestId, 'waiting_approval') ).not.toHaveBeenCalledWith(requestId, 'waiting_approval')
@@ -3429,6 +4118,8 @@ describe('registerIpcHandlers agent terminal state', () => {
) )
expect(decision).toBe('deny') expect(decision).toBe('deny')
expect(harness.approvalBroker.request).not.toHaveBeenCalled() expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(harness.webContents.send).not.toHaveBeenCalledWith( expect(harness.webContents.send).not.toHaveBeenCalledWith(
ipcChannels.agentEvent, ipcChannels.agentEvent,
expect.objectContaining({ type: 'approval' }) expect.objectContaining({ type: 'approval' })
+399 -281
View File
File diff suppressed because it is too large Load Diff
+315 -2
View File
@@ -7,7 +7,7 @@ import {
} from 'node:fs/promises' } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { import {
runtimeSettingsInputSchema, runtimeSettingsInputSchema,
type RuntimeSettingsInput type RuntimeSettingsInput
@@ -456,6 +456,29 @@ describe('RuntimeSettingsStore', () => {
).toBe(true) ).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 () => { it('encrypts an OpenAI-compatible embedding API key and binds it to the full endpoint', async () => {
const { filePath, store } = await createStore() const { filePath, store } = await createStore()
await store.update( await store.update(
@@ -582,6 +605,81 @@ describe('RuntimeSettingsStore', () => {
) )
}) })
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 () => { it('clears rerank credentials and rejects replacement without secure storage', async () => {
const { filePath, store } = await createStore() const { filePath, store } = await createStore()
await store.update( await store.update(
@@ -648,6 +746,57 @@ describe('RuntimeSettingsStore', () => {
}) })
}) })
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 () => { it('defaults image quality when migrating version 7 settings', async () => {
const { filePath, store } = await createStore() const { filePath, store } = await createStore()
await store.update( await store.update(
@@ -915,6 +1064,78 @@ describe('RuntimeSettingsStore', () => {
}) })
}) })
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 () => { it('migrates version 1 settings without losing the encrypted API key', async () => {
const { filePath, store } = await createStore() const { filePath, store } = await createStore()
const encryptedCredential = cipher const encryptedCredential = cipher
@@ -1319,11 +1540,103 @@ describe('RuntimeSettingsStore', () => {
await expect(store.getPublicSettings()).resolves.toMatchObject({ await expect(store.getPublicSettings()).resolves.toMatchObject({
provider: 'model', provider: 'model',
warning: expect.stringContaining('已损坏') warnings: [{ code: 'runtime-settings-recovered' }]
}) })
const files = await readdir(join(filePath, '..')) const files = await readdir(join(filePath, '..'))
expect( expect(
files.some((name) => name.startsWith('runtime-settings.json.corrupt-')) files.some((name) => name.startsWith('runtime-settings.json.corrupt-'))
).toBe(true) ).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(
[]
)
})
}) })
+358 -237
View File
@@ -1,14 +1,9 @@
import { import {
mkdir,
readFile, readFile,
realpath, realpath,
rename, stat
rm,
stat,
writeFile
} from 'node:fs/promises' } from 'node:fs/promises'
import { homedir } from 'node:os' import { homedir } from 'node:os'
import { dirname } from 'node:path'
import { z } from 'zod' import { z } from 'zod'
import { import {
continueModeSchema, continueModeSchema,
@@ -23,17 +18,28 @@ import {
runtimeProviderSchema, runtimeProviderSchema,
runtimeSandboxModeSchema, runtimeSandboxModeSchema,
toolApprovalPolicySchema, toolApprovalPolicySchema,
RuntimeSettings, type RuntimeSettings,
type RuntimeSettingsInput type RuntimeSettingsInput
} from '../shared/contracts' } from '../shared/contracts'
import {
settingsWarningsEqual,
type SettingsWarning
} from '../shared/settings-warning-contracts'
import {
assertSupportedSettingsVersion,
isolateCorruptSettingsFile,
isMissingFileError,
UnsupportedSettingsVersionError,
writeJsonFileAtomically
} from './settings-file-utils'
import {
decryptSettingsCredential,
encryptedSettingsCredentialSchema,
encryptSettingsCredential,
type SettingsCredentialCipher
} from './settings-credential-cipher'
const credentialSchema = z const credentialSchema = encryptedSettingsCredentialSchema.optional()
.object({
formatVersion: z.literal(1),
scheme: z.literal('electron-safe-storage'),
ciphertextBase64: z.string()
})
.optional()
const version4StoredSettingsSchema = z.object({ const version4StoredSettingsSchema = z.object({
version: z.literal(4), version: z.literal(4),
@@ -179,8 +185,6 @@ const storedSettingsSchema = version13StoredSettingsSchema
knowledgeRerankCredential: credentialSchema knowledgeRerankCredential: credentialSchema
}) })
class UnsupportedRuntimeSettingsVersionError extends Error {}
type StoredSettings = z.infer<typeof storedSettingsSchema> type StoredSettings = z.infer<typeof storedSettingsSchema>
type Version10StoredSettings = z.infer< type Version10StoredSettings = z.infer<
typeof version10StoredSettingsSchema typeof version10StoredSettingsSchema
@@ -239,11 +243,7 @@ const embeddingCredentialPayloadSchema = z.object({
const rerankCredentialPayloadSchema = embeddingCredentialPayloadSchema const rerankCredentialPayloadSchema = embeddingCredentialPayloadSchema
export type CredentialCipher = { export type CredentialCipher = SettingsCredentialCipher
isAvailable: () => boolean
encrypt: (value: string) => Buffer
decrypt: (value: Buffer) => string
}
export type ResolvedRuntimeSettings = { export type ResolvedRuntimeSettings = {
provider: RuntimeSettings['provider'] provider: RuntimeSettings['provider']
@@ -279,6 +279,11 @@ export type ResolvedRuntimeSettings = {
toolApproval: RuntimeSettings['toolApproval'] toolApproval: RuntimeSettings['toolApproval']
} }
export type RuntimePolicySettings = Pick<
ResolvedRuntimeSettings,
'subagentSmartRoutingEnabled' | 'toolApproval'
>
export type ResolvedModelProfile = { export type ResolvedModelProfile = {
id: string id: string
name: string name: string
@@ -347,6 +352,15 @@ function migrateContinueCommand(command: string): string {
return value === 'cn' ? '' : value return value === 'cn' ? '' : value
} }
function normalizeModelBaseUrl(value: string): string {
const url = new URL(value)
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('模型服务地址必须使用 HTTP 或 HTTPS')
}
url.pathname = url.pathname.replace(/\/+$/u, '')
return url.toString().replace(/\/$/u, '')
}
function compatibleTextProfileId( function compatibleTextProfileId(
settings: Pick< settings: Pick<
Version10StoredSettings, Version10StoredSettings,
@@ -445,14 +459,21 @@ function migrateVersion10(
} }
function normalizeStoredSettings(settings: StoredSettings): StoredSettings { function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
const fallbackProfileId = compatibleTextProfileId(settings) const modelProfiles = settings.modelProfiles.map((profile) => ({
...profile,
baseUrl: normalizeModelBaseUrl(profile.baseUrl)
}))
const fallbackProfileId = compatibleTextProfileId({
modelProfiles,
defaultModelProfileId: settings.defaultModelProfileId
})
const normalizeSource = ( const normalizeSource = (
source: RuntimeSettings['opencodeModelSource'] source: RuntimeSettings['opencodeModelSource']
): RuntimeSettings['opencodeModelSource'] => { ): RuntimeSettings['opencodeModelSource'] => {
if (source.kind === 'platform') { if (source.kind === 'platform') {
return source return source
} }
const profile = settings.modelProfiles.find( const profile = modelProfiles.find(
(candidate) => candidate.id === source.profileId (candidate) => candidate.id === source.profileId
) )
if (profile && isAgentRuntimeModelProtocol(profile.protocol)) { if (profile && isAgentRuntimeModelProtocol(profile.protocol)) {
@@ -463,14 +484,21 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
: { kind: 'platform' } : { kind: 'platform' }
} }
const opencodeBaseUrl = settings.opencodeBaseUrl.trim() const opencodeBaseUrl = settings.opencodeBaseUrl.trim()
const defaultModelProfileId = settings.modelProfiles.some( if (opencodeBaseUrl) {
const url = new URL(opencodeBaseUrl)
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('OpenCode 地址必须使用 HTTP 或 HTTPS')
}
}
const defaultModelProfileId = modelProfiles.some(
(profile) => profile.id === settings.defaultModelProfileId (profile) => profile.id === settings.defaultModelProfileId
) )
? settings.defaultModelProfileId ? settings.defaultModelProfileId
: settings.modelProfiles[0]!.id : modelProfiles[0]!.id
return { return {
...settings, ...settings,
modelProfiles,
provider: provider:
settings.provider === 'auto' ? 'model' : settings.provider, settings.provider === 'auto' ? 'model' : settings.provider,
defaultModelProfileId, defaultModelProfileId,
@@ -558,15 +586,27 @@ function migrateVersion5(
function migrateVersion6( function migrateVersion6(
settings: z.infer<typeof version6StoredSettingsSchema> settings: z.infer<typeof version6StoredSettingsSchema>
): StoredSettings { ): StoredSettings {
const endpoint = new URL(settings.knowledgeEmbeddingBaseUrl) let knowledgeEmbeddingBaseUrl: string =
endpoint.pathname = `${endpoint.pathname.replace(/\/+$/u, '')}/v1/embeddings` defaultRuntimeSettings.knowledgeEmbeddingBaseUrl
try {
const endpoint = new URL(settings.knowledgeEmbeddingBaseUrl)
if (['http:', 'https:'].includes(endpoint.protocol)) {
const path = endpoint.pathname.replace(/\/+$/u, '')
if (!/\/v1\/embeddings$/iu.test(path)) {
endpoint.pathname = `${path}/v1/embeddings`
}
knowledgeEmbeddingBaseUrl = endpoint.toString()
}
} catch {
// Preserve the rest of the legacy settings and repair only this endpoint.
}
return migrateVersion10({ return migrateVersion10({
...settings, ...settings,
version: 10, version: 10,
subagentSmartRoutingEnabled: subagentSmartRoutingEnabled:
defaultRuntimeSettings.subagentSmartRoutingEnabled, defaultRuntimeSettings.subagentSmartRoutingEnabled,
intranetCompatibilityEnabled: true, intranetCompatibilityEnabled: true,
knowledgeEmbeddingBaseUrl: endpoint.toString(), knowledgeEmbeddingBaseUrl,
modelProfiles: settings.modelProfiles.map((profile) => ({ modelProfiles: settings.modelProfiles.map((profile) => ({
...profile, ...profile,
imageGenerationQuality: imageGenerationQuality:
@@ -613,15 +653,10 @@ function migrateVersion9(
}) })
} }
function normalizeModelBaseUrl(value: string): string {
const url = new URL(value)
url.pathname = url.pathname.replace(/\/+$/u, '')
return url.toString().replace(/\/$/u, '')
}
export class RuntimeSettingsStore { export class RuntimeSettingsStore {
private settings?: StoredSettings private settings?: StoredSettings
private loadWarning?: string private settingsLoad?: Promise<StoredSettings>
private loadWarnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve() private updateQueue: Promise<void> = Promise.resolve()
constructor( constructor(
@@ -630,25 +665,28 @@ export class RuntimeSettingsStore {
private readonly environment: NodeJS.ProcessEnv = process.env private readonly environment: NodeJS.ProcessEnv = process.env
) {} ) {}
private async load(): Promise<StoredSettings> { private load(): Promise<StoredSettings> {
if (this.settings) { if (this.settings) {
return this.settings return Promise.resolve(this.settings)
} }
if (!this.settingsLoad) {
this.settingsLoad = this.readSettings().finally(() => {
this.settingsLoad = undefined
})
}
return this.settingsLoad
}
private async readSettings(): Promise<StoredSettings> {
try { try {
const contents = await readFile(this.filePath, 'utf8') const contents = await readFile(this.filePath, 'utf8')
const parsed: unknown = JSON.parse(contents) const parsed: unknown = JSON.parse(contents)
if ( assertSupportedSettingsVersion(
parsed && parsed,
typeof parsed === 'object' && 14,
'version' in parsed && (version) =>
typeof parsed.version === 'number' && `当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试`
parsed.version > 14 )
) {
throw new UnsupportedRuntimeSettingsVersionError(
` GoodBuddy Runtime ${parsed.version}`
)
}
const current = storedSettingsSchema.safeParse(parsed) const current = storedSettingsSchema.safeParse(parsed)
if (current.success) { if (current.success) {
this.settings = current.data this.settings = current.data
@@ -772,23 +810,15 @@ export class RuntimeSettingsStore {
} }
this.settings = normalizeStoredSettings(this.settings) this.settings = normalizeStoredSettings(this.settings)
} catch (error) { } catch (error) {
if (error instanceof UnsupportedRuntimeSettingsVersionError) { if (error instanceof UnsupportedSettingsVersionError) {
throw error throw error
} }
if ( if (!isMissingFileError(error)) {
!( await isolateCorruptSettingsFile(
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
) {
this.loadWarning =
'Runtime 设置文件已损坏,已隔离原文件并恢复默认设置'
await rename(
this.filePath, this.filePath,
`${this.filePath}.corrupt-${Date.now()}` 'Runtime 设置已损坏且无法隔离'
).catch(() => undefined) )
this.loadWarnings = [{ code: 'runtime-settings-recovered' }]
} }
this.settings = { ...defaultSettings } this.settings = { ...defaultSettings }
} }
@@ -798,52 +828,70 @@ export class RuntimeSettingsStore {
private getStoredApiKey( private getStoredApiKey(
profile: StoredSettings['modelProfiles'][number] profile: StoredSettings['modelProfiles'][number]
): string | undefined { ): string | undefined {
if (!profile.credential || !this.cipher.isAvailable()) { if (!profile.credential) {
return undefined return undefined
} }
const warning = (code: SettingsWarning['code']): undefined => {
this.addWarning({ code, subject: profile.name })
return undefined
}
if (!this.cipher.isAvailable()) {
return warning('runtime-model-credential-unreadable')
}
try { try {
const payload = credentialPayloadSchema.parse( const payload = credentialPayloadSchema.parse(
JSON.parse( decryptSettingsCredential(this.cipher, profile.credential)
this.cipher.decrypt(
Buffer.from(profile.credential.ciphertextBase64, 'base64')
)
)
) )
if (payload.origin !== new URL(profile.baseUrl).origin) { if (payload.origin !== new URL(profile.baseUrl).origin) {
this.loadWarning = return warning('runtime-model-credential-binding-mismatch')
`${profile.name} API Key API Key`
return undefined
} }
this.removeWarnings(
[
'runtime-model-credential-unreadable',
'runtime-model-credential-binding-mismatch'
],
profile.name
)
return payload.apiKey return payload.apiKey
} catch { } catch {
return undefined return warning('runtime-model-credential-unreadable')
} }
} }
private getStoredEmbeddingApiKey( private getStoredEmbeddingApiKey(
settings: StoredSettings settings: StoredSettings
): string | undefined { ): string | undefined {
if ( if (!settings.knowledgeEmbeddingCredential) {
!settings.knowledgeEmbeddingCredential || return undefined
!this.cipher.isAvailable() }
) { if (!this.cipher.isAvailable()) {
this.addWarning({
code: 'runtime-embedding-credential-unreadable'
})
return undefined return undefined
} }
try { try {
const payload = embeddingCredentialPayloadSchema.parse( const payload = embeddingCredentialPayloadSchema.parse(
JSON.parse( decryptSettingsCredential(
this.cipher.decrypt( this.cipher,
Buffer.from( settings.knowledgeEmbeddingCredential
settings.knowledgeEmbeddingCredential.ciphertextBase64,
'base64'
)
)
) )
) )
return payload.endpoint === settings.knowledgeEmbeddingBaseUrl if (payload.endpoint !== settings.knowledgeEmbeddingBaseUrl) {
? payload.apiKey this.addWarning({
: undefined code: 'runtime-embedding-credential-binding-mismatch'
})
return undefined
}
this.removeWarnings([
'runtime-embedding-credential-unreadable',
'runtime-embedding-credential-binding-mismatch'
])
return payload.apiKey
} catch { } catch {
this.addWarning({
code: 'runtime-embedding-credential-unreadable'
})
return undefined return undefined
} }
} }
@@ -851,28 +899,64 @@ export class RuntimeSettingsStore {
private getStoredRerankApiKey( private getStoredRerankApiKey(
settings: StoredSettings settings: StoredSettings
): string | undefined { ): string | undefined {
if (!settings.knowledgeRerankCredential || !this.cipher.isAvailable()) { if (!settings.knowledgeRerankCredential) {
return undefined
}
if (!this.cipher.isAvailable()) {
this.addWarning({
code: 'runtime-rerank-credential-unreadable'
})
return undefined return undefined
} }
try { try {
const payload = rerankCredentialPayloadSchema.parse( const payload = rerankCredentialPayloadSchema.parse(
JSON.parse( decryptSettingsCredential(
this.cipher.decrypt( this.cipher,
Buffer.from( settings.knowledgeRerankCredential
settings.knowledgeRerankCredential.ciphertextBase64,
'base64'
)
)
) )
) )
return payload.endpoint === settings.knowledgeRerankEndpoint if (payload.endpoint !== settings.knowledgeRerankEndpoint) {
? payload.apiKey this.addWarning({
: undefined code: 'runtime-rerank-credential-binding-mismatch'
})
return undefined
}
this.removeWarnings([
'runtime-rerank-credential-unreadable',
'runtime-rerank-credential-binding-mismatch'
])
return payload.apiKey
} catch { } catch {
this.addWarning({
code: 'runtime-rerank-credential-unreadable'
})
return undefined return undefined
} }
} }
private addWarning(warning: SettingsWarning): void {
if (
!this.loadWarnings.some(
(current) => settingsWarningsEqual(current, warning)
)
) {
this.loadWarnings.push(warning)
}
}
private removeWarnings(
codes: readonly SettingsWarning['code'][],
subject?: string
): void {
this.loadWarnings = this.loadWarnings.filter(
(warning) =>
!(
codes.includes(warning.code) &&
(subject === undefined || warning.subject === subject)
)
)
}
private getEnvironmentApiKey(): string | undefined { private getEnvironmentApiKey(): string | undefined {
return ( return (
this.environment.GOODBUDDY_MODEL_API_KEY?.trim() || this.environment.GOODBUDDY_MODEL_API_KEY?.trim() ||
@@ -903,7 +987,7 @@ export class RuntimeSettingsStore {
? this.getEnvironmentApiKey() ? this.getEnvironmentApiKey()
: undefined : undefined
const storedApiKey = const storedApiKey =
profile.authentication === 'api-key' profile.authentication === 'api-key' && !environmentApiKey
? this.getStoredApiKey(profile) ? this.getStoredApiKey(profile)
: undefined : undefined
const environmentBaseUrl = const environmentBaseUrl =
@@ -918,6 +1002,14 @@ export class RuntimeSettingsStore {
const model = environmentApiKey const model = environmentApiKey
? environmentModel || defaultRuntimeSettings.modelName ? environmentModel || defaultRuntimeSettings.modelName
: profile.modelName : profile.modelName
const credentialSource: RuntimeSettings['credentialSource'] =
environmentApiKey
? 'environment'
: storedApiKey
? 'encrypted'
: profile.credential
? 'unreadable'
: 'none'
return { return {
apiKey: environmentApiKey ?? storedApiKey, apiKey: environmentApiKey ?? storedApiKey,
baseUrl, baseUrl,
@@ -926,52 +1018,45 @@ export class RuntimeSettingsStore {
authentication: profile.authentication, authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput, supportsImageInput: profile.supportsImageInput,
imageGenerationQuality: profile.imageGenerationQuality, imageGenerationQuality: profile.imageGenerationQuality,
credentialSource: environmentApiKey credentialSource
? 'environment'
: storedApiKey
? 'encrypted'
: 'none'
} }
} }
private resolveProfile( private resolveModelProfiles(
settings: StoredSettings, settings: StoredSettings,
profileId: string effective: ReturnType<
): ResolvedModelProfile | undefined { RuntimeSettingsStore['resolveEffectiveModelSettings']
const profile = settings.modelProfiles.find( >
(candidate) => candidate.id === profileId ): ResolvedModelProfile[] {
return settings.modelProfiles.map((profile) =>
profile.id === settings.defaultModelProfileId
? {
id: profile.id,
name: profile.name,
baseUrl: effective.baseUrl,
modelName: effective.model,
protocol: effective.protocol,
authentication: effective.authentication,
supportsImageInput: effective.supportsImageInput,
imageGenerationQuality:
effective.imageGenerationQuality,
apiKey: effective.apiKey
}
: {
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:
profile.authentication === 'api-key'
? this.getStoredApiKey(profile)
: undefined
}
) )
if (!profile) {
return undefined
}
if (profile.id === settings.defaultModelProfileId) {
const effective = this.resolveEffectiveModelSettings(settings)
return {
id: profile.id,
name: profile.name,
baseUrl: effective.baseUrl,
modelName: effective.model,
protocol: effective.protocol,
authentication: effective.authentication,
supportsImageInput: effective.supportsImageInput,
imageGenerationQuality: effective.imageGenerationQuality,
apiKey: effective.apiKey
}
}
return {
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:
profile.authentication === 'api-key'
? this.getStoredApiKey(profile)
: undefined
}
} }
private resolveAgentSettings(settings: StoredSettings): { private resolveAgentSettings(settings: StoredSettings): {
@@ -1022,48 +1107,81 @@ export class RuntimeSettingsStore {
private toPublicSettings(settings: StoredSettings): RuntimeSettings { private toPublicSettings(settings: StoredSettings): RuntimeSettings {
const effective = this.resolveEffectiveModelSettings(settings) const effective = this.resolveEffectiveModelSettings(settings)
const agent = this.resolveAgentSettings(settings) const agent = this.resolveAgentSettings(settings)
const environmentApiKeyConfigured = Boolean(
this.getEnvironmentApiKey()
)
const resolvedModelProfiles = this.resolveModelProfiles(
settings,
effective
)
const resolvedProfilesById = new Map(
resolvedModelProfiles.map((profile) => [profile.id, profile])
)
const modelProfiles = settings.modelProfiles.map((profile) => { const modelProfiles = settings.modelProfiles.map((profile) => {
const isDefault = profile.id === settings.defaultModelProfileId const isDefault = profile.id === settings.defaultModelProfileId
const apiKey = const resolved = resolvedProfilesById.get(profile.id)
profile.authentication === 'api-key' if (!resolved) {
? this.getStoredApiKey(profile) throw new Error(`模型连接不存在:${profile.id}`)
: undefined }
const apiKey = resolved.apiKey
return { return {
id: profile.id, id: profile.id,
name: profile.name, name: profile.name,
baseUrl: isDefault baseUrl: resolved.baseUrl,
? effective.baseUrl modelName: resolved.modelName,
: profile.baseUrl, protocol: resolved.protocol,
modelName: isDefault ? effective.model : profile.modelName, authentication: resolved.authentication,
protocol: isDefault supportsImageInput: resolved.supportsImageInput,
? effective.protocol imageGenerationQuality:
: profile.protocol, resolved.imageGenerationQuality ??
authentication: isDefault defaultRuntimeSettings.imageGenerationQuality,
? effective.authentication apiKeyConfigured: Boolean(apiKey),
: profile.authentication,
supportsImageInput: isDefault
? effective.supportsImageInput
: profile.supportsImageInput,
imageGenerationQuality: isDefault
? effective.imageGenerationQuality
: profile.imageGenerationQuality,
apiKeyConfigured: isDefault
? Boolean(effective.apiKey)
: Boolean(apiKey),
credentialSource: isDefault credentialSource: isDefault
? effective.credentialSource ? effective.credentialSource
: apiKey : apiKey
? ('encrypted' as const) ? ('encrypted' as const)
: ('none' as const) : profile.credential
? ('unreadable' as const)
: ('none' as const)
}
})
const configuredModelProfiles = settings.modelProfiles.map((profile) => {
const environmentManaged =
profile.id === settings.defaultModelProfileId &&
profile.authentication === 'api-key' &&
environmentApiKeyConfigured
const apiKey = resolvedProfilesById.get(profile.id)?.apiKey
return {
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
imageGenerationQuality:
profile.imageGenerationQuality ??
defaultRuntimeSettings.imageGenerationQuality,
apiKeyConfigured: environmentManaged || Boolean(apiKey),
credentialSource: environmentManaged
? ('environment' as const)
: apiKey
? ('encrypted' as const)
: profile.credential
? ('unreadable' as const)
: ('none' as const)
} }
}) })
const embeddingEnvironmentApiKey = const embeddingEnvironmentApiKey =
this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim() this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim()
const embeddingStoredApiKey = const embeddingStoredApiKey = embeddingEnvironmentApiKey
this.getStoredEmbeddingApiKey(settings) ? undefined
: this.getStoredEmbeddingApiKey(settings)
const rerankEnvironmentApiKey = const rerankEnvironmentApiKey =
this.environment.GOODBUDDY_RERANK_API_KEY?.trim() this.environment.GOODBUDDY_RERANK_API_KEY?.trim()
const rerankStoredApiKey = this.getStoredRerankApiKey(settings) const rerankStoredApiKey = rerankEnvironmentApiKey
? undefined
: this.getStoredRerankApiKey(settings)
return { return {
provider: settings.provider, provider: settings.provider,
modelBaseUrl: effective.baseUrl, modelBaseUrl: effective.baseUrl,
@@ -1092,7 +1210,9 @@ export class RuntimeSettingsStore {
? 'environment' ? 'environment'
: embeddingStoredApiKey : embeddingStoredApiKey
? 'encrypted' ? 'encrypted'
: 'none', : settings.knowledgeEmbeddingCredential
? 'unreadable'
: 'none',
knowledgeRerankEnabled: settings.knowledgeRerankEnabled, knowledgeRerankEnabled: settings.knowledgeRerankEnabled,
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint, knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint,
knowledgeRerankModel: settings.knowledgeRerankModel, knowledgeRerankModel: settings.knowledgeRerankModel,
@@ -1103,7 +1223,9 @@ export class RuntimeSettingsStore {
? 'environment' ? 'environment'
: rerankStoredApiKey : rerankStoredApiKey
? 'encrypted' ? 'encrypted'
: 'none', : settings.knowledgeRerankCredential
? 'unreadable'
: 'none',
workspacePath: agent.workspacePath, workspacePath: agent.workspacePath,
apiKeyConfigured: Boolean(effective.apiKey), apiKeyConfigured: Boolean(effective.apiKey),
credentialSource: effective.credentialSource, credentialSource: effective.credentialSource,
@@ -1115,7 +1237,20 @@ export class RuntimeSettingsStore {
continueModelSource: settings.continueModelSource, continueModelSource: settings.continueModelSource,
secureStorageAvailable: this.cipher.isAvailable(), secureStorageAvailable: this.cipher.isAvailable(),
toolApproval: settings.toolApproval, toolApproval: settings.toolApproval,
warning: this.loadWarning configured: {
modelProfiles: configuredModelProfiles,
opencodeBaseUrl: settings.opencodeBaseUrl,
opencodeBinaryPath: settings.opencodeBinaryPath,
opencodeConfigPath: settings.opencodeConfigPath,
continueBinaryPath: settings.continueBinaryPath,
continueConfigPath: settings.continueConfigPath,
workspacePath: settings.workspacePath || homedir(),
opencodeModelSource: settings.opencodeModelSource,
continueModelSource: settings.continueModelSource
},
...(this.loadWarnings.length > 0
? { warnings: [...this.loadWarnings] }
: {})
} }
} }
@@ -1123,24 +1258,31 @@ export class RuntimeSettingsStore {
return this.toPublicSettings(await this.load()) return this.toPublicSettings(await this.load())
} }
async getPolicySettings(): Promise<RuntimePolicySettings> {
const settings = await this.load()
return {
subagentSmartRoutingEnabled:
settings.subagentSmartRoutingEnabled,
toolApproval: settings.toolApproval
}
}
async getResolvedSettings(): Promise<ResolvedRuntimeSettings> { async getResolvedSettings(): Promise<ResolvedRuntimeSettings> {
const settings = await this.load() const settings = await this.load()
const effective = this.resolveEffectiveModelSettings(settings) const effective = this.resolveEffectiveModelSettings(settings)
const agent = this.resolveAgentSettings(settings) const agent = this.resolveAgentSettings(settings)
const modelProfiles = this.resolveModelProfiles(settings, effective)
const profilesById = new Map(
modelProfiles.map((profile) => [profile.id, profile])
)
const opencodeModelProfile = const opencodeModelProfile =
!agent.opencodeBaseUrl && !agent.opencodeBaseUrl &&
settings.opencodeModelSource.kind === 'profile' settings.opencodeModelSource.kind === 'profile'
? this.resolveProfile( ? profilesById.get(settings.opencodeModelSource.profileId)
settings,
settings.opencodeModelSource.profileId
)
: undefined : undefined
const continueModelProfile = const continueModelProfile =
settings.continueModelSource.kind === 'profile' settings.continueModelSource.kind === 'profile'
? this.resolveProfile( ? profilesById.get(settings.continueModelSource.profileId)
settings,
settings.continueModelSource.profileId
)
: undefined : undefined
return { return {
provider: settings.provider, provider: settings.provider,
@@ -1151,13 +1293,7 @@ export class RuntimeSettingsStore {
supportsImageInput: effective.supportsImageInput, supportsImageInput: effective.supportsImageInput,
imageGenerationQuality: effective.imageGenerationQuality, imageGenerationQuality: effective.imageGenerationQuality,
apiKey: effective.apiKey, apiKey: effective.apiKey,
modelProfiles: settings.modelProfiles.map((profile) => { modelProfiles,
const resolved = this.resolveProfile(settings, profile.id)
if (!resolved) {
throw new Error(`${profile.id}`)
}
return resolved
}),
defaultModelProfileId: settings.defaultModelProfileId, defaultModelProfileId: settings.defaultModelProfileId,
opencodeModelProfile, opencodeModelProfile,
continueModelProfile, continueModelProfile,
@@ -1248,7 +1384,15 @@ export class RuntimeSettingsStore {
const existing = current.modelProfiles.find( const existing = current.modelProfiles.find(
(candidate) => candidate.id === profile.id (candidate) => candidate.id === profile.id
) )
const normalizedBaseUrl = normalizeModelBaseUrl(profile.baseUrl) const environmentManaged =
profile.id === current.defaultModelProfileId &&
profile.authentication === 'api-key' &&
profile.apiKey.action === 'keep' &&
existing !== undefined &&
Boolean(this.getEnvironmentApiKey())
const normalizedBaseUrl = normalizeModelBaseUrl(
environmentManaged ? existing.baseUrl : profile.baseUrl
)
if ( if (
profile.authentication === 'api-key' && profile.authentication === 'api-key' &&
profile.apiKey.action === 'keep' && profile.apiKey.action === 'keep' &&
@@ -1264,7 +1408,9 @@ export class RuntimeSettingsStore {
id: profile.id, id: profile.id,
name: profile.name, name: profile.name,
baseUrl: normalizedBaseUrl, baseUrl: normalizedBaseUrl,
modelName: profile.modelName, modelName: environmentManaged
? existing.modelName
: profile.modelName,
protocol: profile.protocol, protocol: profile.protocol,
authentication: profile.authentication, authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput ?? false, supportsImageInput: profile.supportsImageInput ?? false,
@@ -1280,19 +1426,14 @@ export class RuntimeSettingsStore {
profile.authentication === 'api-key' && profile.authentication === 'api-key' &&
profile.apiKey.action === 'replace' profile.apiKey.action === 'replace'
) { ) {
nextProfile.credential = { nextProfile.credential = encryptSettingsCredential(
formatVersion: 1, this.cipher,
scheme: 'electron-safe-storage', {
ciphertextBase64: this.cipher version: 1,
.encrypt( apiKey: profile.apiKey.value,
JSON.stringify({ origin: new URL(normalizedBaseUrl).origin
version: 1, }
apiKey: profile.apiKey.value, )
origin: new URL(normalizedBaseUrl).origin
})
)
.toString('base64')
}
} }
return nextProfile return nextProfile
}) })
@@ -1319,19 +1460,14 @@ export class RuntimeSettingsStore {
knowledgeEmbeddingCredential = knowledgeEmbeddingCredential =
current.knowledgeEmbeddingCredential current.knowledgeEmbeddingCredential
} else if (embeddingApiKeyUpdate.action === 'replace') { } else if (embeddingApiKeyUpdate.action === 'replace') {
knowledgeEmbeddingCredential = { knowledgeEmbeddingCredential = encryptSettingsCredential(
formatVersion: 1, this.cipher,
scheme: 'electron-safe-storage', {
ciphertextBase64: this.cipher version: 1,
.encrypt( apiKey: embeddingApiKeyUpdate.value,
JSON.stringify({ endpoint: embeddingEndpoint
version: 1, }
apiKey: embeddingApiKeyUpdate.value, )
endpoint: embeddingEndpoint
})
)
.toString('base64')
}
} }
const rerankEndpoint = new URL( const rerankEndpoint = new URL(
@@ -1355,19 +1491,14 @@ export class RuntimeSettingsStore {
) { ) {
knowledgeRerankCredential = current.knowledgeRerankCredential knowledgeRerankCredential = current.knowledgeRerankCredential
} else if (rerankApiKeyUpdate.action === 'replace') { } else if (rerankApiKeyUpdate.action === 'replace') {
knowledgeRerankCredential = { knowledgeRerankCredential = encryptSettingsCredential(
formatVersion: 1, this.cipher,
scheme: 'electron-safe-storage', {
ciphertextBase64: this.cipher version: 1,
.encrypt( apiKey: rerankApiKeyUpdate.value,
JSON.stringify({ endpoint: rerankEndpoint
version: 1, }
apiKey: rerankApiKeyUpdate.value, )
endpoint: rerankEndpoint
})
)
.toString('base64')
}
} }
const [ const [
@@ -1490,19 +1621,9 @@ export class RuntimeSettingsStore {
toolApproval: input.toolApproval toolApproval: input.toolApproval
} }
await mkdir(dirname(this.filePath), { recursive: true }) await writeJsonFileAtomically(this.filePath, next)
const temporaryPath = `${this.filePath}.${process.pid}.tmp`
try {
await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600
})
await rename(temporaryPath, this.filePath)
} finally {
await rm(temporaryPath, { force: true })
}
this.settings = next this.settings = next
this.loadWarning = undefined this.loadWarnings = []
return this.toPublicSettings(next) return this.toPublicSettings(next)
} }
+41
View File
@@ -0,0 +1,41 @@
import { z } from 'zod'
export interface SettingsCredentialCipher {
isAvailable(): boolean
encrypt(value: string): Buffer
decrypt(value: Buffer): string
}
export const encryptedSettingsCredentialSchema = z.object({
formatVersion: z.literal(1),
scheme: z.literal('electron-safe-storage'),
ciphertextBase64: z.string()
})
export type EncryptedSettingsCredential = z.infer<
typeof encryptedSettingsCredentialSchema
>
export function encryptSettingsCredential(
cipher: SettingsCredentialCipher,
payload: unknown
): EncryptedSettingsCredential {
return {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: cipher
.encrypt(JSON.stringify(payload))
.toString('base64')
}
}
export function decryptSettingsCredential(
cipher: SettingsCredentialCipher,
credential: EncryptedSettingsCredential
): unknown {
return JSON.parse(
cipher.decrypt(
Buffer.from(credential.ciphertextBase64, 'base64')
)
) as unknown
}
+99
View File
@@ -0,0 +1,99 @@
import { randomBytes } from 'node:crypto'
import {
mkdir,
rename,
rm,
writeFile
} from 'node:fs/promises'
import { dirname } from 'node:path'
export interface SettingsFileOperations {
rename: typeof rename
writeFile: typeof writeFile
}
export class UnsupportedSettingsVersionError extends Error {}
const defaultSettingsFileOperations: SettingsFileOperations = {
rename,
writeFile
}
function resolveSettingsFileOperations(
operations?: Partial<SettingsFileOperations>
): SettingsFileOperations {
return {
...defaultSettingsFileOperations,
...operations
}
}
export function isMissingFileError(error: unknown): boolean {
return (
error !== null &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
}
export function assertSupportedSettingsVersion(
value: unknown,
currentVersion: number,
message: (version: number) => string
): void {
if (
value !== null &&
typeof value === 'object' &&
'version' in value &&
typeof value.version === 'number' &&
value.version > currentVersion
) {
throw new UnsupportedSettingsVersionError(message(value.version))
}
}
export async function isolateCorruptSettingsFile(
filePath: string,
failureMessage: string,
now: () => number = Date.now,
operations?: Partial<SettingsFileOperations>
): Promise<void> {
const fileOperations = resolveSettingsFileOperations(operations)
const isolatedPath =
`${filePath}.corrupt-${now()}-` +
randomBytes(6).toString('hex')
try {
await fileOperations.rename(filePath, isolatedPath)
} catch (error) {
if (!isMissingFileError(error)) {
throw new Error(failureMessage, { cause: error })
}
}
}
export async function writeJsonFileAtomically(
filePath: string,
value: unknown,
operations?: Partial<SettingsFileOperations>
): Promise<void> {
const fileOperations = resolveSettingsFileOperations(operations)
await mkdir(dirname(filePath), { recursive: true })
const temporaryPath =
`${filePath}.${process.pid}.` +
`${randomBytes(6).toString('hex')}.tmp`
try {
await fileOperations.writeFile(
temporaryPath,
`${JSON.stringify(value, null, 2)}\n`,
{
encoding: 'utf8',
mode: 0o600,
flag: 'wx'
}
)
await fileOperations.rename(temporaryPath, filePath)
} finally {
await rm(temporaryPath, { force: true })
}
}
+50 -1
View File
@@ -1,5 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { waitForCleanup } from './shutdown' import {
runCleanupBeforeDeadline,
settleCleanupPhases,
waitForCleanup
} from './shutdown'
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
@@ -23,4 +27,49 @@ describe('waitForCleanup', () => {
await expect(result).resolves.toBe(false) await expect(result).resolves.toBe(false)
}) })
it('runs dependent cleanup phases in order despite failures', async () => {
const order: string[] = []
await settleCleanupPhases([
[
async () => {
order.push('ipc')
throw new Error('cleanup failed')
}
],
[
async () => {
order.push('gateway')
}
],
[
async () => {
order.push('knowledge')
}
]
])
expect(order).toEqual(['ipc', 'gateway', 'knowledge'])
})
it('finalizes databases only after cleanup beats the deadline', async () => {
const finalize = vi.fn()
await expect(
runCleanupBeforeDeadline(Promise.resolve(), 100, finalize)
).resolves.toBe(true)
expect(finalize).toHaveBeenCalledOnce()
vi.useFakeTimers()
const timedOutFinalize = vi.fn()
const result = runCleanupBeforeDeadline(
new Promise(() => {}),
100,
timedOutFinalize
)
await vi.advanceTimersByTimeAsync(100)
await expect(result).resolves.toBe(false)
expect(timedOutFinalize).not.toHaveBeenCalled()
})
}) })
+24
View File
@@ -16,3 +16,27 @@ export async function waitForCleanup(
} }
return completed return completed
} }
export type CleanupOperation = () => unknown | Promise<unknown>
export async function settleCleanupPhases(
phases: readonly (readonly CleanupOperation[])[]
): Promise<void> {
for (const phase of phases) {
await Promise.allSettled(
phase.map((operation) => Promise.resolve().then(operation))
)
}
}
export async function runCleanupBeforeDeadline(
cleanup: Promise<unknown>,
timeoutMs: number,
finalize: () => unknown | Promise<unknown>
): Promise<boolean> {
const completed = await waitForCleanup(cleanup, timeoutMs)
if (completed) {
await finalize()
}
return completed
}
+13 -10
View File
@@ -740,6 +740,8 @@ describe('App', () => {
expect( expect(
screen.getByRole('button', { name: 'Chat' }) screen.getByRole('button', { name: 'Chat' })
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByText('Desktop workspace')).toBeInTheDocument()
expect(screen.getByText('GOODBUDDY WORKSPACE')).toBeInTheDocument()
expect( expect(
screen.getByRole('heading', { screen.getByRole('heading', {
name: 'What would you like to accomplish today?' name: 'What would you like to accomplish today?'
@@ -760,6 +762,13 @@ describe('App', () => {
} }
}) })
it('renders localized workspace branding in Chinese', async () => {
render(<App />)
expect(await screen.findByText('桌面工作区')).toBeInTheDocument()
expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument()
})
it('keeps Settings open when the interface language changes', async () => { it('keeps Settings open when the interface language changes', async () => {
api.updates = { api.updates = {
getSettings: vi.fn(async () => ({ getSettings: vi.fn(async () => ({
@@ -3278,7 +3287,7 @@ describe('App', () => {
) )
}) })
it('normalizes a legacy Auto conversation to the explicit default Runtime', async () => { it('preserves a legacy Auto conversation without silently persisting a replacement', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([ vi.mocked(api.conversations.list).mockResolvedValueOnce([
{ {
id: '00000000-0000-4000-8000-000000000020', id: '00000000-0000-4000-8000-000000000020',
@@ -3306,17 +3315,14 @@ describe('App', () => {
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: '00000000-0000-4000-8000-000000000020', id: '00000000-0000-4000-8000-000000000020',
runtimeSelection: { runtimeSelection: { provider: 'auto' }
provider: 'model',
profileId: modelProfileId
}
}) })
]) ])
) )
) )
}) })
it('rebinds a loaded conversation when its model profile was removed', async () => { it('keeps a removed model selection visible until the user replaces it', async () => {
const removedProfileId = const removedProfileId =
'00000000-0000-4000-8000-000000000099' '00000000-0000-4000-8000-000000000099'
vi.mocked(api.conversations.list).mockResolvedValueOnce([ vi.mocked(api.conversations.list).mockResolvedValueOnce([
@@ -3341,9 +3347,6 @@ describe('App', () => {
]) ])
render(<App />) render(<App />)
expect(
await screen.findByRole('button', { name: /.*sonnet-5/u })
).toBeInTheDocument()
await waitFor(() => await waitFor(() =>
expect(api.conversations.replace).toHaveBeenLastCalledWith( expect(api.conversations.replace).toHaveBeenLastCalledWith(
expect.arrayContaining([ expect.arrayContaining([
@@ -3351,7 +3354,7 @@ describe('App', () => {
id: '00000000-0000-4000-8000-000000000022', id: '00000000-0000-4000-8000-000000000022',
runtimeSelection: { runtimeSelection: {
provider: 'model', provider: 'model',
profileId: modelProfileId profileId: removedProfileId
} }
}) })
]) ])
+31 -106
View File
@@ -65,9 +65,12 @@ import { maximumPastedImageBytes } from '../../shared/contracts'
import { import {
agentRuntimeSelectionKey, agentRuntimeSelectionKey,
agentRuntimeSelectionSchema, agentRuntimeSelectionSchema,
repairAgentRuntimeSelection,
type AgentRuntimeSelection type AgentRuntimeSelection
} from '../../shared/runtime-selection-contracts' } from '../../shared/runtime-selection-contracts'
import {
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider
} from './runtime-selection'
import type { import type {
AssistantProject, AssistantProject,
AssistantArtifact, AssistantArtifact,
@@ -802,45 +805,6 @@ function mergeArtifacts(
) )
} }
function getDefaultRuntimeSelection(
settings: RuntimeSettings
): AgentRuntimeSelection {
if (settings.provider === 'model') {
return {
provider: 'model',
profileId: settings.defaultModelProfileId
}
}
if (settings.provider === 'opencode') {
return {
provider: 'opencode',
...(settings.opencodeModelSource.kind === 'profile'
? { profileId: settings.opencodeModelSource.profileId }
: {})
}
}
if (settings.provider === 'continue') {
return {
provider: 'continue',
...(settings.continueModelSource.kind === 'profile'
? { profileId: settings.continueModelSource.profileId }
: {})
}
}
if (settings.opencodeBaseUrl || settings.opencodeEmbedded) {
return {
provider: 'opencode',
...(settings.opencodeModelSource.kind === 'profile'
? { profileId: settings.opencodeModelSource.profileId }
: {})
}
}
return {
provider: 'model',
profileId: settings.defaultModelProfileId
}
}
function getProjectDefaultRuntimeSelection( function getProjectDefaultRuntimeSelection(
project: AssistantProject | undefined, project: AssistantProject | undefined,
settings: RuntimeSettings settings: RuntimeSettings
@@ -848,7 +812,7 @@ function getProjectDefaultRuntimeSelection(
const selection = project?.runtimeSelection const selection = project?.runtimeSelection
return !selection || selection.provider === 'auto' return !selection || selection.provider === 'auto'
? getDefaultRuntimeSelection(settings) ? getDefaultRuntimeSelection(settings)
: repairAgentRuntimeSelection(selection, settings) : selection
} }
function getRuntimeSelectionLabel( function getRuntimeSelectionLabel(
@@ -859,6 +823,7 @@ function getRuntimeSelectionLabel(
directModel: string directModel: string
automatic: string automatic: string
automaticSelection: string automaticSelection: string
modelUnavailable: string
} }
): string { ): string {
if (!selection || !settings) { if (!selection || !settings) {
@@ -870,36 +835,36 @@ function getRuntimeSelectionLabel(
(candidate) => candidate.id === selection.profileId (candidate) => candidate.id === selection.profileId
) )
: undefined : undefined
const requestedProfileMissing =
'profileId' in selection &&
Boolean(selection.profileId) &&
profile === undefined
if (selection.provider === 'model') { if (selection.provider === 'model') {
return profile return profile
? `${profile.name} · ${profile.modelName}` ? `${profile.name} · ${profile.modelName}`
: status?.label ?? labels.directModel : requestedProfileMissing
? labels.modelUnavailable
: status?.label ?? labels.directModel
} }
if (selection.provider === 'opencode') { if (selection.provider === 'opencode') {
return profile ? `OpenCode · ${profile.name}` : 'OpenCode' return profile
? `OpenCode · ${profile.name}`
: requestedProfileMissing
? `OpenCode · ${labels.modelUnavailable}`
: 'OpenCode'
} }
if (selection.provider === 'continue') { if (selection.provider === 'continue') {
return profile ? `Continue · ${profile.name}` : 'Continue' return profile
? `Continue · ${profile.name}`
: requestedProfileMissing
? `Continue · ${labels.modelUnavailable}`
: 'Continue'
} }
return status return status
? `${labels.automatic} · ${status.label}` ? `${labels.automatic} · ${status.label}`
: labels.automaticSelection : labels.automaticSelection
} }
function getConfiguredAgentRuntimeSelection(
settings: RuntimeSettings,
provider: 'opencode' | 'continue'
): AgentRuntimeSelection {
const source =
provider === 'opencode'
? settings.opencodeModelSource
: settings.continueModelSource
return {
provider,
...(source.kind === 'profile' ? { profileId: source.profileId } : {})
}
}
function getConfiguredAgentRuntimeSource( function getConfiguredAgentRuntimeSource(
settings: RuntimeSettings, settings: RuntimeSettings,
provider: 'opencode' | 'continue', provider: 'opencode' | 'continue',
@@ -910,7 +875,7 @@ function getConfiguredAgentRuntimeSource(
useOwnConfiguration: (runtime: string) => string useOwnConfiguration: (runtime: string) => string
} }
): { label: string; detail: string } { ): { label: string; detail: string } {
const selection = getConfiguredAgentRuntimeSelection(settings, provider) const selection = getRuntimeSelectionForProvider(provider, settings)
const profile = const profile =
'profileId' in selection 'profileId' in selection
? settings.modelProfiles.find( ? settings.modelProfiles.find(
@@ -1766,7 +1731,8 @@ function App(): React.JSX.Element {
() => ({ () => ({
directModel: t('runtime.directModel'), directModel: t('runtime.directModel'),
automatic: t('runtime.automatic'), automatic: t('runtime.automatic'),
automaticSelection: t('runtime.automaticSelection') automaticSelection: t('runtime.automaticSelection'),
modelUnavailable: t('runtime.modelUnavailable')
}), }),
[t] [t]
) )
@@ -1787,10 +1753,10 @@ function App(): React.JSX.Element {
runtimeLabels runtimeLabels
) )
const openCodeMenuSelection = runtimeSettings const openCodeMenuSelection = runtimeSettings
? getConfiguredAgentRuntimeSelection(runtimeSettings, 'opencode') ? getRuntimeSelectionForProvider('opencode', runtimeSettings)
: undefined : undefined
const continueMenuSelection = runtimeSettings const continueMenuSelection = runtimeSettings
? getConfiguredAgentRuntimeSelection(runtimeSettings, 'continue') ? getRuntimeSelectionForProvider('continue', runtimeSettings)
: undefined : undefined
const openCodeMenuSource = runtimeSettings const openCodeMenuSource = runtimeSettings
? getConfiguredAgentRuntimeSource( ? getConfiguredAgentRuntimeSource(
@@ -1866,48 +1832,6 @@ function App(): React.JSX.Element {
} }
}, [activeId, conversations]) }, [activeId, conversations])
useEffect(() => {
if (!runtimeSettings || !conversationStoreReady) {
return
}
const timeout = setTimeout(() => {
setConversations((current) => {
let changed = false
const next = current.map((conversation) => {
const project = projects.find(
(candidate) => candidate.id === conversation.projectId
)
const defaultSelection = getProjectDefaultRuntimeSelection(
project,
runtimeSettings
)
const selection =
!conversation.runtimeSelection ||
conversation.runtimeSelection.provider === 'auto'
? defaultSelection
: repairAgentRuntimeSelection(
conversation.runtimeSelection,
runtimeSettings
)
if (
conversation.runtimeSelection &&
agentRuntimeSelectionKey(conversation.runtimeSelection) ===
agentRuntimeSelectionKey(selection)
) {
return conversation
}
changed = true
return {
...conversation,
runtimeSelection: selection
}
})
return changed ? next : current
})
}, 0)
return () => clearTimeout(timeout)
}, [conversationStoreReady, projects, runtimeSettings])
useEffect(() => { useEffect(() => {
const selection = activeRuntimeSelectionRef.current const selection = activeRuntimeSelectionRef.current
if (!selection || !runtimeSettings) { if (!selection || !runtimeSettings) {
@@ -4762,7 +4686,7 @@ function App(): React.JSX.Element {
</div> </div>
<div className="brand__copy"> <div className="brand__copy">
<strong>GoodBuddy</strong> <strong>GoodBuddy</strong>
<span>Desktop workspace</span> <span>{t('brand.desktopWorkspace')}</span>
</div> </div>
</div> </div>
@@ -5299,7 +5223,7 @@ function App(): React.JSX.Element {
<div className="welcome__badge"> <div className="welcome__badge">
<Sparkles size={18} /> <Sparkles size={18} />
</div> </div>
<p className="eyebrow">GOODBUDDY WORKSPACE</p> <p className="eyebrow">{t('chat.welcome.eyebrow')}</p>
<h1>{t('chat.welcome.title')}</h1> <h1>{t('chat.welcome.title')}</h1>
<p className="welcome__description"> <p className="welcome__description">
{t('chat.welcome.description')} {t('chat.welcome.description')}
@@ -6849,6 +6773,7 @@ function App(): React.JSX.Element {
<SettingsPanel <SettingsPanel
appearanceTheme={appearanceTheme} appearanceTheme={appearanceTheme}
heartbeats={assistantHeartbeats} heartbeats={assistantHeartbeats}
magicNotesEnabled={magicNotesEnabled}
onAppearanceThemeChange={setAppearanceTheme} onAppearanceThemeChange={setAppearanceTheme}
onClearLocalData={clearLocalData} onClearLocalData={clearLocalData}
onClose={() => setView('chat')} onClose={() => setView('chat')}
+7 -2
View File
@@ -35,7 +35,10 @@ import type { WeixinBindingSnapshot } from '../../shared/weixin-channel-contract
import type { AppNotificationInput } from './notifications' import type { AppNotificationInput } from './notifications'
import { trapTabFocus } from './dialog-focus' import { trapTabFocus } from './dialog-focus'
import { PageTabs, SegmentedControl } from './WorkspacePrimitives' import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives' import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
type ChannelDraft = { type ChannelDraft = {
enabled: boolean enabled: boolean
@@ -455,6 +458,8 @@ function ChannelEditor({
<small> <small>
{settings.source === 'environment' {settings.source === 'environment'
? t('channels.credential.environmentSource') ? t('channels.credential.environmentSource')
: settings.source === 'unreadable'
? t('channels.credential.secretUnreadable')
: settings.secretConfigured : settings.secretConfigured
? t('channels.credential.secretSaved') ? t('channels.credential.secretSaved')
: t('channels.credential.secretMissing')} : t('channels.credential.secretMissing')}
@@ -1327,7 +1332,7 @@ export function ChannelSettingsSection({
aria-label={t('channels.sectionAriaLabel')} aria-label={t('channels.sectionAriaLabel')}
className="settings-section channel-settings" className="settings-section channel-settings"
> >
{snapshot.warning && <p className="settings-warning">{snapshot.warning}</p>} <SettingsWarningList warnings={snapshot.warnings} />
<div className="channel-settings__tabs"> <div className="channel-settings__tabs">
<PageTabs <PageTabs
@@ -199,6 +199,36 @@ describe('DocumentParsingSettingsSection', () => {
afterEach(() => cleanup()) afterEach(() => cleanup())
it.each([
['zh-CN', '正在加载…'],
['en-US', 'Loading…']
] as const)('localizes the loading state in %s', async (locale, label) => {
await changeUiLocale(locale)
getSnapshot.mockImplementationOnce(
() => new Promise(() => undefined)
)
render(<DocumentParsingSettingsSection />)
expect(screen.getByText(label)).toBeInTheDocument()
})
it('localizes recovered document parsing settings warnings', async () => {
await changeUiLocale('en-US')
getSnapshot.mockResolvedValueOnce({
...snapshot,
warnings: [{ code: 'document-parsing-settings-recovered' }]
})
render(<DocumentParsingSettingsSection />)
expect(
await screen.findByText(
/The document parsing settings file was corrupt/u
)
).toBeInTheDocument()
})
it('shows actual capability status and saves workflow settings', async () => { it('shows actual capability status and saves workflow settings', async () => {
const onNotify = vi.fn() const onNotify = vi.fn()
render( render(
@@ -29,7 +29,10 @@ import type {
DocumentParsingTestPurpose DocumentParsingTestPurpose
} from '../../shared/document-parsing-contracts' } from '../../shared/document-parsing-contracts'
import type { AppNotificationInput } from './notifications' import type { AppNotificationInput } from './notifications'
import { SettingsCategoryHeader } from './SettingsPrimitives' import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
type DocumentParsingSettingsSectionProps = { type DocumentParsingSettingsSectionProps = {
onNotify?: (notification: AppNotificationInput) => void onNotify?: (notification: AppNotificationInput) => void
@@ -402,7 +405,9 @@ export function DocumentParsingSettingsSection({
error={error ?? unavailableError} error={error ?? unavailableError}
/> />
{!error && !unavailableError && ( {!error && !unavailableError && (
<p className="settings-empty">Loading</p> <p className="settings-empty">
{t('documentParsing.loading')}
</p>
)} )}
</> </>
) )
@@ -453,6 +458,7 @@ export function DocumentParsingSettingsSection({
category="document-parsing" category="document-parsing"
error={error} error={error}
/> />
<SettingsWarningList warnings={snapshot.warnings} />
{settingsDirty && ( {settingsDirty && (
<p <p
className="settings-notice" className="settings-notice"
+10 -17
View File
@@ -31,7 +31,10 @@ import type {
WebSearchTestResult WebSearchTestResult
} from '../../shared/capability-contracts' } from '../../shared/capability-contracts'
import { trapTabFocus } from './dialog-focus' import { trapTabFocus } from './dialog-focus'
import { SettingsCategoryHeader } from './SettingsPrimitives' import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
import { PageTabs } from './WorkspacePrimitives' import { PageTabs } from './WorkspacePrimitives'
const configurableMcpTargets: RuntimeTarget[] = ['model'] const configurableMcpTargets: RuntimeTarget[] = ['model']
@@ -85,7 +88,11 @@ function editorFromServer(server: McpServerSummary): McpEditor {
} }
} }
export function McpSettingsSection(): React.JSX.Element { export function McpSettingsSection({
magicNotesEnabled = false
}: {
magicNotesEnabled?: boolean
}): React.JSX.Element {
const { t } = useTranslation('integrations') const { t } = useTranslation('integrations')
const tRef = useRef(t) const tRef = useRef(t)
useEffect(() => { useEffect(() => {
@@ -106,7 +113,6 @@ export function McpSettingsSection(): React.JSX.Element {
disabled: t('mcp.diagnosticStatuses.disabled') disabled: t('mcp.diagnosticStatuses.disabled')
} }
const [snapshot, setSnapshot] = useState<CapabilitySnapshot>() const [snapshot, setSnapshot] = useState<CapabilitySnapshot>()
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
const [editor, setEditor] = useState<McpEditor>() const [editor, setEditor] = useState<McpEditor>()
const [busy, setBusy] = useState<string>() const [busy, setBusy] = useState<string>()
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
@@ -158,20 +164,6 @@ export function McpSettingsSection(): React.JSX.Element {
}) })
}, []) }, [])
useEffect(() => {
const getSettings = window.goodbuddy.updates?.getSettings
if (!getSettings) {
return
}
void getSettings()
.then((settings) => {
setMagicNotesEnabled(settings.magicNotesEnabled)
})
.catch(() => {
setMagicNotesEnabled(false)
})
}, [])
useEffect(() => { useEffect(() => {
if (!editorOpen) { if (!editorOpen) {
return return
@@ -417,6 +409,7 @@ export function McpSettingsSection(): React.JSX.Element {
error={!editor ? error : undefined} error={!editor ? error : undefined}
headingId="mcp-settings-heading" headingId="mcp-settings-heading"
/> />
<SettingsWarningList warnings={snapshot?.warnings} />
<PageTabs <PageTabs
ariaLabel={t('mcp.tabs.ariaLabel')} ariaLabel={t('mcp.tabs.ariaLabel')}
idPrefix="mcp-settings" idPrefix="mcp-settings"
@@ -6,7 +6,10 @@ import type {
} from '../../shared/application-settings-contracts' } from '../../shared/application-settings-contracts'
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts' import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
import { SegmentedControl } from './WorkspacePrimitives' import { SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives' import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
type PlatformFeaturesSettingsSectionProps = { type PlatformFeaturesSettingsSectionProps = {
onMagicNotesEnabledChange: (enabled: boolean) => void onMagicNotesEnabledChange: (enabled: boolean) => void
@@ -116,6 +119,7 @@ export function PlatformFeaturesSettingsSection({
error={error} error={error}
headingId="platform-features-heading" headingId="platform-features-heading"
/> />
<SettingsWarningList warnings={settings?.warnings} />
<section <section
aria-label={t('platformFeatures.label')} aria-label={t('platformFeatures.label')}
className="settings-section" className="settings-section"
+9 -43
View File
@@ -18,8 +18,11 @@ import {
normalizeInteractiveWorkMode normalizeInteractiveWorkMode
} from '../../shared/assistant-contracts' } from '../../shared/assistant-contracts'
import type { RuntimeSettings } from '../../shared/contracts' import type { RuntimeSettings } from '../../shared/contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import { trapTabFocus } from './dialog-focus' import { trapTabFocus } from './dialog-focus'
import {
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider
} from './runtime-selection'
type ProjectSwitcherProps = { type ProjectSwitcherProps = {
projects: AssistantProject[] projects: AssistantProject[]
@@ -36,43 +39,6 @@ type ProjectSwitcherProps = {
) => Promise<AssistantProject> ) => Promise<AssistantProject>
} }
function runtimeSelectionForProvider(
provider: 'model' | 'opencode' | 'continue',
settings: RuntimeSettings
): AgentRuntimeSelection {
if (provider === 'model') {
return {
provider,
profileId: settings.defaultModelProfileId
}
}
const source =
provider === 'opencode'
? settings.opencodeModelSource
: settings.continueModelSource
return {
provider,
...(source.kind === 'profile' ? { profileId: source.profileId } : {})
}
}
function defaultRuntimeSelection(
settings: RuntimeSettings
): AgentRuntimeSelection {
if (settings.provider === 'model') {
return runtimeSelectionForProvider('model', settings)
}
if (settings.provider === 'opencode') {
return runtimeSelectionForProvider('opencode', settings)
}
if (settings.provider === 'continue') {
return runtimeSelectionForProvider('continue', settings)
}
return settings.opencodeBaseUrl || settings.opencodeEmbedded
? runtimeSelectionForProvider('opencode', settings)
: runtimeSelectionForProvider('model', settings)
}
export function ProjectSwitcher({ export function ProjectSwitcher({
projects, projects,
activeProjectId, activeProjectId,
@@ -157,7 +123,7 @@ export function ProjectSwitcher({
? draft ? draft
: { : {
...draft, ...draft,
runtimeSelection: defaultRuntimeSelection(runtimeSettings) runtimeSelection: getDefaultRuntimeSelection(runtimeSettings)
} }
if (dialogMode === 'settings' && activeProject) { if (dialogMode === 'settings' && activeProject) {
await onUpdate(activeProject.id, input) await onUpdate(activeProject.id, input)
@@ -276,7 +242,7 @@ export function ProjectSwitcher({
rootPath: '', rootPath: '',
defaultWorkMode: 'ask', defaultWorkMode: 'ask',
runtimeSelection: runtimeSettings runtimeSelection: runtimeSettings
? defaultRuntimeSelection(runtimeSettings) ? getDefaultRuntimeSelection(runtimeSettings)
: undefined : undefined
}) })
restoreFocusTarget.current = 'create' restoreFocusTarget.current = 'create'
@@ -308,7 +274,7 @@ export function ProjectSwitcher({
runtimeSelection: runtimeSelection:
activeProject.runtimeSelection ?? activeProject.runtimeSelection ??
(runtimeSettings (runtimeSettings
? defaultRuntimeSelection(runtimeSettings) ? getDefaultRuntimeSelection(runtimeSettings)
: undefined) : undefined)
}) })
restoreFocusTarget.current = 'settings' restoreFocusTarget.current = 'settings'
@@ -441,7 +407,7 @@ export function ProjectSwitcher({
onChange={(event) => onChange={(event) =>
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
runtimeSelection: runtimeSelectionForProvider( runtimeSelection: getRuntimeSelectionForProvider(
event.target.value as event.target.value as
| 'model' | 'model'
| 'opencode' | 'opencode'
@@ -454,7 +420,7 @@ export function ProjectSwitcher({
draft.runtimeSelection?.provider === 'auto' draft.runtimeSelection?.provider === 'auto'
? 'model' ? 'model'
: (draft.runtimeSelection?.provider ?? : (draft.runtimeSelection?.provider ??
defaultRuntimeSelection(runtimeSettings) getDefaultRuntimeSelection(runtimeSettings)
.provider) .provider)
} }
> >
+213 -1
View File
@@ -7,6 +7,7 @@ import {
waitFor, waitFor,
within within
} from '@testing-library/react' } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssistantExpert } from '../../shared/assistant-contracts' import type { AssistantExpert } from '../../shared/assistant-contracts'
import type { import type {
@@ -569,6 +570,31 @@ describe('SettingsPanel runtime files', () => {
screen.getByRole('radio', { name: /Use system language/u }) screen.getByRole('radio', { name: /Use system language/u })
).toBeInTheDocument() ).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: 'Model connections' })
)
expect(
await screen.findByRole('button', {
name: 'Edit model connection Default model'
})
).toBeInTheDocument()
expect(screen.getByLabelText('Name')).toHaveValue('Default model')
fireEvent.click(
screen.getByRole('button', { name: 'Save settings' })
)
await waitFor(() =>
expect(updateRuntime).toHaveBeenLastCalledWith(
expect.objectContaining({
modelProfiles: expect.arrayContaining([
expect.objectContaining({
id: modelProfileId,
name: '默认模型'
})
])
})
)
)
fireEvent.click( fireEvent.click(
screen.getByRole('tab', { name: 'Agent Runtime' }) screen.getByRole('tab', { name: 'Agent Runtime' })
) )
@@ -590,6 +616,69 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument() ).toBeInTheDocument()
}) })
it('does not translate user-defined model connection names', async () => {
const userProfileId = '00000000-0000-4000-8000-000000000099'
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
name: 'My renamed model'
},
{
...runtimeSettings.modelProfiles[0]!,
id: userProfileId,
name: '默认模型'
}
]
})
await changeUiLocale('en-US')
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(
screen.getByRole('tab', { name: 'Model connections' })
)
expect(
await screen.findByRole('button', {
name: 'Edit model connection My renamed model'
})
).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: 'Edit model connection 默认模型'
})
).toBeInTheDocument()
})
it('localizes structured Runtime recovery warnings', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
warnings: [{ code: 'runtime-settings-recovered' }]
})
await changeUiLocale('en-US')
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
expect(
await screen.findByText(/The Runtime settings file was corrupt/u)
).toBeInTheDocument()
})
it('toggles the Magic Notes platform entry setting', async () => { it('toggles the Magic Notes platform entry setting', async () => {
const onMagicNotesEnabledChange = vi.fn() const onMagicNotesEnabledChange = vi.fn()
render( render(
@@ -617,7 +706,6 @@ describe('SettingsPanel runtime files', () => {
}) })
) )
expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true) expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
fireEvent.click( fireEvent.click(
screen.getByRole('button', { name: '保存后自动' }) screen.getByRole('button', { name: '保存后自动' })
) )
@@ -638,6 +726,59 @@ describe('SettingsPanel runtime files', () => {
) )
}) })
it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
function Harness(): React.JSX.Element {
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
return (
<SettingsPanel
{...heartbeatSettingsProps}
magicNotesEnabled={magicNotesEnabled}
onMagicNotesEnabledChange={setMagicNotesEnabled}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
}
render(
<Harness />
)
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
const noteServerToggle = await screen.findByRole('button', {
name: '展开服务器 笔记'
})
expect(noteServerToggle.closest('article')).toHaveClass(
'mcp-server-card--disabled'
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
await screen.findByRole('switch', {
name: '显示魔法笔记入口'
})
)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNotesEnabled: true
})
)
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
await waitFor(() =>
expect(
screen
.getByRole('button', { name: '展开服务器 笔记' })
.closest('article')
).not.toHaveClass('mcp-server-card--disabled')
)
expect(
screen.getByText('内置 MCP Server · 按模式读写 · 按对话授权')
).toBeInTheDocument()
})
it('keeps page navigation beside an independently scrollable panel', () => { it('keeps page navigation beside an independently scrollable panel', () => {
render( render(
<SettingsPanel <SettingsPanel
@@ -759,6 +900,77 @@ describe('SettingsPanel runtime files', () => {
expect(screen.queryByText('设置已保存')).not.toBeInTheDocument() expect(screen.queryByText('设置已保存')).not.toBeInTheDocument()
}) })
it('submits configured model values while environment values are effective', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelBaseUrl: 'https://environment.example/v1',
modelName: 'environment-model',
apiKeyConfigured: true,
credentialSource: 'environment',
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
baseUrl: 'https://environment.example/v1',
modelName: 'environment-model',
apiKeyConfigured: true,
credentialSource: 'environment'
}
],
configured: {
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
baseUrl: 'https://stored.example/v1',
modelName: 'stored-model',
apiKeyConfigured: true,
credentialSource: 'environment'
}
],
opencodeBaseUrl: '',
opencodeBinaryPath: '',
opencodeConfigPath: '',
continueBinaryPath: '',
continueConfigPath: '',
workspacePath: 'C:\\Workspace',
opencodeModelSource: runtimeSettings.opencodeModelSource,
continueModelSource: runtimeSettings.continueModelSource
}
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByDisplayValue('C:\\Workspace')
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
expect(
await screen.findByDisplayValue('https://environment.example/v1')
).toBeDisabled()
expect(screen.getByDisplayValue('environment-model')).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelBaseUrl: 'https://stored.example/v1',
modelName: 'stored-model',
modelProfiles: [
expect.objectContaining({
baseUrl: 'https://stored.example/v1',
modelName: 'stored-model',
apiKey: { action: 'keep' }
})
]
})
)
)
})
it('applies a speech model draft only when Settings is saved', async () => { it('applies a speech model draft only when Settings is saved', async () => {
render( render(
<SettingsPanel <SettingsPanel
+259 -138
View File
@@ -9,7 +9,7 @@ import {
Trash2, Trash2,
X X
} from 'lucide-react' } from 'lucide-react'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { import type {
AssistantExpert, AssistantExpert,
@@ -26,6 +26,7 @@ import type {
} from '../../shared/contracts' } from '../../shared/contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts' import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import { import {
defaultModelProfileId as builtInDefaultModelProfileId,
defaultRuntimeSettings, defaultRuntimeSettings,
isAgentRuntimeModelProtocol isAgentRuntimeModelProtocol
} from '../../shared/contracts' } from '../../shared/contracts'
@@ -40,7 +41,10 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection' import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection' import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { PageHeader, SegmentedControl } from './WorkspacePrimitives' import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives' import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
import { import {
settingsCategoryList, settingsCategoryList,
type SettingsCategoryId type SettingsCategoryId
@@ -81,6 +85,7 @@ type SettingsPanelProps = {
onRunHeartbeat: (heartbeatId: string) => Promise<void> onRunHeartbeat: (heartbeatId: string) => Promise<void>
appearanceTheme?: AppearanceTheme appearanceTheme?: AppearanceTheme
onAppearanceThemeChange?: (theme: AppearanceTheme) => void onAppearanceThemeChange?: (theme: AppearanceTheme) => void
magicNotesEnabled?: boolean
onMagicNotesEnabledChange?: (enabled: boolean) => void onMagicNotesEnabledChange?: (enabled: boolean) => void
} }
@@ -120,6 +125,118 @@ function toModelProfileDrafts(
})) }))
} }
function configuredRuntimeSettings(
settings: RuntimeSettings
): NonNullable<RuntimeSettings['configured']> {
return settings.configured ?? {
modelProfiles: settings.modelProfiles,
opencodeBaseUrl: settings.opencodeBaseUrl,
opencodeBinaryPath: settings.opencodeBinaryPath,
opencodeConfigPath: settings.opencodeConfigPath,
continueBinaryPath: settings.continueBinaryPath,
continueConfigPath: settings.continueConfigPath,
workspacePath: settings.workspacePath,
opencodeModelSource: settings.opencodeModelSource,
continueModelSource: settings.continueModelSource
}
}
type RuntimeDraftSelection =
| string
| ((selectedId: string) => string)
function hydrateRuntimeSettings(
value: RuntimeSettings,
setters: {
settings: (value: RuntimeSettings) => void
provider: (value: RuntimeSettings['provider']) => void
modelProfiles: (value: ModelProfileDraft[]) => void
selectedModelProfileId: (value: RuntimeDraftSelection) => void
defaultModelProfileId: (value: string) => void
opencodeModelSource: (value: RuntimeModelSource) => void
continueModelSource: (value: RuntimeModelSource) => void
opencodeBaseUrl: (value: string) => void
opencodeBinaryPath: (value: string) => void
opencodeConfigPath: (value: string) => void
continueBinaryPath: (value: string) => void
continueConfigPath: (value: string) => void
continueMode: (value: RuntimeSettings['continueMode']) => void
runtimeSandboxMode: (
value: RuntimeSettings['runtimeSandboxMode']
) => void
knowledgeEmbeddingEnabled: (value: boolean) => void
knowledgeEmbeddingBaseUrl: (value: string) => void
knowledgeEmbeddingModel: (value: string) => void
knowledgeEmbeddingApiKey: (value: string) => void
clearKnowledgeEmbeddingApiKey: (value: boolean) => void
knowledgeRerankEnabled: (value: boolean) => void
knowledgeRerankEndpoint: (value: string) => void
knowledgeRerankModel: (value: string) => void
knowledgeRerankApiKey: (value: string) => void
clearKnowledgeRerankApiKey: (value: boolean) => void
workspacePath: (value: string) => void
toolApproval: (value: RuntimeSettingsInput['toolApproval']) => void
subagentSmartRoutingEnabled: (value: boolean) => void
},
preserveSelectedProfile = false
): void {
const configured = configuredRuntimeSettings(value)
setters.settings(value)
setters.provider(value.provider)
setters.modelProfiles(toModelProfileDrafts(value))
const fallbackProfileId = value.modelProfiles.some(
(profile) => profile.id === value.defaultModelProfileId
)
? value.defaultModelProfileId
: value.modelProfiles[0]?.id ?? ''
setters.selectedModelProfileId(
preserveSelectedProfile
? (selectedId) =>
value.modelProfiles.some(
(profile) => profile.id === selectedId
)
? selectedId
: fallbackProfileId
: fallbackProfileId
)
setters.defaultModelProfileId(value.defaultModelProfileId)
setters.opencodeModelSource(configured.opencodeModelSource)
setters.continueModelSource(configured.continueModelSource)
setters.opencodeBaseUrl(configured.opencodeBaseUrl)
setters.opencodeBinaryPath(configured.opencodeBinaryPath)
setters.opencodeConfigPath(configured.opencodeConfigPath)
setters.continueBinaryPath(configured.continueBinaryPath)
setters.continueConfigPath(configured.continueConfigPath)
setters.continueMode(value.continueMode)
setters.runtimeSandboxMode(value.runtimeSandboxMode)
setters.knowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
setters.knowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
setters.knowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
setters.knowledgeEmbeddingApiKey('')
setters.clearKnowledgeEmbeddingApiKey(false)
setters.knowledgeRerankEnabled(
value.knowledgeRerankEnabled ??
defaultRuntimeSettings.knowledgeRerankEnabled
)
setters.knowledgeRerankEndpoint(
value.knowledgeRerankEndpoint ??
defaultRuntimeSettings.knowledgeRerankEndpoint
)
setters.knowledgeRerankModel(
value.knowledgeRerankModel ??
defaultRuntimeSettings.knowledgeRerankModel
)
setters.knowledgeRerankApiKey('')
setters.clearKnowledgeRerankApiKey(false)
setters.workspacePath(configured.workspacePath)
setters.toolApproval(
value.toolApproval === 'policy' ? 'policy' : 'always'
)
setters.subagentSmartRoutingEnabled(
value.subagentSmartRoutingEnabled
)
}
type RuntimeConfigCardProps = { type RuntimeConfigCardProps = {
runtime: AgentRuntimeType runtime: AgentRuntimeType
runtimeLabel: string runtimeLabel: string
@@ -246,6 +363,7 @@ export function SettingsPanel({
onExpertsChanged = () => {}, onExpertsChanged = () => {},
appearanceTheme = 'system', appearanceTheme = 'system',
onAppearanceThemeChange = () => {}, onAppearanceThemeChange = () => {},
magicNotesEnabled = false,
onMagicNotesEnabledChange = () => {} onMagicNotesEnabledChange = () => {}
}: SettingsPanelProps): React.JSX.Element | null { }: SettingsPanelProps): React.JSX.Element | null {
const { i18n, t } = useTranslation('settings') const { i18n, t } = useTranslation('settings')
@@ -322,6 +440,13 @@ export function SettingsPanel({
subagentSmartRoutingEnabled, subagentSmartRoutingEnabled,
setSubagentSmartRoutingEnabled setSubagentSmartRoutingEnabled
] = useState(false) ] = useState(false)
const modelProfileDisplayName = (
profile: Pick<ModelProfileDraft, 'id' | 'name'>
): string =>
profile.id === builtInDefaultModelProfileId &&
profile.name === '默认模型'
? t('model.profile.seededDefaultName')
: profile.name
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const [embeddingConfiguration, setEmbeddingConfiguration] = const [embeddingConfiguration, setEmbeddingConfiguration] =
@@ -350,6 +475,47 @@ export function SettingsPanel({
const [agentRuntimeType, setAgentRuntimeType] = const [agentRuntimeType, setAgentRuntimeType] =
useState<AgentRuntimeType>('opencode') useState<AgentRuntimeType>('opencode')
const settingsBodyRef = useRef<HTMLDivElement>(null) const settingsBodyRef = useRef<HTMLDivElement>(null)
const hydrateSettings = useCallback(
(
value: RuntimeSettings,
preserveSelectedProfile = false
): void => {
hydrateRuntimeSettings(
value,
{
settings: setSettings,
provider: setProvider,
modelProfiles: setModelProfiles,
selectedModelProfileId: setSelectedModelProfileId,
defaultModelProfileId: setDefaultModelProfileId,
opencodeModelSource: setOpencodeModelSource,
continueModelSource: setContinueModelSource,
opencodeBaseUrl: setOpencodeBaseUrl,
opencodeBinaryPath: setOpencodeBinaryPath,
opencodeConfigPath: setOpencodeConfigPath,
continueBinaryPath: setContinueBinaryPath,
continueConfigPath: setContinueConfigPath,
continueMode: setContinueMode,
runtimeSandboxMode: setRuntimeSandboxMode,
knowledgeEmbeddingEnabled: setKnowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl: setKnowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: setKnowledgeEmbeddingModel,
knowledgeEmbeddingApiKey: setKnowledgeEmbeddingApiKey,
clearKnowledgeEmbeddingApiKey: setClearKnowledgeEmbeddingApiKey,
knowledgeRerankEnabled: setKnowledgeRerankEnabled,
knowledgeRerankEndpoint: setKnowledgeRerankEndpoint,
knowledgeRerankModel: setKnowledgeRerankModel,
knowledgeRerankApiKey: setKnowledgeRerankApiKey,
clearKnowledgeRerankApiKey: setClearKnowledgeRerankApiKey,
workspacePath: setWorkspacePath,
toolApproval: setToolApproval,
subagentSmartRoutingEnabled: setSubagentSmartRoutingEnabled
},
preserveSelectedProfile
)
},
[]
)
const configurationTab = const configurationTab =
activeTab === 'model' || activeTab === 'model' ||
activeTab === 'runtime' || activeTab === 'runtime' ||
@@ -409,52 +575,7 @@ export function SettingsPanel({
setPersistedSpeechModelId(undefined) setPersistedSpeechModelId(undefined)
setSpeechModelSelectionDirty(false) setSpeechModelSelectionDirty(false)
setAgentRuntimeType('opencode') setAgentRuntimeType('opencode')
setSettings(value) hydrateSettings(value)
setProvider(value.provider)
setModelProfiles(toModelProfileDrafts(value))
setSelectedModelProfileId(
value.modelProfiles.some(
(profile) => profile.id === value.defaultModelProfileId
)
? value.defaultModelProfileId
: value.modelProfiles[0]?.id ?? ''
)
setDefaultModelProfileId(value.defaultModelProfileId)
setOpencodeModelSource(value.opencodeModelSource)
setContinueModelSource(value.continueModelSource)
setOpencodeBaseUrl(value.opencodeBaseUrl)
setOpencodeBinaryPath(value.opencodeBinaryPath)
setOpencodeConfigPath(value.opencodeConfigPath)
setContinueBinaryPath(value.continueBinaryPath)
setContinueConfigPath(value.continueConfigPath)
setContinueMode(value.continueMode)
setRuntimeSandboxMode(value.runtimeSandboxMode)
setKnowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
setKnowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
setKnowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
setKnowledgeEmbeddingApiKey('')
setClearKnowledgeEmbeddingApiKey(false)
setKnowledgeRerankEnabled(
value.knowledgeRerankEnabled ??
defaultRuntimeSettings.knowledgeRerankEnabled
)
setKnowledgeRerankEndpoint(
value.knowledgeRerankEndpoint ??
defaultRuntimeSettings.knowledgeRerankEndpoint
)
setKnowledgeRerankModel(
value.knowledgeRerankModel ??
defaultRuntimeSettings.knowledgeRerankModel
)
setKnowledgeRerankApiKey('')
setClearKnowledgeRerankApiKey(false)
setWorkspacePath(value.workspacePath)
setToolApproval(
value.toolApproval === 'policy' ? 'policy' : 'always'
)
setSubagentSmartRoutingEnabled(
value.subagentSmartRoutingEnabled
)
}) })
.catch((reason: unknown) => { .catch((reason: unknown) => {
setError( setError(
@@ -475,7 +596,7 @@ export function SettingsPanel({
) )
) )
}) })
}, [i18n, open]) }, [hydrateSettings, i18n, open])
useEffect(() => { useEffect(() => {
if (open && settingsBodyRef.current) { if (open && settingsBodyRef.current) {
@@ -550,32 +671,52 @@ export function SettingsPanel({
if (!defaultProfile) { if (!defaultProfile) {
throw new Error(t('errors.requireModelConnection')) throw new Error(t('errors.requireModelConnection'))
} }
const profileInputs = modelProfiles.map((profile) => ({ const configuredProfiles = new Map(
id: profile.id, settings?.configured?.modelProfiles.map((profile) => [
name: profile.name, profile.id,
baseUrl: profile.baseUrl, profile
modelName: profile.modelName, ])
protocol: profile.protocol, )
authentication: profile.authentication, const profileInputs = modelProfiles.map((profile) => {
supportsImageInput: profile.supportsImageInput, const configured = configuredProfiles.get(profile.id)
imageGenerationQuality: profile.imageGenerationQuality, const environmentManaged =
apiKey: profile.clearApiKey profile.credentialSource === 'environment' &&
? ({ action: 'clear' } as const) configured !== undefined
: profile.apiKey.trim() return {
? ({ id: profile.id,
action: 'replace', name: profile.name,
value: profile.apiKey.trim() baseUrl: environmentManaged
} as const) ? configured.baseUrl
: ({ action: 'keep' } as const) : profile.baseUrl,
})) modelName: environmentManaged
? configured.modelName
: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: profile.clearApiKey
? ({ action: 'clear' } as const)
: profile.apiKey.trim()
? ({
action: 'replace',
value: profile.apiKey.trim()
} as const)
: ({ action: 'keep' } as const)
}
})
const defaultProfileInput =
profileInputs.find(
(profile) => profile.id === defaultProfile.id
) ?? profileInputs[0]!
const value = await window.goodbuddy.settings.updateRuntime({ const value = await window.goodbuddy.settings.updateRuntime({
provider, provider,
modelBaseUrl: defaultProfile.baseUrl, modelBaseUrl: defaultProfileInput.baseUrl,
modelName: defaultProfile.modelName, modelName: defaultProfileInput.modelName,
modelProtocol: defaultProfile.protocol, modelProtocol: defaultProfileInput.protocol,
modelAuthentication: defaultProfile.authentication, modelAuthentication: defaultProfileInput.authentication,
imageGenerationQuality: imageGenerationQuality:
defaultProfile.imageGenerationQuality, defaultProfileInput.imageGenerationQuality,
opencodeBaseUrl, opencodeBaseUrl,
opencodeEmbedded: !opencodeBaseUrl, opencodeEmbedded: !opencodeBaseUrl,
opencodeBinaryPath, opencodeBinaryPath,
@@ -607,9 +748,7 @@ export function SettingsPanel({
} }
: { action: 'keep' }, : { action: 'keep' },
workspacePath, workspacePath,
apiKey: profileInputs.find( apiKey: defaultProfileInput.apiKey,
(profile) => profile.id === defaultProfile.id
)!.apiKey,
modelProfiles: profileInputs, modelProfiles: profileInputs,
defaultModelProfileId: defaultProfile.id, defaultModelProfileId: defaultProfile.id,
opencodeModelSource, opencodeModelSource,
@@ -628,48 +767,7 @@ export function SettingsPanel({
) )
selectedSpeechModelId = speechSnapshot.selectedModelId selectedSpeechModelId = speechSnapshot.selectedModelId
} }
setSettings(value) hydrateSettings(value, true)
setModelProfiles(toModelProfileDrafts(value))
setSelectedModelProfileId((selectedId) =>
value.modelProfiles.some((profile) => profile.id === selectedId)
? selectedId
: value.defaultModelProfileId
)
setDefaultModelProfileId(value.defaultModelProfileId)
setOpencodeModelSource(value.opencodeModelSource)
setContinueModelSource(value.continueModelSource)
setOpencodeBaseUrl(value.opencodeBaseUrl)
setOpencodeBinaryPath(value.opencodeBinaryPath)
setOpencodeConfigPath(value.opencodeConfigPath)
setContinueBinaryPath(value.continueBinaryPath)
setContinueConfigPath(value.continueConfigPath)
setContinueMode(value.continueMode)
setRuntimeSandboxMode(value.runtimeSandboxMode)
setKnowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
setKnowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
setKnowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
setKnowledgeEmbeddingApiKey('')
setClearKnowledgeEmbeddingApiKey(false)
setKnowledgeRerankEnabled(
value.knowledgeRerankEnabled ??
defaultRuntimeSettings.knowledgeRerankEnabled
)
setKnowledgeRerankEndpoint(
value.knowledgeRerankEndpoint ??
defaultRuntimeSettings.knowledgeRerankEndpoint
)
setKnowledgeRerankModel(
value.knowledgeRerankModel ??
defaultRuntimeSettings.knowledgeRerankModel
)
setKnowledgeRerankApiKey('')
setClearKnowledgeRerankApiKey(false)
setToolApproval(
value.toolApproval === 'policy' ? 'policy' : 'always'
)
setSubagentSmartRoutingEnabled(
value.subagentSmartRoutingEnabled
)
if (speechModelSelectionDirty) { if (speechModelSelectionDirty) {
setSpeechModelDraftId(selectedSpeechModelId) setSpeechModelDraftId(selectedSpeechModelId)
setPersistedSpeechModelId(selectedSpeechModelId) setPersistedSpeechModelId(selectedSpeechModelId)
@@ -987,7 +1085,10 @@ export function SettingsPanel({
.filter((profile) => .filter((profile) =>
isAgentRuntimeModelProtocol(profile.protocol) isAgentRuntimeModelProtocol(profile.protocol)
) )
.map(({ id, name }) => ({ id, name })) .map(({ id, name }) => ({
id,
name: modelProfileDisplayName({ id, name })
}))
const savedRoleDefaultModelProfileId = const savedRoleDefaultModelProfileId =
savedRoleModelProfiles.some( savedRoleModelProfiles.some(
(profile) => profile.id === settings?.defaultModelProfileId (profile) => profile.id === settings?.defaultModelProfileId
@@ -1246,9 +1347,7 @@ export function SettingsPanel({
)} )}
{activeTab === 'runtime' && ( {activeTab === 'runtime' && (
<> <>
{settings?.warning && ( <SettingsWarningList warnings={settings?.warnings} />
<p className="settings-warning">{settings.warning}</p>
)}
<div className="settings-section"> <div className="settings-section">
<div className="settings-section__title"> <div className="settings-section__title">
<FolderOpen size={17} /> <FolderOpen size={17} />
@@ -1327,12 +1426,16 @@ export function SettingsPanel({
}) })
: activeRuntimeModelProfile : activeRuntimeModelProfile
? t('runtime.followGoodBuddy', { ? t('runtime.followGoodBuddy', {
name: activeRuntimeModelProfile.name, name: modelProfileDisplayName(
activeRuntimeModelProfile
),
model: activeRuntimeModelProfile.modelName model: activeRuntimeModelProfile.modelName
}) })
: defaultTextModelProfile : defaultTextModelProfile
? t('runtime.followGoodBuddy', { ? t('runtime.followGoodBuddy', {
name: defaultTextModelProfile.name, name: modelProfileDisplayName(
defaultTextModelProfile
),
model: defaultTextModelProfile.modelName model: defaultTextModelProfile.modelName
}) })
: t('runtime.noCompatibleModel')} : t('runtime.noCompatibleModel')}
@@ -1415,7 +1518,7 @@ export function SettingsPanel({
key={profile.id} key={profile.id}
value={profile.id} value={profile.id}
> >
{profile.name} {modelProfileDisplayName(profile)}
{isOpenCodeCompatible(profile) {isOpenCodeCompatible(profile)
? '' ? ''
: t('runtime.incompatibleSuffix')} : t('runtime.incompatibleSuffix')}
@@ -1440,7 +1543,12 @@ export function SettingsPanel({
path={opencodeConfigPath} path={opencodeConfigPath}
runtime="opencode" runtime="opencode"
runtimeLabel="OpenCode" runtimeLabel="OpenCode"
savedPath={settings?.opencodeConfigPath} savedPath={
settings
? configuredRuntimeSettings(settings)
.opencodeConfigPath
: undefined
}
/> />
)} )}
{opencodeModelSource.kind === 'platform' && {opencodeModelSource.kind === 'platform' &&
@@ -1548,12 +1656,16 @@ export function SettingsPanel({
}) })
: activeRuntimeModelProfile : activeRuntimeModelProfile
? t('runtime.followGoodBuddy', { ? t('runtime.followGoodBuddy', {
name: activeRuntimeModelProfile.name, name: modelProfileDisplayName(
activeRuntimeModelProfile
),
model: activeRuntimeModelProfile.modelName model: activeRuntimeModelProfile.modelName
}) })
: defaultTextModelProfile : defaultTextModelProfile
? t('runtime.followGoodBuddy', { ? t('runtime.followGoodBuddy', {
name: defaultTextModelProfile.name, name: modelProfileDisplayName(
defaultTextModelProfile
),
model: defaultTextModelProfile.modelName model: defaultTextModelProfile.modelName
}) })
: t('runtime.noCompatibleModel')} : t('runtime.noCompatibleModel')}
@@ -1634,7 +1746,7 @@ export function SettingsPanel({
key={profile.id} key={profile.id}
value={profile.id} value={profile.id}
> >
{profile.name} {modelProfileDisplayName(profile)}
{isContinueCompatible(profile) {isContinueCompatible(profile)
? '' ? ''
: t('runtime.incompatibleSuffix')} : t('runtime.incompatibleSuffix')}
@@ -1658,7 +1770,12 @@ export function SettingsPanel({
path={continueConfigPath} path={continueConfigPath}
runtime="continue" runtime="continue"
runtimeLabel="Continue" runtimeLabel="Continue"
savedPath={settings?.continueConfigPath} savedPath={
settings
? configuredRuntimeSettings(settings)
.continueConfigPath
: undefined
}
/> />
)} )}
<label className="field"> <label className="field">
@@ -1798,7 +1915,7 @@ export function SettingsPanel({
: undefined : undefined
} }
aria-label={t('model.profile.editAriaLabel', { aria-label={t('model.profile.editAriaLabel', {
name: profile.name name: modelProfileDisplayName(profile)
})} })}
onClick={() => onClick={() =>
setSelectedModelProfileId(profile.id) setSelectedModelProfileId(profile.id)
@@ -1806,7 +1923,7 @@ export function SettingsPanel({
type="button" type="button"
> >
<span className="model-connection-list__name"> <span className="model-connection-list__name">
<strong>{profile.name}</strong> <strong>{modelProfileDisplayName(profile)}</strong>
<small>{profile.modelName}</small> <small>{profile.modelName}</small>
</span> </span>
<span className="model-connection-list__badges"> <span className="model-connection-list__badges">
@@ -1836,7 +1953,7 @@ export function SettingsPanel({
<div className="settings-section__title"> <div className="settings-section__title">
<div> <div>
<strong id={`model-connection-${profile.id}`}> <strong id={`model-connection-${profile.id}`}>
{profile.name} {modelProfileDisplayName(profile)}
</strong> </strong>
<small>{t('model.profile.detail')}</small> <small>{t('model.profile.detail')}</small>
</div> </div>
@@ -1858,7 +1975,7 @@ export function SettingsPanel({
)} )}
<button <button
aria-label={t('model.profile.deleteAriaLabel', { aria-label={t('model.profile.deleteAriaLabel', {
name: profile.name name: modelProfileDisplayName(profile)
})} })}
className="danger-button danger-button--quiet" className="danger-button danger-button--quiet"
disabled={modelProfiles.length <= 1} disabled={modelProfiles.length <= 1}
@@ -1877,7 +1994,7 @@ export function SettingsPanel({
name: event.target.value name: event.target.value
}) })
} }
value={profile.name} value={modelProfileDisplayName(profile)}
/> />
</label> </label>
<label className="field"> <label className="field">
@@ -1912,7 +2029,7 @@ export function SettingsPanel({
<select <select
aria-label={t( aria-label={t(
'model.profile.protocolAriaLabel', 'model.profile.protocolAriaLabel',
{ name: profile.name } { name: modelProfileDisplayName(profile) }
)} )}
onChange={(event) => onChange={(event) =>
{ {
@@ -1979,7 +2096,7 @@ export function SettingsPanel({
<select <select
aria-label={t( aria-label={t(
'model.profile.authenticationAriaLabel', 'model.profile.authenticationAriaLabel',
{ name: profile.name } { name: modelProfileDisplayName(profile) }
)} )}
onChange={(event) => { onChange={(event) => {
const authentication = event.target const authentication = event.target
@@ -2027,7 +2144,7 @@ export function SettingsPanel({
<select <select
aria-label={t( aria-label={t(
'model.profile.imageQualityAriaLabel', 'model.profile.imageQualityAriaLabel',
{ name: profile.name } { name: modelProfileDisplayName(profile) }
)} )}
onChange={(event) => onChange={(event) =>
updateModelProfile(profile.id, { updateModelProfile(profile.id, {
@@ -2530,7 +2647,11 @@ export function SettingsPanel({
</> </>
)} )}
{activeTab === 'skills' && <SkillsSettingsSection />} {activeTab === 'skills' && <SkillsSettingsSection />}
{activeTab === 'mcp' && <McpSettingsSection />} {activeTab === 'mcp' && (
<McpSettingsSection
magicNotesEnabled={magicNotesEnabled}
/>
)}
{activeTab === 'about' && <UpdateSettingsSection />} {activeTab === 'about' && <UpdateSettingsSection />}
</div> </div>
</div> </div>
+30
View File
@@ -1,8 +1,38 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
settingsWarningKey,
type SettingsWarning
} from '../../shared/settings-warning-contracts'
import { import {
settingsCategories, settingsCategories,
type SettingsCategoryId type SettingsCategoryId
} from './settings-categories' } from './settings-categories'
import { translateSettingsWarning } from './settings-warnings'
export function SettingsWarningList({
warnings
}: {
warnings?: readonly SettingsWarning[]
}): React.JSX.Element | null {
const { t } = useTranslation('warnings')
if (!warnings?.length) {
return null
}
return (
<>
{warnings.map((warning) => (
<p
className="settings-warning"
key={settingsWarningKey(warning)}
role="alert"
>
{translateSettingsWarning(warning, t)}
</p>
))}
</>
)
}
export function SettingsCategoryHeader({ export function SettingsCategoryHeader({
actions, actions,
+5 -1
View File
@@ -6,7 +6,10 @@ import type {
VersionCheckResult VersionCheckResult
} from '../../shared/application-settings-contracts' } from '../../shared/application-settings-contracts'
import type { AppInfo } from '../../shared/contracts' import type { AppInfo } from '../../shared/contracts'
import { SettingsCategoryHeader } from './SettingsPrimitives' import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) { if (bytes >= 1024 * 1024 * 1024) {
@@ -137,6 +140,7 @@ export function UpdateSettingsSection(): React.JSX.Element {
error={error} error={error}
headingId="update-settings-heading" headingId="update-settings-heading"
/> />
<SettingsWarningList warnings={settings?.warnings} />
<section <section
aria-label={t('updates.label')} aria-label={t('updates.label')}
className="settings-section update-settings" className="settings-section update-settings"
+6 -2
View File
@@ -9,6 +9,7 @@ import { magicNotes as englishMagicNotes } from './locales/en-US/magicNotes'
import { settings as englishSettings } from './locales/en-US/settings' import { settings as englishSettings } from './locales/en-US/settings'
import { settingsSections as englishSettingsSections } from './locales/en-US/settingsSections' import { settingsSections as englishSettingsSections } from './locales/en-US/settingsSections'
import { workspace as englishWorkspace } from './locales/en-US/workspace' import { workspace as englishWorkspace } from './locales/en-US/workspace'
import { warnings as englishWarnings } from './locales/en-US/warnings'
import { activity as chineseActivity } from './locales/zh-CN/activity' import { activity as chineseActivity } from './locales/zh-CN/activity'
import { app as chineseApp } from './locales/zh-CN/app' import { app as chineseApp } from './locales/zh-CN/app'
import { heartbeat as chineseHeartbeat } from './locales/zh-CN/heartbeat' import { heartbeat as chineseHeartbeat } from './locales/zh-CN/heartbeat'
@@ -18,6 +19,7 @@ import { magicNotes as chineseMagicNotes } from './locales/zh-CN/magicNotes'
import { settings as chineseSettings } from './locales/zh-CN/settings' import { settings as chineseSettings } from './locales/zh-CN/settings'
import { settingsSections as chineseSettingsSections } from './locales/zh-CN/settingsSections' import { settingsSections as chineseSettingsSections } from './locales/zh-CN/settingsSections'
import { workspace as chineseWorkspace } from './locales/zh-CN/workspace' import { workspace as chineseWorkspace } from './locales/zh-CN/workspace'
import { warnings as chineseWarnings } from './locales/zh-CN/warnings'
export const supportedUiLocales = ['zh-CN', 'en-US'] as const export const supportedUiLocales = ['zh-CN', 'en-US'] as const
export type UiLocale = (typeof supportedUiLocales)[number] export type UiLocale = (typeof supportedUiLocales)[number]
@@ -32,7 +34,8 @@ export const i18nResources = {
magicNotes: chineseMagicNotes, magicNotes: chineseMagicNotes,
settings: chineseSettings, settings: chineseSettings,
settingsSections: chineseSettingsSections, settingsSections: chineseSettingsSections,
workspace: chineseWorkspace workspace: chineseWorkspace,
warnings: chineseWarnings
}, },
'en-US': { 'en-US': {
activity: englishActivity, activity: englishActivity,
@@ -43,7 +46,8 @@ export const i18nResources = {
magicNotes: englishMagicNotes, magicNotes: englishMagicNotes,
settings: englishSettings, settings: englishSettings,
settingsSections: englishSettingsSections, settingsSections: englishSettingsSections,
workspace: englishWorkspace workspace: englishWorkspace,
warnings: englishWarnings
} }
} as const } as const
@@ -2,6 +2,9 @@ import type { TranslationShape } from '../../resource-types'
import type { app as chineseApp } from '../zh-CN/app' import type { app as chineseApp } from '../zh-CN/app'
export const app = { export const app = {
brand: {
desktopWorkspace: 'Desktop workspace'
},
notifications: { notifications: {
success: 'Success', success: 'Success',
error: 'Error', error: 'Error',
@@ -125,6 +128,7 @@ export const app = {
user: 'You', user: 'You',
assistantResult: 'Assistant result {{index}}', assistantResult: 'Assistant result {{index}}',
welcome: { welcome: {
eyebrow: 'GOODBUDDY WORKSPACE',
title: 'What would you like to accomplish today?', title: 'What would you like to accomplish today?',
description: description:
'Ask a question, organize information, or connect OpenCode for file search and development tools.' 'Ask a question, organize information, or connect OpenCode for file search and development tools.'
@@ -70,6 +70,7 @@ export const integrations = {
environmentSource: 'Provided by environment variables', environmentSource: 'Provided by environment variables',
secretSaved: 'Secret saved with encryption', secretSaved: 'Secret saved with encryption',
secretMissing: 'Secret not configured', secretMissing: 'Secret not configured',
secretUnreadable: 'Secret saved, but currently unreadable',
readOnly: readOnly:
'This channel is managed by environment variables. Change the launch environment and restart the app.', 'This channel is managed by environment variables. Change the launch environment and restart the app.',
enable: 'Enable the {{channel}} channel', enable: 'Enable the {{channel}} channel',
@@ -136,6 +136,7 @@ export const settings = {
none: 'Not configured', none: 'Not configured',
encrypted: 'Encrypted in secure system storage', encrypted: 'Encrypted in secure system storage',
environment: 'Provided by an environment variable', environment: 'Provided by an environment variable',
unreadable: 'Saved, but currently unreadable',
configuredPlaceholder: 'Configured; leave blank to keep it', configuredPlaceholder: 'Configured; leave blank to keep it',
enterApiKey: 'Enter API Key', enterApiKey: 'Enter API Key',
noAuthentication: 'No authentication', noAuthentication: 'No authentication',
@@ -224,6 +225,7 @@ export const settings = {
} }
}, },
documentParsing: { documentParsing: {
loading: 'Loading…',
status: { status: {
title: 'Runtime status', title: 'Runtime status',
description: 'Capabilities currently available on this device', description: 'Capabilities currently available on this device',
@@ -409,6 +411,7 @@ export const settings = {
} }
}, },
profile: { profile: {
seededDefaultName: 'Default model',
generatedName: 'Model connection {{count}}', generatedName: 'Model connection {{count}}',
title: 'LLM model connections', title: 'LLM model connections',
description: description:
@@ -0,0 +1,45 @@
import type { TranslationShape } from '../../resource-types'
import type { warnings as chineseWarnings } from '../zh-CN/warnings'
export const warnings = {
'application-settings-recovered':
'The application settings file was corrupt. The original file was isolated, and safe defaults are now in use.',
'document-parsing-settings-recovered':
'The document parsing settings file was corrupt. The original file was isolated, and safe defaults are now in use.',
'capability-settings-recovered':
'The capability settings file was corrupt. The original file was isolated. Web search and computer control remain off until you review and enable them.',
'runtime-settings-recovered':
'The Runtime settings file was corrupt. The original file was isolated, and defaults are now in use.',
'runtime-model-credential-unreadable':
'The API Key for model connection “{{subject}}” cannot be read. Re-enter or clear this credential.',
'runtime-model-credential-binding-mismatch':
'The service address for model connection “{{subject}}” does not match its saved API Key. Re-enter or clear this credential.',
'runtime-embedding-credential-unreadable':
'The embedding model API Key cannot be read. Re-enter or clear this credential.',
'runtime-embedding-credential-binding-mismatch':
'The embedding endpoint does not match its saved API Key. Re-enter or clear this credential.',
'runtime-rerank-credential-unreadable':
'The rerank model API Key cannot be read. Re-enter or clear this credential.',
'runtime-rerank-credential-binding-mismatch':
'The rerank endpoint does not match its saved API Key. Re-enter or clear this credential.',
'channel-settings-recovered':
'The channel settings file was corrupt. The original file was isolated, and all channels were restored as disabled.',
'channel-weixin-credential-unreadable':
'The WeChat connection credential cannot be read, so the channel is temporarily disabled. Connect it again with a QR code.',
'channel-weixin-secure-storage-unavailable':
'Secure system storage is temporarily unavailable, so the WeChat channel is disabled. Retry after secure storage recovers.',
'channel-weixin-legacy-binding-invalid':
'The legacy WeChat connection could not be migrated safely. Connect it again with a QR code.',
'channel-wecom-environment-invalid':
'The WeCom environment configuration is invalid or incomplete, so the channel remains off.',
'channel-dingtalk-environment-invalid':
'The DingTalk environment configuration is invalid or incomplete, so the channel remains off.',
'channel-wecom-credential-unreadable':
'The WeCom Secret cannot be read. Re-enter or clear this credential.',
'channel-dingtalk-credential-unreadable':
'The DingTalk Client Secret cannot be read. Re-enter or clear this credential.',
'channel-runtime-selections-repaired':
'Repaired {{count}} unavailable backend selections for unattended channels. Review each channel project setting.'
} as const satisfies TranslationShape<typeof chineseWarnings>
export default warnings
@@ -1,4 +1,7 @@
export const app = { export const app = {
brand: {
desktopWorkspace: '桌面工作区'
},
notifications: { notifications: {
success: '成功', success: '成功',
error: '错误', error: '错误',
@@ -121,6 +124,7 @@ export const app = {
user: '用户', user: '用户',
assistantResult: '助手成果 {{index}}', assistantResult: '助手成果 {{index}}',
welcome: { welcome: {
eyebrow: 'GOODBUDDY 工作台',
title: '今天想一起完成什么?', title: '今天想一起完成什么?',
description: description:
'快速提问、梳理信息,或连接 OpenCode 使用文件搜索和开发工具。' '快速提问、梳理信息,或连接 OpenCode 使用文件搜索和开发工具。'
@@ -62,6 +62,7 @@ export const integrations = {
environmentSource: '由环境变量提供', environmentSource: '由环境变量提供',
secretSaved: 'Secret 已加密保存', secretSaved: 'Secret 已加密保存',
secretMissing: 'Secret 尚未配置', secretMissing: 'Secret 尚未配置',
secretUnreadable: 'Secret 已保存,但当前无法读取',
readOnly: readOnly:
'当前通道由环境变量管理。请在启动环境中修改配置后重启应用。', '当前通道由环境变量管理。请在启动环境中修改配置后重启应用。',
enable: '启用{{channel}}通道', enable: '启用{{channel}}通道',
@@ -124,6 +124,7 @@ export const settings = {
none: '尚未配置', none: '尚未配置',
encrypted: '已由系统安全存储加密', encrypted: '已由系统安全存储加密',
environment: '由环境变量提供', environment: '由环境变量提供',
unreadable: '已保存,但当前无法读取',
configuredPlaceholder: '已配置,留空保持不变', configuredPlaceholder: '已配置,留空保持不变',
enterApiKey: '输入 API Key', enterApiKey: '输入 API Key',
noAuthentication: '无需认证', noAuthentication: '无需认证',
@@ -205,6 +206,7 @@ export const settings = {
} }
}, },
documentParsing: { documentParsing: {
loading: '正在加载…',
status: { status: {
title: '运行状态', title: '运行状态',
description: '显示当前设备实际可用的解析能力', description: '显示当前设备实际可用的解析能力',
@@ -372,6 +374,7 @@ export const settings = {
} }
}, },
profile: { profile: {
seededDefaultName: '默认模型',
generatedName: '模型连接 {{count}}', generatedName: '模型连接 {{count}}',
title: 'LLM 模型连接', title: 'LLM 模型连接',
description: description:
@@ -0,0 +1,42 @@
export const warnings = {
'application-settings-recovered':
'应用设置文件已损坏。原文件已隔离,当前使用安全默认设置。',
'document-parsing-settings-recovered':
'文档解析设置文件已损坏。原文件已隔离,当前使用安全默认设置。',
'capability-settings-recovered':
'能力设置文件已损坏。原文件已隔离,网页搜索和电脑控制已保持关闭,请检查后手动启用。',
'runtime-settings-recovered':
'Runtime 设置文件已损坏。原文件已隔离,当前使用默认设置。',
'runtime-model-credential-unreadable':
'模型连接“{{subject}}”的 API Key 无法读取。请重新输入或清除该凭据。',
'runtime-model-credential-binding-mismatch':
'模型连接“{{subject}}”的服务地址与已保存 API Key 不匹配。请重新输入或清除该凭据。',
'runtime-embedding-credential-unreadable':
'向量模型 API Key 无法读取。请重新输入或清除该凭据。',
'runtime-embedding-credential-binding-mismatch':
'向量接口地址与已保存 API Key 不匹配。请重新输入或清除该凭据。',
'runtime-rerank-credential-unreadable':
'重排模型 API Key 无法读取。请重新输入或清除该凭据。',
'runtime-rerank-credential-binding-mismatch':
'重排接口地址与已保存 API Key 不匹配。请重新输入或清除该凭据。',
'channel-settings-recovered':
'通道设置文件已损坏。原文件已隔离,所有通道已恢复为关闭状态。',
'channel-weixin-credential-unreadable':
'微信绑定凭据无法读取,通道已临时停用。请重新扫码绑定。',
'channel-weixin-secure-storage-unavailable':
'系统安全存储暂不可用,微信绑定已临时停用。恢复安全存储后可重试。',
'channel-weixin-legacy-binding-invalid':
'旧版微信绑定无法安全迁移,请重新扫码绑定。',
'channel-wecom-environment-invalid':
'企业微信环境变量配置无效或不完整,通道保持关闭。',
'channel-dingtalk-environment-invalid':
'钉钉环境变量配置无效或不完整,通道保持关闭。',
'channel-wecom-credential-unreadable':
'企业微信 Secret 无法读取。请重新输入或清除该凭据。',
'channel-dingtalk-credential-unreadable':
'钉钉 Client Secret 无法读取。请重新输入或清除该凭据。',
'channel-runtime-selections-repaired':
'已修复 {{count}} 个无人值守通道的不可用后端选择。请检查各通道项目设置。'
} as const
export default warnings
+2
View File
@@ -8,6 +8,7 @@ import activity from './locales/zh-CN/activity'
import magicNotes from './locales/zh-CN/magicNotes' import magicNotes from './locales/zh-CN/magicNotes'
import integrations from './locales/zh-CN/integrations' import integrations from './locales/zh-CN/integrations'
import workspace from './locales/zh-CN/workspace' import workspace from './locales/zh-CN/workspace'
import warnings from './locales/zh-CN/warnings'
declare module 'i18next' { declare module 'i18next' {
interface CustomTypeOptions { interface CustomTypeOptions {
@@ -22,6 +23,7 @@ declare module 'i18next' {
magicNotes: typeof magicNotes magicNotes: typeof magicNotes
integrations: typeof integrations integrations: typeof integrations
workspace: typeof workspace workspace: typeof workspace
warnings: typeof warnings
} }
returnNull: false returnNull: false
} }
+37
View File
@@ -0,0 +1,37 @@
import type { RuntimeSettings } from '../../shared/contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
export function getRuntimeSelectionForProvider(
provider: 'model' | 'opencode' | 'continue',
settings: RuntimeSettings
): AgentRuntimeSelection {
if (provider === 'model') {
return {
provider,
profileId: settings.defaultModelProfileId
}
}
const source =
provider === 'opencode'
? settings.opencodeModelSource
: settings.continueModelSource
return {
provider,
...(source.kind === 'profile' ? { profileId: source.profileId } : {})
}
}
export function getDefaultRuntimeSelection(
settings: RuntimeSettings
): AgentRuntimeSelection {
if (
settings.provider === 'model' ||
settings.provider === 'opencode' ||
settings.provider === 'continue'
) {
return getRuntimeSelectionForProvider(settings.provider, settings)
}
return settings.opencodeBaseUrl || settings.opencodeEmbedded
? getRuntimeSelectionForProvider('opencode', settings)
: getRuntimeSelectionForProvider('model', settings)
}
+21
View File
@@ -0,0 +1,21 @@
import type { TFunction } from 'i18next'
import type { SettingsWarning } from '../../shared/settings-warning-contracts'
export function translateSettingsWarning(
warning: SettingsWarning,
t: TFunction<'warnings'>
): string {
switch (warning.code) {
case 'runtime-model-credential-unreadable':
case 'runtime-model-credential-binding-mismatch':
return t(warning.code, {
subject: warning.subject ?? ''
})
case 'channel-runtime-selections-repaired':
return t(warning.code, {
count: warning.count ?? 0
})
default:
return t(warning.code)
}
}
+9 -2
View File
@@ -1,5 +1,6 @@
import { z } from 'zod' import { z } from 'zod'
import { magicNoteCommentFormatSchema } from './magic-notes-contracts' import { magicNoteCommentFormatSchema } from './magic-notes-contracts'
import { settingsWarningsSchema } from './settings-warning-contracts'
export const magicNoteCommentModeSchema = z.enum([ export const magicNoteCommentModeSchema = z.enum([
'immediate', 'immediate',
@@ -11,7 +12,7 @@ export type MagicNoteCommentMode = z.infer<
typeof magicNoteCommentModeSchema typeof magicNoteCommentModeSchema
> >
export const applicationSettingsSchema = z const applicationPreferencesSchema = z
.object({ .object({
checkUpdatesOnStartup: z.boolean(), checkUpdatesOnStartup: z.boolean(),
magicNotesEnabled: z.boolean(), magicNotesEnabled: z.boolean(),
@@ -20,7 +21,13 @@ export const applicationSettingsSchema = z
}) })
.strict() .strict()
export const applicationSettingsUpdateSchema = applicationSettingsSchema export const applicationSettingsSchema = applicationPreferencesSchema
.extend({
warnings: settingsWarningsSchema.optional()
})
.strict()
export const applicationSettingsUpdateSchema = applicationPreferencesSchema
.partial() .partial()
.refine((input) => Object.keys(input).length > 0, { .refine((input) => Object.keys(input).length > 0, {
message: 'At least one application setting is required' message: 'At least one application setting is required'
+3 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import { settingsWarningsSchema } from './settings-warning-contracts'
const controlCharacterFreeString = (maximumLength: number) => const controlCharacterFreeString = (maximumLength: number) =>
z z
@@ -331,7 +332,8 @@ export const capabilitySnapshotSchema = z
.array(computerCapabilityConfigSummarySchema) .array(computerCapabilityConfigSummarySchema)
.max(2) .max(2)
.optional(), .optional(),
browserProfiles: browserProfilesSummarySchema.optional() browserProfiles: browserProfilesSummarySchema.optional(),
warnings: settingsWarningsSchema.optional()
}) })
.strict() .strict()
export type CapabilitySnapshot = z.infer<typeof capabilitySnapshotSchema> export type CapabilitySnapshot = z.infer<typeof capabilitySnapshotSchema>
+4 -7
View File
@@ -3,6 +3,7 @@ import {
projectChannelSchema, projectChannelSchema,
type ProjectChannel type ProjectChannel
} from './assistant-contracts' } from './assistant-contracts'
import { settingsWarningsSchema } from './settings-warning-contracts'
export const CHANNEL_SETTINGS_LIMITS = { export const CHANNEL_SETTINGS_LIMITS = {
maximumIdentifierLength: 256, maximumIdentifierLength: 256,
@@ -106,7 +107,8 @@ export type ChannelSettingsApply = z.infer<
export const channelCredentialSourceSchema = z.enum([ export const channelCredentialSourceSchema = z.enum([
'none', 'none',
'encrypted', 'encrypted',
'environment' 'environment',
'unreadable'
]) ])
export type ChannelCredentialSource = z.infer< export type ChannelCredentialSource = z.infer<
typeof channelCredentialSourceSchema typeof channelCredentialSourceSchema
@@ -186,12 +188,7 @@ export const channelSettingsSnapshotSchema = z
weixin: weixinChannelSettingsSchema, weixin: weixinChannelSettingsSchema,
wecom: weComChannelSettingsSchema, wecom: weComChannelSettingsSchema,
dingtalk: dingTalkChannelSettingsSchema, dingtalk: dingTalkChannelSettingsSchema,
warning: z warnings: settingsWarningsSchema.optional()
.string()
.trim()
.min(1)
.max(CHANNEL_SETTINGS_LIMITS.maximumWarningLength)
.optional()
}) })
.strict() .strict()
export type ChannelSettingsSnapshot = z.infer< export type ChannelSettingsSnapshot = z.infer<
+27 -5
View File
@@ -86,6 +86,7 @@ import type {
DocumentParsingSnapshot, DocumentParsingSnapshot,
DocumentParsingTestPurpose DocumentParsingTestPurpose
} from './document-parsing-contracts' } from './document-parsing-contracts'
import type { SettingsWarning } from './settings-warning-contracts'
import type { import type {
KnowledgeChunkDeleteInput, KnowledgeChunkDeleteInput,
KnowledgeChunkPage, KnowledgeChunkPage,
@@ -601,7 +602,19 @@ export type ModelConnectionSettings = {
supportsImageInput?: boolean supportsImageInput?: boolean
imageGenerationQuality: ImageGenerationQuality imageGenerationQuality: ImageGenerationQuality
apiKeyConfigured: boolean apiKeyConfigured: boolean
credentialSource: 'none' | 'encrypted' | 'environment' credentialSource: 'none' | 'encrypted' | 'environment' | 'unreadable'
}
export type ConfiguredRuntimeSettings = {
modelProfiles: ModelConnectionSettings[]
opencodeBaseUrl: string
opencodeBinaryPath: string
opencodeConfigPath: string
continueBinaryPath: string
continueConfigPath: string
workspacePath: string
opencodeModelSource: RuntimeModelSource
continueModelSource: RuntimeModelSource
} }
export type RuntimeSettings = { export type RuntimeSettings = {
@@ -625,22 +638,31 @@ export type RuntimeSettings = {
knowledgeEmbeddingBaseUrl: string knowledgeEmbeddingBaseUrl: string
knowledgeEmbeddingModel: string knowledgeEmbeddingModel: string
knowledgeEmbeddingApiKeyConfigured: boolean knowledgeEmbeddingApiKeyConfigured: boolean
knowledgeEmbeddingCredentialSource: 'none' | 'encrypted' | 'environment' knowledgeEmbeddingCredentialSource:
| 'none'
| 'encrypted'
| 'environment'
| 'unreadable'
knowledgeRerankEnabled?: boolean knowledgeRerankEnabled?: boolean
knowledgeRerankEndpoint?: string knowledgeRerankEndpoint?: string
knowledgeRerankModel?: string knowledgeRerankModel?: string
knowledgeRerankApiKeyConfigured?: boolean knowledgeRerankApiKeyConfigured?: boolean
knowledgeRerankCredentialSource?: 'none' | 'encrypted' | 'environment' knowledgeRerankCredentialSource?:
| 'none'
| 'encrypted'
| 'environment'
| 'unreadable'
workspacePath: string workspacePath: string
apiKeyConfigured: boolean apiKeyConfigured: boolean
credentialSource: 'none' | 'encrypted' | 'environment' credentialSource: 'none' | 'encrypted' | 'environment' | 'unreadable'
modelProfiles: ModelConnectionSettings[] modelProfiles: ModelConnectionSettings[]
defaultModelProfileId: string defaultModelProfileId: string
opencodeModelSource: RuntimeModelSource opencodeModelSource: RuntimeModelSource
continueModelSource: RuntimeModelSource continueModelSource: RuntimeModelSource
secureStorageAvailable: boolean secureStorageAvailable: boolean
toolApproval: RuntimeSettingsInput['toolApproval'] toolApproval: RuntimeSettingsInput['toolApproval']
warning?: string configured?: ConfiguredRuntimeSettings
warnings?: SettingsWarning[]
} }
export type ContextAttachment = ConversationAttachment export type ContextAttachment = ConversationAttachment
+3 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import { settingsWarningsSchema } from './settings-warning-contracts'
export const maximumDocumentExtractedCharacters = 5_000_000 export const maximumDocumentExtractedCharacters = 5_000_000
export const maximumDocumentOcrSectionCharacters = 1_000_000 export const maximumDocumentOcrSectionCharacters = 1_000_000
@@ -192,7 +193,8 @@ export const documentParsingSnapshotSchema = z
.object({ .object({
settings: documentParsingSettingsSchema, settings: documentParsingSettingsSchema,
status: documentParsingStatusSchema, status: documentParsingStatusSchema,
ocrModels: documentOcrModelSnapshotSchema ocrModels: documentOcrModelSnapshotSchema,
warnings: settingsWarningsSchema.optional()
}) })
.strict() .strict()
+55
View File
@@ -0,0 +1,55 @@
import { z } from 'zod'
export const settingsWarningCodeSchema = z.enum([
'application-settings-recovered',
'document-parsing-settings-recovered',
'capability-settings-recovered',
'runtime-settings-recovered',
'runtime-model-credential-unreadable',
'runtime-model-credential-binding-mismatch',
'runtime-embedding-credential-unreadable',
'runtime-embedding-credential-binding-mismatch',
'runtime-rerank-credential-unreadable',
'runtime-rerank-credential-binding-mismatch',
'channel-settings-recovered',
'channel-weixin-credential-unreadable',
'channel-weixin-secure-storage-unavailable',
'channel-weixin-legacy-binding-invalid',
'channel-wecom-environment-invalid',
'channel-dingtalk-environment-invalid',
'channel-wecom-credential-unreadable',
'channel-dingtalk-credential-unreadable',
'channel-runtime-selections-repaired'
])
export const settingsWarningSchema = z
.object({
code: settingsWarningCodeSchema,
subject: z.string().trim().min(1).max(120).optional(),
count: z.number().int().min(1).max(10_000).optional()
})
.strict()
export const settingsWarningsSchema = z
.array(settingsWarningSchema)
.max(32)
export type SettingsWarningCode = z.infer<
typeof settingsWarningCodeSchema
>
export type SettingsWarning = z.infer<typeof settingsWarningSchema>
export function settingsWarningKey(warning: SettingsWarning): string {
return JSON.stringify([
warning.code,
warning.subject ?? null,
warning.count ?? null
])
}
export function settingsWarningsEqual(
left: SettingsWarning,
right: SettingsWarning
): boolean {
return settingsWarningKey(left) === settingsWarningKey(right)
}