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 requiresToolApproval = false
readonly supportsToolExecution = true
readonly supportsScopedDataTools = true
private detection?: Promise<RuntimeBinaryDetection>
private readonly hostAdapters = new Map<
RuntimeSettings['continueMode'],
+221
View File
@@ -1096,7 +1096,9 @@ describe('ModelAgentRuntime', () => {
tool_calls: [
{
index: 0,
id: '',
function: {
name: '',
arguments: '"README.md"}'
}
}
@@ -1219,6 +1221,86 @@ describe('ModelAgentRuntime', () => {
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 () => {
const loadTool: ModelToolDefinition = {
name: 'mcp_load_tools',
@@ -1833,6 +1915,81 @@ describe('ModelAgentRuntime', () => {
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 () => {
const fetcher = vi.fn<typeof fetch>(async () =>
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 () => {
const response = {
choices: [
+29 -9
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto'
import type {
ApprovalDecision,
AgentRuntimeStatus,
@@ -725,21 +726,30 @@ function getChatToolImageCarrierContent(
]
}
function createToolCallId(): string {
return `goodbuddy_call_${randomBytes(16).toString('hex')}`
}
function parseToolCallIdentity(
id: unknown,
name: unknown
name: unknown,
fallbackId?: unknown
): { id: string; name: string } {
const resolvedId =
typeof id === 'string' && id.length > 0
? id
: typeof fallbackId === 'string' && fallbackId.length > 0
? fallbackId
: createToolCallId()
if (
typeof id !== 'string' ||
id.length === 0 ||
id.length > 256 ||
resolvedId.length > 256 ||
typeof name !== 'string' ||
name.length === 0 ||
name.length > 128
) {
throw new Error('模型返回了无效的工具调用标识')
throw new Error('模型返回了无效的工具调用标识或名称')
}
return { id, name }
return { id: resolvedId, name }
}
function parseModelToolResponse(
@@ -771,6 +781,7 @@ function parseModelToolResponse(
reasoning.push(record.thinking)
} else if (record.type === 'tool_use') {
const identity = parseToolCallIdentity(record.id, record.name)
record.id = identity.id
toolCalls.push({
...identity,
arguments: parseToolArguments(record.input)
@@ -845,8 +856,10 @@ function parseModelToolResponse(
} else if (output.type === 'function_call') {
const identity = parseToolCallIdentity(
output.call_id,
output.name
output.name,
output.id
)
output.call_id = identity.id
toolCalls.push({
...identity,
arguments: parseToolArguments(output.arguments)
@@ -893,6 +906,7 @@ function parseModelToolResponse(
toolCall.id,
functionCall.name
)
toolCall.id = identity.id
toolCalls.push({
...identity,
arguments: parseToolArguments(functionCall.arguments)
@@ -1112,6 +1126,10 @@ export class ModelAgentRuntime implements AgentRuntime {
return this.capability === 'chat'
}
get supportsScopedDataTools(): boolean {
return this.capability === 'chat'
}
private isConfigured(): boolean {
return (
this.options.authentication === 'none' ||
@@ -1665,11 +1683,13 @@ export class ModelAgentRuntime implements AgentRuntime {
? functionDelta.arguments
: ''),
id:
typeof toolDelta?.id === 'string'
typeof toolDelta?.id === 'string' &&
toolDelta.id.length > 0
? toolDelta.id
: current.id,
name:
typeof functionDelta?.name === 'string'
typeof functionDelta?.name === 'string' &&
functionDelta.name.length > 0
? functionDelta.name
: current.name
}
+4
View File
@@ -565,6 +565,10 @@ export class OpenCodeRuntime implements AgentRuntime {
return this.options.embedded && !this.options.baseUrl
}
get supportsScopedDataTools(): boolean {
return this.usesEmbeddedPermissionMediation()
}
private async acquireEmbeddedRun(
signal: AbortSignal
): Promise<() => void> {
+4
View File
@@ -45,6 +45,10 @@ export class AgentRuntimeController implements AgentRuntime {
return this.current.runtime.supportsToolExecution
}
get supportsScopedDataTools(): boolean {
return this.current.runtime.supportsScopedDataTools !== false
}
get capability(): AgentRuntime['capability'] {
return this.current.runtime.capability
}
+2
View File
@@ -51,6 +51,8 @@ export interface AgentRuntime {
readonly runtimeId?: AgentRuntimeStatus['id']
readonly requiresToolApproval: boolean
readonly supportsToolExecution: boolean
/** Whether request-scoped GoodBuddy data tools can reach this runtime. */
readonly supportsScopedDataTools?: boolean
readonly capability?: 'chat' | 'image-generation'
getStatus(): Promise<AgentRuntimeStatus>
testConnection?(): Promise<AgentRuntimeStatus>
+1
View File
@@ -11,6 +11,7 @@ export class UnconfiguredAgentRuntime implements AgentRuntime {
readonly runtimeId = 'setup'
readonly requiresToolApproval = false
readonly supportsToolExecution = false
readonly supportsScopedDataTools = false
getStatus(): Promise<AgentRuntimeStatus> {
return Promise.resolve({
+23 -3
View File
@@ -266,9 +266,10 @@ describe('ApplicationSettingsStore', () => {
const { directory, filePath, store } = await createStore()
await writeFile(filePath, data, 'utf8')
await expect(store.get()).resolves.toEqual(
defaultApplicationSettings
)
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
warnings: [{ code: 'application-settings-recovered' }]
})
const entries = await readdir(directory)
expect(entries).toHaveLength(1)
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 () => {
const { directory } = await createStore()
const filePath = join(directory, 'settings-directory')
+32 -51
View File
@@ -1,12 +1,4 @@
import {
mkdir,
readFile,
rename,
rm,
writeFile
} from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
import { dirname } from 'node:path'
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
import {
applicationSettingsSchema,
@@ -14,6 +6,14 @@ import {
type ApplicationSettings
} from '../shared/application-settings-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 {
applicationSettingsSchema,
applicationSettingsUpdateSchema
@@ -70,36 +70,19 @@ export const defaultApplicationSettings: ApplicationSettings = {
magicNoteCommentFormat: 'combined'
}
function isMissingFile(error: unknown): boolean {
return (
error !== null &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
}
export class ApplicationSettingsStore {
private settings?: StoredApplicationSettings
private settingsLoad?: Promise<StoredApplicationSettings>
private warnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {}
private async isolateCorruptFile(): Promise<void> {
const isolatedPath =
`${this.filePath}.corrupt-${Date.now()}-` +
randomBytes(6).toString('hex')
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 }
)
}
}
await isolateCorruptSettingsFile(
this.filePath,
'Application settings are corrupt and could not be isolated'
)
}
private async loadStored(): Promise<StoredApplicationSettings> {
@@ -122,6 +105,7 @@ export class ApplicationSettingsStore {
parsed = JSON.parse(contents) as unknown
} catch {
await this.isolateCorruptFile()
this.warnings = [{ code: 'application-settings-recovered' }]
this.settings = {
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null,
@@ -129,6 +113,12 @@ export class ApplicationSettingsStore {
}
return this.settings
}
assertSupportedSettingsVersion(
parsed,
CURRENT_SETTINGS_VERSION,
(version) =>
`当前 GoodBuddy 不支持应用设置版本 ${version},请升级应用后重试`
)
const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) {
const versionFourResult =
@@ -179,6 +169,7 @@ export class ApplicationSettingsStore {
return this.settings
}
await this.isolateCorruptFile()
this.warnings = [{ code: 'application-settings-recovered' }]
this.settings = {
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null,
@@ -188,7 +179,10 @@ export class ApplicationSettingsStore {
}
this.settings = result.data
} catch (error) {
if (!isMissingFile(error)) {
if (error instanceof UnsupportedSettingsVersionError) {
throw error
}
if (!isMissingFileError(error)) {
throw new Error('Application settings could not be read', {
cause: error
})
@@ -208,7 +202,10 @@ export class ApplicationSettingsStore {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
magicNotesEnabled: stored.magicNotesEnabled,
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> {
await mkdir(dirname(this.filePath), { recursive: true })
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 })
}
await writeJsonFileAtomically(this.filePath, next)
this.settings = next
}
@@ -248,6 +228,7 @@ export class ApplicationSettingsStore {
version: CURRENT_SETTINGS_VERSION
}
await this.persist(next)
this.warnings = []
return {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
magicNotesEnabled: next.magicNotesEnabled,
@@ -1386,7 +1386,7 @@ describe('AssistantDatabase', () => {
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 removedProfileId =
'00000000-0000-4000-8000-000000000291'
@@ -1478,7 +1478,7 @@ describe('AssistantDatabase', () => {
},
continueModelSource: { kind: 'platform' }
})
).toBe(7)
).toBe(4)
expect(
database
.listConversations()
@@ -1486,9 +1486,9 @@ describe('AssistantDatabase', () => {
.sort((left, right) => left.title.localeCompare(right.title))
.map((conversation) => conversation.runtimeSelection)
).toEqual([
{ provider: 'model', profileId: defaultProfileId },
{ provider: 'opencode', profileId: runtimeProfileId },
{ provider: 'continue' },
{ provider: 'model', profileId: removedProfileId },
{ provider: 'opencode', profileId: removedProfileId },
{ provider: 'continue', profileId: removedProfileId },
{ provider: 'model', profileId: runtimeProfileId }
])
expect(database.getProject(channelProject.id).runtimeSelection).toEqual({
+3 -5
View File
@@ -42,7 +42,6 @@ import {
import {
agentRuntimeSelectionKey,
agentRuntimeSelectionSchema,
repairAgentRuntimeSelection,
repairChannelRuntimeSelection,
type AgentRuntimeSelection,
type RuntimeSelectionRepairSettings
@@ -1363,7 +1362,8 @@ export class AssistantDatabase {
.prepare(
`SELECT id, runtime_selection_json, channel
FROM conversations
WHERE runtime_selection_json IS NOT NULL`
WHERE runtime_selection_json IS NOT NULL
AND channel IS NOT NULL`
)
.all() as Array<{
id: string
@@ -1410,9 +1410,7 @@ export class AssistantDatabase {
if (!current) {
continue
}
const next = conversation.channel
? repairChannelRuntimeSelection(current, settings)
: repairAgentRuntimeSelection(current, settings)
const next = repairChannelRuntimeSelection(current, settings)
if (
agentRuntimeSelectionKey(next) ===
agentRuntimeSelectionKey(current)
@@ -80,6 +80,32 @@ describe('RemoteDelegationService', () => {
).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 () => {
const records = new Map<
string,
@@ -157,7 +183,7 @@ describe('RemoteDelegationService', () => {
const polling = service.pollOnce()
await vi.waitFor(() => expect(observedSignal).toBeDefined())
service.stop()
await service.stop()
await expect(polling).rejects.toBeDefined()
expect(observedSignal?.aborted).toBe(true)
@@ -157,7 +157,7 @@ export class RemoteDelegationService {
private readonly pendingResults = new Map<string, RemoteResult>()
private interval?: NodeJS.Timeout
private activeRequest?: AbortController
private polling = false
private activePoll?: Promise<void>
constructor(private readonly options: RemoteDelegationOptions) {
this.endpoint = normalizeEndpoint(options.endpoint)
@@ -179,19 +179,29 @@ export class RemoteDelegationService {
void this.pollOnce().catch(() => undefined)
}
stop(): void {
async stop(): Promise<void> {
if (this.interval) {
clearInterval(this.interval)
this.interval = undefined
}
this.activeRequest?.abort()
await this.activePoll?.catch(() => undefined)
}
async pollOnce(): Promise<void> {
if (this.polling) {
return
pollOnce(): Promise<void> {
if (this.activePoll) {
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()
this.activeRequest = controller
try {
@@ -260,7 +270,6 @@ export class RemoteDelegationService {
if (this.activeRequest === controller) {
this.activeRequest = undefined
}
this.polling = false
}
}
@@ -3,10 +3,7 @@ import {
lstat,
mkdir,
readFile,
realpath,
rename,
rm,
writeFile
realpath
} from 'node:fs/promises'
import { isAbsolute, join, relative, resolve } from 'node:path'
import { z } from 'zod'
@@ -14,6 +11,10 @@ import {
browserProfileIdSchema,
browserProfileNameSchema
} from '../../shared/capability-contracts'
import {
isMissingFileError,
writeJsonFileAtomically
} from '../settings-file-utils'
const MAX_PROFILES = 32
const MAX_REFERENCES = 64
@@ -204,12 +205,7 @@ export class FileBrowserProfileStore implements BrowserProfileStore {
}
return JSON.parse(await readFile(filePath, 'utf8')) as unknown
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
if (isMissingFileError(error)) {
return undefined
}
throw error
@@ -217,36 +213,22 @@ export class FileBrowserProfileStore implements BrowserProfileStore {
}
async save(state: BrowserProfileState): Promise<void> {
const { root, filePath } = await this.prepareRoot()
const { filePath } = await this.prepareRoot()
try {
const targetDetails = await lstat(filePath)
if (targetDetails.isSymbolicLink() || !targetDetails.isFile()) {
throw new Error('Browser profile storage file must be a regular file')
}
} catch (error) {
if (
!(
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
) {
if (!isMissingFileError(error)) {
throw error
}
}
const temporaryPath = join(root, `.${this.fileName}.${randomUUID()}.tmp`)
try {
await writeFile(
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 })
}
await writeJsonFileAtomically(
filePath,
browserProfileStateSchema.parse(state)
)
}
}
@@ -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 { join } from 'node:path'
import { strToU8, zipSync } from 'fflate'
@@ -847,6 +854,98 @@ describe('CapabilityService', () => {
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 () => {
const { service } = await createService({
platform: 'darwin',
+82 -65
View File
@@ -38,6 +38,21 @@ import {
type RuntimeTarget,
type SkillSummary
} 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 {
BrowserProfileService,
FileBrowserProfileStore,
@@ -90,13 +105,8 @@ const skillStateSchema = z
})
.strict()
const encryptedSecretSchema = z
.object({
formatVersion: z.literal(1),
scheme: z.literal('electron-safe-storage'),
ciphertextBase64: z.string()
})
.optional()
const encryptedSecretSchema =
encryptedSettingsCredentialSchema.optional()
const storedMcpCommonShape = {
id: mcpServerIdSchema,
@@ -199,11 +209,7 @@ const secretPayloadSchema = z
})
.strict()
export type CapabilityCipher = {
isAvailable: () => boolean
encrypt: (value: string) => Buffer
decrypt: (value: Buffer) => string
}
export type CapabilityCipher = SettingsCredentialCipher
export type ResolvedMcpServer = McpServerSummary & {
secret?: string
@@ -226,6 +232,7 @@ export type CapabilityServiceOptions = Readonly<{
browserProfiles?: BrowserProfileService
diagnostics?: CapabilityDiagnostics
availableComputerCapabilityImplementations?: readonly ComputerCapabilityImplementationKind[]
settingsFileOperations?: Partial<SettingsFileOperations>
}>
function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabilities'] {
@@ -241,12 +248,14 @@ function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabili
}
}
function emptyStoredCapabilities(): StoredCapabilities {
function emptyStoredCapabilities(
webSearchEnabled = true
): StoredCapabilities {
return {
version: 4,
skills: {},
mcpServers: [],
webSearch: { enabled: true },
webSearch: { enabled: webSearchEnabled },
computerCapabilities: defaultComputerCapabilityStates()
}
}
@@ -303,12 +312,7 @@ async function listSkills(
try {
entries = await readdir(root, { withFileTypes: true })
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
if (isMissingFileError(error)) {
return []
}
throw error
@@ -550,12 +554,14 @@ async function extractSkillZip(
export class CapabilityService {
private state?: StoredCapabilities
private loadPromise?: Promise<StoredCapabilities>
private warnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve()
private readonly platform: NodeJS.Platform
private readonly architecture: string
private readonly electronTarget: boolean
private readonly browserProfiles: BrowserProfileService
private readonly diagnostics: CapabilityDiagnostics
private readonly settingsFileOperations?: Partial<SettingsFileOperations>
private readonly availableComputerCapabilityImplementations: ReadonlySet<ComputerCapabilityImplementationKind>
constructor(
@@ -574,6 +580,7 @@ export class CapabilityService {
'managed-browser-driver'
]
)
this.settingsFileOperations = options.settingsFileOperations
this.browserProfiles =
options.browserProfiles ??
new BrowserProfileService(
@@ -635,6 +642,9 @@ export class CapabilityService {
let shouldPersist = false
try {
const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown
assertSupportedSettingsVersion(raw, 4, (version) =>
`当前 GoodBuddy 不支持能力设置版本 ${version},请升级应用后重试`
)
const version = z
.object({
version: z.union([
@@ -676,19 +686,21 @@ export class CapabilityService {
loaded = storedCapabilitiesSchema.parse(raw)
}
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
if (error instanceof UnsupportedSettingsVersionError) {
throw error
}
if (isMissingFileError(error)) {
loaded = emptyStoredCapabilities()
} else {
await rename(
await isolateCorruptSettingsFile(
this.filePath,
`${this.filePath}.corrupt-${Date.now()}`
).catch(() => undefined)
loaded = emptyStoredCapabilities()
'能力设置已损坏且无法隔离',
Date.now,
this.settingsFileOperations
)
this.warnings = [{ code: 'capability-settings-recovered' }]
loaded = emptyStoredCapabilities(false)
shouldPersist = true
}
}
const migrateMcpAssignments = loaded.mcpServers.some((server) =>
@@ -741,17 +753,27 @@ export class CapabilityService {
private async persist(state: StoredCapabilities): Promise<void> {
const validated = storedCapabilitiesSchema.parse(state)
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.${process.pid}.tmp`
await writeFile(
temporaryPath,
`${JSON.stringify(validated, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 }
await writeJsonFileAtomically(
this.filePath,
validated,
this.settingsFileOperations
)
await rename(temporaryPath, this.filePath)
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<
Array<Omit<SkillSummary, 'enabled' | 'assignments'>>
> {
@@ -823,7 +845,10 @@ export class CapabilityService {
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> {
return this.queue(async () => {
const state = await this.load()
await this.persist({
await this.persistUserChange({
...state,
webSearch: { enabled }
})
@@ -898,7 +923,7 @@ export class CapabilityService {
}
}
const state = await this.load()
await this.persist({
await this.persistUserChange({
...state,
computerCapabilities: {
...state.computerCapabilities,
@@ -965,7 +990,7 @@ export class CapabilityService {
}
}
try {
await this.persist(nextState)
await this.persistUserChange(nextState)
} catch (error) {
if (profileId) {
try {
@@ -992,7 +1017,7 @@ export class CapabilityService {
previousProfileId,
reference
)
await this.persist(state)
await this.persistUserChange(state)
if (profileId) {
await this.browserProfiles.removeReference(
profileId,
@@ -1064,6 +1089,7 @@ export class CapabilityService {
await this.browserProfiles.createProfile(
browserProfileNameSchema.parse(name)
)
this.clearRecoveryWarnings()
return this.getSnapshot()
})
}
@@ -1077,6 +1103,7 @@ export class CapabilityService {
browserProfileIdSchema.parse(profileId),
browserProfileNameSchema.parse(name)
)
this.clearRecoveryWarnings()
return this.getSnapshot()
})
}
@@ -1086,6 +1113,7 @@ export class CapabilityService {
await this.browserProfiles.setDefaultProfile(
browserProfileIdSchema.parse(profileId)
)
this.clearRecoveryWarnings()
return this.getSnapshot()
})
}
@@ -1095,6 +1123,7 @@ export class CapabilityService {
await this.browserProfiles.deleteProfile(
browserProfileIdSchema.parse(profileId)
)
this.clearRecoveryWarnings()
return this.getSnapshot()
})
}
@@ -1125,7 +1154,7 @@ export class CapabilityService {
await readSkill(temporaryPath, 'imported', skill.id)
await rename(temporaryPath, targetPath)
const state = await this.load()
await this.persist({
await this.persistUserChange({
...state,
skills: {
...state.skills,
@@ -1223,7 +1252,7 @@ export class CapabilityService {
const state = await this.load()
const skills = { ...state.skills }
delete skills[id]
await this.persist({ ...state, skills })
await this.persistUserChange({ ...state, skills })
return this.getSnapshot()
})
}
@@ -1255,7 +1284,7 @@ export class CapabilityService {
throw new Error('Skill 不存在')
}
const state = await this.load()
await this.persist({
await this.persistUserChange({
...state,
skills: {
...state.skills,
@@ -1311,19 +1340,11 @@ export class CapabilityService {
if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,MCP 访问令牌未保存')
}
credential = {
formatVersion: 1 as const,
scheme: 'electron-safe-storage' as const,
ciphertextBase64: this.cipher
.encrypt(
JSON.stringify({
version: 1,
serverId: id,
secret: value.secret.value
})
)
.toString('base64')
}
credential = encryptSettingsCredential(this.cipher, {
version: 1,
serverId: id,
secret: value.secret.value
})
}
const stored: StoredMcpServer =
value.transport === 'stdio'
@@ -1354,7 +1375,7 @@ export class CapabilityService {
server.id === id ? stored : server
)
: [...state.mcpServers, stored]
await this.persist({ ...state, mcpServers: nextServers })
await this.persistUserChange({ ...state, mcpServers: nextServers })
return this.getSnapshot()
})
}
@@ -1366,7 +1387,7 @@ export class CapabilityService {
if (!state.mcpServers.some((server) => server.id === id)) {
throw new Error('MCP Server 不存在')
}
await this.persist({
await this.persistUserChange({
...state,
mcpServers: state.mcpServers.filter((server) => server.id !== id)
})
@@ -1388,11 +1409,7 @@ export class CapabilityService {
}
try {
const payload = secretPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(server.credential.ciphertextBase64, 'base64')
)
)
decryptSettingsCredential(this.cipher, server.credential)
)
if (payload.serverId === id) {
secret = payload.secret
@@ -192,10 +192,14 @@ describe('ChannelSettingsStore', () => {
)
const initial = await store.snapshot()
expect(initial.warning).toContain('已损坏')
expect(initial.warnings).toContainEqual({
code: 'channel-settings-recovered'
})
expect(
await readdir(join(filePath, '..'))
).toContain('channel-settings.json.corrupt-1234')
(await readdir(join(filePath, '..'))).some((name) =>
name.startsWith('channel-settings.json.corrupt-1234-')
)
).toBe(true)
await store.apply({
dingtalk: {
@@ -215,6 +219,9 @@ describe('ChannelSettingsStore', () => {
expect((await readdir(join(filePath, '..'))).some(
(name) => name.endsWith('.tmp')
)).toBe(false)
await expect(store.snapshot()).resolves.not.toHaveProperty(
'warnings'
)
})
it('encrypts Weixin binding credentials and removes them on disconnect', async () => {
@@ -251,4 +258,293 @@ describe('ChannelSettingsStore', () => {
})
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 {
mkdir,
readFile,
rename,
rm,
writeFile
} from 'node:fs/promises'
import { dirname } from 'node:path'
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
import {
CHANNEL_SETTINGS_LIMITS,
@@ -21,17 +13,28 @@ import {
type WeComChannelSettingsInput
} from '../../shared/channel-settings-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 {
isAvailable(): boolean
encrypt(value: string): Buffer
decrypt(value: Buffer): string
}
export type ChannelCredentialCipher = SettingsCredentialCipher
const encryptedCredentialSchema = z
.object({
formatVersion: z.literal(1),
scheme: z.literal('electron-safe-storage'),
const encryptedCredentialSchema = encryptedSettingsCredentialSchema
.extend({
ciphertextBase64: z
.string()
.min(1)
@@ -112,6 +115,8 @@ type StoredEncryptedCredential = z.infer<
typeof encryptedCredentialSchema
>
class DeferredWeixinMigrationError extends Error {}
const credentialPayloadSchema = z
.object({
version: z.literal(1),
@@ -161,7 +166,7 @@ type EnvironmentChannel = {
secret?: string
allowedSenderIds: readonly string[]
allowGroupMessages: boolean
error?: string
warning?: SettingsWarning
}
export type ResolvedChannelSettings =
@@ -184,7 +189,7 @@ export type ResolvedChannelSettings =
secret?: string
allowedSenderIds: readonly string[]
allowGroupMessages: boolean
source: 'none' | 'encrypted' | 'environment'
source: 'none' | 'encrypted' | 'environment' | 'unreadable'
readOnly: boolean
}
| {
@@ -194,7 +199,7 @@ export type ResolvedChannelSettings =
secret?: string
allowedSenderIds: readonly string[]
allowGroupMessages: boolean
source: 'none' | 'encrypted' | 'environment'
source: 'none' | 'encrypted' | 'environment' | 'unreadable'
readOnly: boolean
}
@@ -221,15 +226,6 @@ const defaultStatus = (enabled: boolean): ChannelRuntimeStatus => ({
state: enabled ? 'stopped' : 'disabled'
})
function isMissingFile(error: unknown): boolean {
return (
error !== null &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
}
function boundedEnvironmentValue(
environment: NodeJS.ProcessEnv,
name: string,
@@ -319,15 +315,27 @@ export type WeixinBinding = z.infer<typeof weixinBindingSchema>
export class ChannelSettingsStore {
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 readonly environmentChannels: Record<
CredentialChannel,
EnvironmentChannel
>
constructor(
private readonly filePath: string,
private readonly cipher: ChannelCredentialCipher,
private readonly environment: NodeJS.ProcessEnv = process.env,
private readonly now: () => number = Date.now
) {}
) {
this.environmentChannels = {
wecom: this.readEnvironmentChannel('wecom'),
dingtalk: this.readEnvironmentChannel('dingtalk')
}
}
async snapshot(
statuses: Partial<Record<ManagedChannel, ChannelRuntimeStatus>> = {}
@@ -339,9 +347,17 @@ export class ChannelSettingsStore {
])
const weComEnvironment = this.environmentChannel('wecom')
const dingTalkEnvironment = this.environmentChannel('dingtalk')
const environmentWarning =
weComEnvironment.error ?? dingTalkEnvironment.error
const warning = this.warning ?? environmentWarning
const warnings = [
...this.warnings,
...(this.runtimeRepairWarning ? [this.runtimeRepairWarning] : []),
...(weComEnvironment.warning ? [weComEnvironment.warning] : []),
...(dingTalkEnvironment.warning ? [dingTalkEnvironment.warning] : [])
].filter(
(warning, index, values) =>
values.findIndex(
(candidate) => settingsWarningsEqual(candidate, warning)
) === index
)
return {
weixin: {
enabled: weixin.enabled,
@@ -360,12 +376,9 @@ export class ChannelSettingsStore {
allowGroupMessages: wecom.allowGroupMessages,
status:
statuses.wecom ??
(weComEnvironment.error === undefined
(weComEnvironment.warning === undefined
? defaultStatus(wecom.enabled)
: {
state: 'error',
lastError: weComEnvironment.error
})
: { state: 'error' })
},
dingtalk: {
enabled: dingtalk.enabled,
@@ -377,17 +390,24 @@ export class ChannelSettingsStore {
allowGroupMessages: dingtalk.allowGroupMessages,
status:
statuses.dingtalk ??
(dingTalkEnvironment.error === undefined
(dingTalkEnvironment.warning === undefined
? defaultStatus(dingtalk.enabled)
: {
state: 'error',
lastError: dingTalkEnvironment.error
})
: { state: 'error' })
},
...(warning === undefined ? {} : { warning })
...(warnings.length > 0 ? { warnings } : {})
}
}
reportRuntimeSelectionRepairs(count: number): void {
this.runtimeRepairWarning =
count > 0
? {
code: 'channel-runtime-selections-repaired',
count
}
: undefined
}
getSnapshot(
statuses?: Partial<Record<ManagedChannel, ChannelRuntimeStatus>>
): Promise<ChannelSettingsSnapshot> {
@@ -409,9 +429,16 @@ export class ChannelSettingsStore {
const settings = await this.load()
const stored = settings.weixin
const binding = this.decryptWeixinBinding(stored)
if (this.temporarilyDisabledWeixin && binding) {
this.temporarilyDisabledWeixin = false
this.removeWarnings([
'channel-weixin-credential-unreadable',
'channel-weixin-secure-storage-unavailable'
])
}
return {
channel,
enabled: stored.enabled,
enabled: stored.enabled && !this.temporarilyDisabledWeixin,
accountId: binding?.accountId ?? '',
userId: binding?.userId ?? '',
baseUrl: binding?.baseUrl ?? '',
@@ -448,12 +475,18 @@ export class ChannelSettingsStore {
const settings = await this.load()
const stored = settings[channel]
const secret = this.decryptCredential(channel, stored)
const credentialUnreadable =
stored.credential !== undefined && secret === undefined
const common = {
enabled: stored.enabled,
...(secret === undefined ? {} : { secret }),
allowedSenderIds: [...stored.allowedSenderIds],
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
}
return channel === 'wecom'
@@ -484,7 +517,12 @@ export class ChannelSettingsStore {
}
await this.persist(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()
}
const operation = this.updateQueue.then(update, update)
@@ -504,7 +542,12 @@ export class ChannelSettingsStore {
}
await this.persist(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()
}
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('dingtalk', current.dingtalk)
await this.persist(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()
}
@@ -652,34 +713,47 @@ export class ChannelSettingsStore {
if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,无法保存通道 Secret')
}
const encrypted = this.cipher.encrypt(
JSON.stringify({ version: 1, channel, secret })
)
return {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: encrypted.toString('base64')
}
return encryptSettingsCredential(this.cipher, {
version: 1,
channel,
secret
})
}
private decryptCredential(
channel: CredentialChannel,
stored: StoredCredentialChannel
): string | undefined {
if (stored.credential === undefined || !this.cipher.isAvailable()) {
if (stored.credential === 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 {
const payload = credentialPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(stored.credential.ciphertextBase64, 'base64')
)
)
decryptSettingsCredential(this.cipher, stored.credential)
)
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 {
return undefined
return warn()
}
}
@@ -689,21 +763,14 @@ export class ChannelSettingsStore {
if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,无法保存微信绑定')
}
const encrypted = this.cipher.encrypt(
JSON.stringify({
version: 2,
channel: 'weixin',
accountId: binding.accountId,
userId: binding.userId,
baseUrl: binding.baseUrl,
token: binding.token
})
)
return {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: encrypted.toString('base64')
}
return encryptSettingsCredential(this.cipher, {
version: 2,
channel: 'weixin',
accountId: binding.accountId,
userId: binding.userId,
baseUrl: binding.baseUrl,
token: binding.token
})
}
private decryptWeixinBinding(
@@ -714,81 +781,38 @@ export class ChannelSettingsStore {
}
try {
return weixinCredentialPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(stored.credential.ciphertextBase64, 'base64')
)
)
decryptSettingsCredential(this.cipher, stored.credential)
)
} catch {
return undefined
}
}
private async load(): Promise<StoredSettings> {
private load(): Promise<StoredSettings> {
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 {
const raw: unknown = JSON.parse(await readFile(this.filePath, 'utf8'))
assertSupportedSettingsVersion(raw, 3, (version) =>
`当前 GoodBuddy 不支持通道设置版本 ${version},请升级应用后重试`
)
const current = storedSettingsSchema.safeParse(raw)
if (current.success) {
this.settings = current.data
this.settings = this.normalizeStoredSettings(current.data)
} else {
const versionTwo = versionTwoStoredSettingsSchema.safeParse(raw)
if (versionTwo.success) {
const legacyWeixin = versionTwo.data.weixin
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 =
'旧版微信绑定无法安全迁移,请重新扫码绑定'
}
this.settings = this.migrateVersionTwo(versionTwo.data)
} else {
const legacy = legacyStoredSettingsSchema.parse(raw)
this.settings = {
@@ -803,38 +827,114 @@ export class ChannelSettingsStore {
await this.persist(this.settings)
}
} catch (error) {
if (!isMissingFile(error)) {
this.warning = '通道设置文件已损坏,已隔离原文件并恢复默认设置'
await rename(
if (
error instanceof UnsupportedSettingsVersionError ||
error instanceof DeferredWeixinMigrationError
) {
throw error
}
if (!isMissingFileError(error)) {
await isolateCorruptSettingsFile(
this.filePath,
`${this.filePath}.corrupt-${this.now()}`
).catch(() => undefined)
'通道设置已损坏且无法隔离',
this.now
)
this.warnings = [{ code: 'channel-settings-recovered' }]
}
this.settings = cloneStored(defaultStoredSettings)
}
return this.settings
}
private async persist(settings: StoredSettings): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`
try {
await writeFile(
temporaryPath,
`${JSON.stringify(settings, null, 2)}\n`,
{
encoding: 'utf8',
mode: 0o600,
flag: 'wx'
}
private normalizeStoredSettings(settings: StoredSettings): StoredSettings {
if (
settings.weixin.credential &&
this.decryptWeixinBinding(settings.weixin) === undefined
) {
this.temporarilyDisabledWeixin = true
this.addWarning({
code: this.cipher.isAvailable()
? 'channel-weixin-credential-unreadable'
: 'channel-weixin-secure-storage-unavailable'
})
} 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 {
await rm(temporaryPath, { force: true })
}
let token: string | undefined
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 {
return this.environmentChannels[channel]
}
private readEnvironmentChannel(
channel: CredentialChannel
): EnvironmentChannel {
const prefix =
channel === 'wecom' ? 'GOODBUDDY_WECOM' : 'GOODBUDDY_DINGTALK'
const idName =
@@ -903,11 +1003,31 @@ export class ChannelSettingsStore {
senders.value.length > 0
? {}
: {
error:
channel === 'wecom'
? '企业微信环境变量配置无效或不完整'
: '钉钉环境变量配置无效或不完整'
warning: {
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()
}
stop(): void {
async stop(): Promise<void> {
this.generation += 1
this.stopClient()
this.snapshotValue = { status: 'stopped' }
await this.credentialSave
}
private handleMessage(
@@ -83,12 +84,13 @@ export class WechatBindingController {
return
}
if (message.type === 'credential') {
if (this.savingCredential) {
return
}
this.savingCredential = true
this.credentialSave = this.credentialSave
this.stopClient()
const save = this.credentialSave
.then(async () => {
if (generation !== this.generation) {
return
}
this.stopClient()
await this.store.saveWeixinBinding({
accountId: message.accountId,
@@ -120,9 +122,13 @@ export class WechatBindingController {
: '微信绑定保存失败'
})
})
const trackedSave = save
.finally(() => {
this.savingCredential = false
if (this.credentialSave === trackedSave) {
this.savingCredential = false
}
})
this.credentialSave = trackedSave
return
}
if (message.type === 'qr') {
+4 -1
View File
@@ -170,7 +170,10 @@ export class DocumentParsingService {
conversionAvailable: false,
localOcr
},
ocrModels
ocrModels,
...(this.settingsStore.getWarnings().length > 0
? { warnings: [...this.settingsStore.getWarnings()] }
: {})
})
}
@@ -47,6 +47,7 @@ describe('DocumentParsingSettingsStore', () => {
await expect(store.get()).resolves.toEqual(
defaultDocumentParsingSettings
)
expect(store.getWarnings()).toEqual([])
await expect(readdir(directory)).resolves.toEqual([])
})
@@ -143,10 +144,32 @@ describe('DocumentParsingSettingsStore', () => {
await expect(store.get()).resolves.toEqual(
defaultDocumentParsingSettings
)
expect(store.getWarnings()).toEqual([
{ code: 'document-parsing-settings-recovered' }
])
const entries = await readdir(directory)
expect(entries).toHaveLength(1)
expect(entries[0]).toMatch(
/^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 {
mkdir,
readFile,
rename,
rm,
writeFile
} from 'node:fs/promises'
import { dirname } from 'node:path'
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
import {
documentParsingSettingsSchema,
documentParsingSettingsUpdateSchema,
type DocumentParsingSettings
} 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
@@ -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 {
private settings?: StoredDocumentParsingSettings
private settingsLoad?: Promise<StoredDocumentParsingSettings>
private warnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {}
private async isolateCorruptFile(): Promise<void> {
const isolatedPath =
`${this.filePath}.corrupt-${Date.now()}-` +
randomBytes(6).toString('hex')
try {
await rename(this.filePath, isolatedPath)
} catch (error) {
if (!isMissingFile(error)) {
throw new Error('文档解析设置损坏且无法隔离', {
cause: error
})
}
}
await isolateCorruptSettingsFile(
this.filePath,
'文档解析设置损坏且无法隔离'
)
}
private async loadStored(): Promise<StoredDocumentParsingSettings> {
private loadStored(): Promise<StoredDocumentParsingSettings> {
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 {
const contents = await readFile(this.filePath, 'utf8')
let parsed: unknown
@@ -137,12 +131,19 @@ export class DocumentParsingSettingsStore {
parsed = JSON.parse(contents) as unknown
} catch {
await this.isolateCorruptFile()
this.warnings = [{ code: 'document-parsing-settings-recovered' }]
this.settings = {
version: CURRENT_SETTINGS_VERSION,
...defaultDocumentParsingSettings
}
return this.settings
}
assertSupportedSettingsVersion(
parsed,
CURRENT_SETTINGS_VERSION,
(version) =>
`当前 GoodBuddy 不支持文档解析设置版本 ${version},请升级应用后重试`
)
const result =
storedDocumentParsingSettingsSchema.safeParse(parsed)
if (!result.success) {
@@ -170,6 +171,7 @@ export class DocumentParsingSettingsStore {
return this.settings
}
await this.isolateCorruptFile()
this.warnings = [{ code: 'document-parsing-settings-recovered' }]
this.settings = {
version: CURRENT_SETTINGS_VERSION,
...defaultDocumentParsingSettings
@@ -178,7 +180,10 @@ export class DocumentParsingSettingsStore {
}
this.settings = result.data
} catch (error) {
if (!isMissingFile(error)) {
if (error instanceof UnsupportedSettingsVersionError) {
throw error
}
if (!isMissingFileError(error)) {
throw new Error('无法读取文档解析设置', { cause: error })
}
this.settings = {
@@ -195,6 +200,10 @@ export class DocumentParsingSettingsStore {
return documentParsingSettingsSchema.parse(settings)
}
getWarnings(): readonly SettingsWarning[] {
return this.warnings
}
update(input: unknown): Promise<DocumentParsingSettings> {
const operation = this.updateQueue.then(async () => {
const updates = documentParsingSettingsUpdateSchema.parse(input)
@@ -202,25 +211,9 @@ export class DocumentParsingSettingsStore {
version: CURRENT_SETTINGS_VERSION,
...updates
}
await mkdir(dirname(this.filePath), { recursive: true })
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 })
}
await writeJsonFileAtomically(this.filePath, next)
this.settings = next
this.warnings = []
return this.get()
})
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 { GlobalTlsPolicy } from './global-tls-policy'
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 { DocumentOcrModelManager } from './document-ocr-model-manager'
import { DocumentOcrBroker } from './document-ocr-broker'
@@ -104,6 +107,7 @@ let browserService: BrowserService | undefined
let globalTlsPolicy: GlobalTlsPolicy | undefined
let documentOcrBroker: DocumentOcrBroker | undefined
let documentOcrModelManager: DocumentOcrModelManager | undefined
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
function createEmbeddingProvider(
settings: ResolvedRuntimeSettings
@@ -425,8 +429,10 @@ if (hasSingleInstanceLock) {
defaultWorkspace,
initialRuntimeSettings.defaultModelProfileId
)
assistantDatabase.repairConversationRuntimeSelections(
initialRuntimeSettings
channelSettingsStore.reportRuntimeSelectionRepairs(
assistantDatabase.repairConversationRuntimeSelections(
initialRuntimeSettings
)
)
knowledgeGateway = new KnowledgeMcpGateway(knowledgeService, {
magicNotesDatabase: assistantDatabase
@@ -483,8 +489,11 @@ if (hasSingleInstanceLock) {
webSearchEnabled: webSearchCapability?.enabled
})
}
const createConfiguredRuntime = async (): Promise<AgentRuntime> => {
const settings = await settingsStore.getResolvedSettings()
const createConfiguredRuntime = async (
resolvedSettings?: ResolvedRuntimeSettings
): Promise<AgentRuntime> => {
const settings =
resolvedSettings ?? await settingsStore.getResolvedSettings()
return createRuntimeWithCapabilities(
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(
mainWindow,
runtime,
@@ -533,27 +577,7 @@ if (hasSingleInstanceLock) {
assistantDatabase,
approvalBroker,
bundledRuntimePaths,
async () => {
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)
)
},
reconfigureRuntimes,
async () => {
await browserService?.clearSessions()
},
@@ -604,27 +628,28 @@ app.on('before-quit', (event) => {
cleanupStarted = true
void (async () => {
try {
const cleanup = Promise.allSettled([
Promise.resolve().then(() => removeIpcHandlers?.()),
Promise.resolve().then(() => runtime?.dispose()),
Promise.resolve().then(() => selectedRuntimeManager?.dispose()),
Promise.resolve().then(() => knowledgeGateway?.dispose()),
Promise.resolve().then(() => knowledgeService?.dispose()),
Promise.resolve().then(() => browserService?.dispose()),
Promise.resolve().then(() => globalTlsPolicy?.dispose()),
Promise.resolve().then(() => documentOcrModelManager?.dispose()),
Promise.resolve().then(() => documentOcrBroker?.dispose())
const cleanup = settleCleanupPhases([
[() => removeIpcHandlers?.()],
[() => stopRuntimeReconfiguration?.()],
[
() => runtime?.dispose(),
() => selectedRuntimeManager?.dispose(),
() => browserService?.dispose(),
() => globalTlsPolicy?.dispose(),
() => documentOcrModelManager?.dispose(),
() => documentOcrBroker?.dispose()
],
[() => knowledgeGateway?.dispose()],
[() => knowledgeService?.dispose()]
])
globalShortcut.unregisterAll()
tray?.destroy()
await waitForCleanup(cleanup, 8_000)
} finally {
try {
await runCleanupBeforeDeadline(cleanup, 8_000, () => {
assistantDatabase?.close()
} finally {
cleanupComplete = true
app.exit(0)
}
})
} finally {
cleanupComplete = true
app.exit(0)
}
})()
})
+697 -6
View File
@@ -10,6 +10,12 @@ import { AssistantDatabase } from './assistant/assistant-database'
import { registerIpcHandlers } from './ipc'
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 handlers = new Map<string, InvokeHandler>()
@@ -274,6 +280,18 @@ describe('registerIpcHandlers computer capabilities', () => {
).toThrow()
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(() =>
electronMocks.handlers.get(
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', () => {
afterEach(() => {
electronMocks.handlers.clear()
@@ -1193,7 +1368,11 @@ describe('registerIpcHandlers Runtime config actions', () => {
await writeFile(configPath, 'name: Test', 'utf8')
const getPublicSettings = vi.fn(async () => ({
opencodeConfigPath: '',
continueConfigPath: configPath
continueConfigPath: process.execPath,
configured: {
opencodeConfigPath: '',
continueConfigPath: configPath
}
}))
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
@@ -1259,7 +1438,11 @@ describe('registerIpcHandlers Runtime config actions', () => {
getPublicSettings.mockResolvedValueOnce({
opencodeConfigPath: '',
continueConfigPath: process.execPath
continueConfigPath: configPath,
configured: {
opencodeConfigPath: '',
continueConfigPath: process.execPath
}
})
await expect(
electronMocks.handlers.get(
@@ -1571,7 +1754,7 @@ describe('registerIpcHandlers agent terminal state', () => {
profileId: '00000000-0000-4000-8000-000000000001'
},
kind: 'channel',
channel: 'wecom',
channel: 'wecom',
status: 'active',
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
@@ -1633,11 +1816,21 @@ describe('registerIpcHandlers agent terminal state', () => {
subagentSmartRoutingEnabled: smartRoutingEnabled
})
)
const getPolicySettings = vi.fn(
async (): Promise<Record<string, unknown>> => ({
toolApproval,
subagentSmartRoutingEnabled: smartRoutingEnabled
})
)
const getApplicationSettings = vi.fn(async () => ({
magicNotesEnabled
}))
const dispose = registerIpcHandlers(
window as never,
runtime as never,
'CommandOrControl+Shift+Space',
{
getPolicySettings,
getResolvedSettings
} as never,
{} as never,
@@ -1654,7 +1847,7 @@ describe('registerIpcHandlers agent terminal state', () => {
subagentService as never,
undefined,
{
get: vi.fn(async () => ({ magicNotesEnabled }))
get: getApplicationSettings
} as never,
undefined,
undefined,
@@ -1668,6 +1861,8 @@ describe('registerIpcHandlers agent terminal state', () => {
assistantDatabase,
contextManager,
dispose,
getApplicationSettings,
getPolicySettings,
getResolvedSettings,
clearHandler: electronMocks.handlers.get(
ipcChannels.appClearLocalData
@@ -1707,6 +1902,7 @@ describe('registerIpcHandlers agent terminal state', () => {
}
const knowledgeGateway = {
grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
@@ -1769,6 +1965,22 @@ describe('registerIpcHandlers agent terminal state', () => {
}
const knowledgeGateway = {
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(() => []),
revoke: vi.fn()
}
@@ -1831,6 +2043,74 @@ describe('registerIpcHandlers agent terminal state', () => {
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 () => {
const libraries = Array.from({ length: 101 }, (_, index) => ({
id: `00000000-0000-4000-8000-${index
@@ -1841,6 +2121,7 @@ describe('registerIpcHandlers agent terminal state', () => {
const listKnowledgeBases = vi.fn(() => libraries)
const knowledgeGateway = {
grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
@@ -1882,7 +2163,8 @@ describe('registerIpcHandlers agent terminal state', () => {
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
requestId,
[libraries[100]!.id],
expect.any(AbortSignal)
expect.any(AbortSignal),
'none'
)
await harness.dispose()
})
@@ -1912,6 +2194,7 @@ describe('registerIpcHandlers agent terminal state', () => {
}
const knowledgeGateway = {
grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
drainReferences: vi.fn(() => [reference]),
revoke: vi.fn()
}
@@ -1948,7 +2231,8 @@ describe('registerIpcHandlers agent terminal state', () => {
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
requestId,
[libraryId],
expect.any(AbortSignal)
expect.any(AbortSignal),
'none'
)
const publicEvents = harness.webContents.send.mock.calls
.filter(([channel]) => channel === ipcChannels.agentEvent)
@@ -2057,6 +2341,7 @@ describe('registerIpcHandlers agent terminal state', () => {
])
const knowledgeGateway = {
grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
@@ -2333,6 +2618,67 @@ describe('registerIpcHandlers agent terminal state', () => {
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 () => {
const lifecycle: string[] = []
let markStarted!: () => void
@@ -2389,6 +2735,48 @@ describe('registerIpcHandlers agent terminal state', () => {
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 () => {
const runtime = {
capability: 'chat',
@@ -2655,6 +3043,8 @@ describe('registerIpcHandlers agent terminal state', () => {
)
)
expect(runtime.run).not.toHaveBeenCalled()
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(subagentService.run).toHaveBeenCalledWith(
expect.objectContaining({ expert, routingMode: 'smart' })
)
@@ -2899,6 +3289,8 @@ describe('registerIpcHandlers agent terminal state', () => {
})
).resolves.toBe('deny')
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(harness.getPolicySettings).not.toHaveBeenCalled()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(harness.assistantDatabase.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: '企业微信远程请求',
@@ -2910,6 +3302,128 @@ describe('registerIpcHandlers agent terminal state', () => {
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 () => {
let receivedRequest:
| {
@@ -3191,6 +3705,179 @@ describe('registerIpcHandlers agent terminal state', () => {
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 () => {
let receivedAuthorize: unknown = 'not-called'
const configuredProfileId =
@@ -3374,6 +4061,8 @@ describe('registerIpcHandlers agent terminal state', () => {
expect(receivedAuthorize).toEqual(expect.any(Function))
expect(decision).toBe('once')
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(requestId, 'waiting_approval')
@@ -3429,6 +4118,8 @@ describe('registerIpcHandlers agent terminal state', () => {
)
expect(decision).toBe('deny')
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(harness.webContents.send).not.toHaveBeenCalledWith(
ipcChannels.agentEvent,
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'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
runtimeSettingsInputSchema,
type RuntimeSettingsInput
@@ -456,6 +456,29 @@ describe('RuntimeSettingsStore', () => {
).toBe(true)
})
it('rejects non-HTTP model profile URLs during legacy migration', async () => {
const { filePath, store } = await createStore()
await store.update(settings())
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as {
version: number
modelProfiles: Array<{ baseUrl: string }>
}
persisted.version = 6
persisted.modelProfiles[0]!.baseUrl = 'file:///tmp/model'
await writeFile(filePath, JSON.stringify(persisted), 'utf8')
const migratedStore = new RuntimeSettingsStore(filePath, cipher, {})
await expect(migratedStore.getPublicSettings()).resolves.toMatchObject({
provider: 'model',
warnings: [{ code: 'runtime-settings-recovered' }]
})
expect(
(await readdir(join(filePath, '..'))).some((name) =>
name.startsWith('runtime-settings.json.corrupt-')
)
).toBe(true)
})
it('encrypts an OpenAI-compatible embedding API key and binds it to the full endpoint', async () => {
const { filePath, store } = await createStore()
await store.update(
@@ -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 () => {
const { filePath, store } = await createStore()
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 () => {
const { filePath, store } = await createStore()
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 () => {
const { filePath, store } = await createStore()
const encryptedCredential = cipher
@@ -1319,11 +1540,103 @@ describe('RuntimeSettingsStore', () => {
await expect(store.getPublicSettings()).resolves.toMatchObject({
provider: 'model',
warning: expect.stringContaining('已损坏')
warnings: [{ code: 'runtime-settings-recovered' }]
})
const files = await readdir(join(filePath, '..'))
expect(
files.some((name) => name.startsWith('runtime-settings.json.corrupt-'))
).toBe(true)
})
it('distinguishes an unreadable saved credential from a missing credential', async () => {
const { filePath, store } = await createStore()
await store.update(
settings({
apiKey: {
action: 'replace',
value: 'credential-that-will-become-unreadable'
}
})
)
const unreadable = new RuntimeSettingsStore(
filePath,
{
...cipher,
decrypt: () => {
throw new Error('cannot decrypt')
}
},
{}
)
await expect(unreadable.getPublicSettings()).resolves.toMatchObject({
apiKeyConfigured: false,
credentialSource: 'unreadable',
modelProfiles: [
expect.objectContaining({
credentialSource: 'unreadable'
})
],
warnings: [
expect.objectContaining({
code: 'runtime-model-credential-unreadable',
subject: '默认模型'
})
]
})
})
it('clears credential warnings after secure storage recovers', async () => {
const { filePath, store } = await createStore()
await store.update(
settings({
apiKey: {
action: 'replace',
value: 'recoverable-model-credential'
},
knowledgeEmbeddingApiKey: {
action: 'replace',
value: 'recoverable-embedding-credential'
},
knowledgeRerankApiKey: {
action: 'replace',
value: 'recoverable-rerank-credential'
}
})
)
let decryptAvailable = false
const recoveringStore = new RuntimeSettingsStore(
filePath,
{
...cipher,
decrypt: (value) => {
if (!decryptAvailable) {
throw new Error('secure storage is temporarily unavailable')
}
return cipher.decrypt(value)
}
},
{}
)
await expect(recoveringStore.getPublicSettings()).resolves.toMatchObject({
warnings: expect.arrayContaining([
expect.objectContaining({
code: 'runtime-model-credential-unreadable'
}),
{ code: 'runtime-embedding-credential-unreadable' },
{ code: 'runtime-rerank-credential-unreadable' }
])
})
decryptAvailable = true
await expect(recoveringStore.getPublicSettings()).resolves.toMatchObject({
apiKeyConfigured: true,
knowledgeEmbeddingApiKeyConfigured: true,
knowledgeRerankApiKeyConfigured: true
})
expect((await recoveringStore.getPublicSettings()).warnings ?? []).toEqual(
[]
)
})
})
+358 -237
View File
@@ -1,14 +1,9 @@
import {
mkdir,
readFile,
realpath,
rename,
rm,
stat,
writeFile
stat
} from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname } from 'node:path'
import { z } from 'zod'
import {
continueModeSchema,
@@ -23,17 +18,28 @@ import {
runtimeProviderSchema,
runtimeSandboxModeSchema,
toolApprovalPolicySchema,
RuntimeSettings,
type RuntimeSettings,
type RuntimeSettingsInput
} 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
.object({
formatVersion: z.literal(1),
scheme: z.literal('electron-safe-storage'),
ciphertextBase64: z.string()
})
.optional()
const credentialSchema = encryptedSettingsCredentialSchema.optional()
const version4StoredSettingsSchema = z.object({
version: z.literal(4),
@@ -179,8 +185,6 @@ const storedSettingsSchema = version13StoredSettingsSchema
knowledgeRerankCredential: credentialSchema
})
class UnsupportedRuntimeSettingsVersionError extends Error {}
type StoredSettings = z.infer<typeof storedSettingsSchema>
type Version10StoredSettings = z.infer<
typeof version10StoredSettingsSchema
@@ -239,11 +243,7 @@ const embeddingCredentialPayloadSchema = z.object({
const rerankCredentialPayloadSchema = embeddingCredentialPayloadSchema
export type CredentialCipher = {
isAvailable: () => boolean
encrypt: (value: string) => Buffer
decrypt: (value: Buffer) => string
}
export type CredentialCipher = SettingsCredentialCipher
export type ResolvedRuntimeSettings = {
provider: RuntimeSettings['provider']
@@ -279,6 +279,11 @@ export type ResolvedRuntimeSettings = {
toolApproval: RuntimeSettings['toolApproval']
}
export type RuntimePolicySettings = Pick<
ResolvedRuntimeSettings,
'subagentSmartRoutingEnabled' | 'toolApproval'
>
export type ResolvedModelProfile = {
id: string
name: string
@@ -347,6 +352,15 @@ function migrateContinueCommand(command: string): string {
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(
settings: Pick<
Version10StoredSettings,
@@ -445,14 +459,21 @@ function migrateVersion10(
}
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 = (
source: RuntimeSettings['opencodeModelSource']
): RuntimeSettings['opencodeModelSource'] => {
if (source.kind === 'platform') {
return source
}
const profile = settings.modelProfiles.find(
const profile = modelProfiles.find(
(candidate) => candidate.id === source.profileId
)
if (profile && isAgentRuntimeModelProtocol(profile.protocol)) {
@@ -463,14 +484,21 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
: { kind: 'platform' }
}
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
)
? settings.defaultModelProfileId
: settings.modelProfiles[0]!.id
: modelProfiles[0]!.id
return {
...settings,
modelProfiles,
provider:
settings.provider === 'auto' ? 'model' : settings.provider,
defaultModelProfileId,
@@ -558,15 +586,27 @@ function migrateVersion5(
function migrateVersion6(
settings: z.infer<typeof version6StoredSettingsSchema>
): StoredSettings {
const endpoint = new URL(settings.knowledgeEmbeddingBaseUrl)
endpoint.pathname = `${endpoint.pathname.replace(/\/+$/u, '')}/v1/embeddings`
let knowledgeEmbeddingBaseUrl: string =
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({
...settings,
version: 10,
subagentSmartRoutingEnabled:
defaultRuntimeSettings.subagentSmartRoutingEnabled,
intranetCompatibilityEnabled: true,
knowledgeEmbeddingBaseUrl: endpoint.toString(),
knowledgeEmbeddingBaseUrl,
modelProfiles: settings.modelProfiles.map((profile) => ({
...profile,
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 {
private settings?: StoredSettings
private loadWarning?: string
private settingsLoad?: Promise<StoredSettings>
private loadWarnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve()
constructor(
@@ -630,25 +665,28 @@ export class RuntimeSettingsStore {
private readonly environment: NodeJS.ProcessEnv = process.env
) {}
private async load(): Promise<StoredSettings> {
private load(): Promise<StoredSettings> {
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 {
const contents = await readFile(this.filePath, 'utf8')
const parsed: unknown = JSON.parse(contents)
if (
parsed &&
typeof parsed === 'object' &&
'version' in parsed &&
typeof parsed.version === 'number' &&
parsed.version > 14
) {
throw new UnsupportedRuntimeSettingsVersionError(
` GoodBuddy Runtime ${parsed.version}`
)
}
assertSupportedSettingsVersion(
parsed,
14,
(version) =>
`当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试`
)
const current = storedSettingsSchema.safeParse(parsed)
if (current.success) {
this.settings = current.data
@@ -772,23 +810,15 @@ export class RuntimeSettingsStore {
}
this.settings = normalizeStoredSettings(this.settings)
} catch (error) {
if (error instanceof UnsupportedRuntimeSettingsVersionError) {
if (error instanceof UnsupportedSettingsVersionError) {
throw error
}
if (
!(
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
) {
this.loadWarning =
'Runtime 设置文件已损坏,已隔离原文件并恢复默认设置'
await rename(
if (!isMissingFileError(error)) {
await isolateCorruptSettingsFile(
this.filePath,
`${this.filePath}.corrupt-${Date.now()}`
).catch(() => undefined)
'Runtime 设置已损坏且无法隔离'
)
this.loadWarnings = [{ code: 'runtime-settings-recovered' }]
}
this.settings = { ...defaultSettings }
}
@@ -798,52 +828,70 @@ export class RuntimeSettingsStore {
private getStoredApiKey(
profile: StoredSettings['modelProfiles'][number]
): string | undefined {
if (!profile.credential || !this.cipher.isAvailable()) {
if (!profile.credential) {
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 {
const payload = credentialPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(profile.credential.ciphertextBase64, 'base64')
)
)
decryptSettingsCredential(this.cipher, profile.credential)
)
if (payload.origin !== new URL(profile.baseUrl).origin) {
this.loadWarning =
`${profile.name} API Key API Key`
return undefined
return warning('runtime-model-credential-binding-mismatch')
}
this.removeWarnings(
[
'runtime-model-credential-unreadable',
'runtime-model-credential-binding-mismatch'
],
profile.name
)
return payload.apiKey
} catch {
return undefined
return warning('runtime-model-credential-unreadable')
}
}
private getStoredEmbeddingApiKey(
settings: StoredSettings
): string | undefined {
if (
!settings.knowledgeEmbeddingCredential ||
!this.cipher.isAvailable()
) {
if (!settings.knowledgeEmbeddingCredential) {
return undefined
}
if (!this.cipher.isAvailable()) {
this.addWarning({
code: 'runtime-embedding-credential-unreadable'
})
return undefined
}
try {
const payload = embeddingCredentialPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(
settings.knowledgeEmbeddingCredential.ciphertextBase64,
'base64'
)
)
decryptSettingsCredential(
this.cipher,
settings.knowledgeEmbeddingCredential
)
)
return payload.endpoint === settings.knowledgeEmbeddingBaseUrl
? payload.apiKey
: undefined
if (payload.endpoint !== settings.knowledgeEmbeddingBaseUrl) {
this.addWarning({
code: 'runtime-embedding-credential-binding-mismatch'
})
return undefined
}
this.removeWarnings([
'runtime-embedding-credential-unreadable',
'runtime-embedding-credential-binding-mismatch'
])
return payload.apiKey
} catch {
this.addWarning({
code: 'runtime-embedding-credential-unreadable'
})
return undefined
}
}
@@ -851,28 +899,64 @@ export class RuntimeSettingsStore {
private getStoredRerankApiKey(
settings: StoredSettings
): 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
}
try {
const payload = rerankCredentialPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(
settings.knowledgeRerankCredential.ciphertextBase64,
'base64'
)
)
decryptSettingsCredential(
this.cipher,
settings.knowledgeRerankCredential
)
)
return payload.endpoint === settings.knowledgeRerankEndpoint
? payload.apiKey
: undefined
if (payload.endpoint !== settings.knowledgeRerankEndpoint) {
this.addWarning({
code: 'runtime-rerank-credential-binding-mismatch'
})
return undefined
}
this.removeWarnings([
'runtime-rerank-credential-unreadable',
'runtime-rerank-credential-binding-mismatch'
])
return payload.apiKey
} catch {
this.addWarning({
code: 'runtime-rerank-credential-unreadable'
})
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 {
return (
this.environment.GOODBUDDY_MODEL_API_KEY?.trim() ||
@@ -903,7 +987,7 @@ export class RuntimeSettingsStore {
? this.getEnvironmentApiKey()
: undefined
const storedApiKey =
profile.authentication === 'api-key'
profile.authentication === 'api-key' && !environmentApiKey
? this.getStoredApiKey(profile)
: undefined
const environmentBaseUrl =
@@ -918,6 +1002,14 @@ export class RuntimeSettingsStore {
const model = environmentApiKey
? environmentModel || defaultRuntimeSettings.modelName
: profile.modelName
const credentialSource: RuntimeSettings['credentialSource'] =
environmentApiKey
? 'environment'
: storedApiKey
? 'encrypted'
: profile.credential
? 'unreadable'
: 'none'
return {
apiKey: environmentApiKey ?? storedApiKey,
baseUrl,
@@ -926,52 +1018,45 @@ export class RuntimeSettingsStore {
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
imageGenerationQuality: profile.imageGenerationQuality,
credentialSource: environmentApiKey
? 'environment'
: storedApiKey
? 'encrypted'
: 'none'
credentialSource
}
}
private resolveProfile(
private resolveModelProfiles(
settings: StoredSettings,
profileId: string
): ResolvedModelProfile | undefined {
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === profileId
effective: ReturnType<
RuntimeSettingsStore['resolveEffectiveModelSettings']
>
): 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): {
@@ -1022,48 +1107,81 @@ export class RuntimeSettingsStore {
private toPublicSettings(settings: StoredSettings): RuntimeSettings {
const effective = this.resolveEffectiveModelSettings(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 isDefault = profile.id === settings.defaultModelProfileId
const apiKey =
profile.authentication === 'api-key'
? this.getStoredApiKey(profile)
: undefined
const resolved = resolvedProfilesById.get(profile.id)
if (!resolved) {
throw new Error(`模型连接不存在:${profile.id}`)
}
const apiKey = resolved.apiKey
return {
id: profile.id,
name: profile.name,
baseUrl: isDefault
? effective.baseUrl
: profile.baseUrl,
modelName: isDefault ? effective.model : profile.modelName,
protocol: isDefault
? effective.protocol
: profile.protocol,
authentication: isDefault
? effective.authentication
: profile.authentication,
supportsImageInput: isDefault
? effective.supportsImageInput
: profile.supportsImageInput,
imageGenerationQuality: isDefault
? effective.imageGenerationQuality
: profile.imageGenerationQuality,
apiKeyConfigured: isDefault
? Boolean(effective.apiKey)
: Boolean(apiKey),
baseUrl: resolved.baseUrl,
modelName: resolved.modelName,
protocol: resolved.protocol,
authentication: resolved.authentication,
supportsImageInput: resolved.supportsImageInput,
imageGenerationQuality:
resolved.imageGenerationQuality ??
defaultRuntimeSettings.imageGenerationQuality,
apiKeyConfigured: Boolean(apiKey),
credentialSource: isDefault
? effective.credentialSource
: apiKey
? ('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 =
this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim()
const embeddingStoredApiKey =
this.getStoredEmbeddingApiKey(settings)
const embeddingStoredApiKey = embeddingEnvironmentApiKey
? undefined
: this.getStoredEmbeddingApiKey(settings)
const rerankEnvironmentApiKey =
this.environment.GOODBUDDY_RERANK_API_KEY?.trim()
const rerankStoredApiKey = this.getStoredRerankApiKey(settings)
const rerankStoredApiKey = rerankEnvironmentApiKey
? undefined
: this.getStoredRerankApiKey(settings)
return {
provider: settings.provider,
modelBaseUrl: effective.baseUrl,
@@ -1092,7 +1210,9 @@ export class RuntimeSettingsStore {
? 'environment'
: embeddingStoredApiKey
? 'encrypted'
: 'none',
: settings.knowledgeEmbeddingCredential
? 'unreadable'
: 'none',
knowledgeRerankEnabled: settings.knowledgeRerankEnabled,
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint,
knowledgeRerankModel: settings.knowledgeRerankModel,
@@ -1103,7 +1223,9 @@ export class RuntimeSettingsStore {
? 'environment'
: rerankStoredApiKey
? 'encrypted'
: 'none',
: settings.knowledgeRerankCredential
? 'unreadable'
: 'none',
workspacePath: agent.workspacePath,
apiKeyConfigured: Boolean(effective.apiKey),
credentialSource: effective.credentialSource,
@@ -1115,7 +1237,20 @@ export class RuntimeSettingsStore {
continueModelSource: settings.continueModelSource,
secureStorageAvailable: this.cipher.isAvailable(),
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())
}
async getPolicySettings(): Promise<RuntimePolicySettings> {
const settings = await this.load()
return {
subagentSmartRoutingEnabled:
settings.subagentSmartRoutingEnabled,
toolApproval: settings.toolApproval
}
}
async getResolvedSettings(): Promise<ResolvedRuntimeSettings> {
const settings = await this.load()
const effective = this.resolveEffectiveModelSettings(settings)
const agent = this.resolveAgentSettings(settings)
const modelProfiles = this.resolveModelProfiles(settings, effective)
const profilesById = new Map(
modelProfiles.map((profile) => [profile.id, profile])
)
const opencodeModelProfile =
!agent.opencodeBaseUrl &&
settings.opencodeModelSource.kind === 'profile'
? this.resolveProfile(
settings,
settings.opencodeModelSource.profileId
)
? profilesById.get(settings.opencodeModelSource.profileId)
: undefined
const continueModelProfile =
settings.continueModelSource.kind === 'profile'
? this.resolveProfile(
settings,
settings.continueModelSource.profileId
)
? profilesById.get(settings.continueModelSource.profileId)
: undefined
return {
provider: settings.provider,
@@ -1151,13 +1293,7 @@ export class RuntimeSettingsStore {
supportsImageInput: effective.supportsImageInput,
imageGenerationQuality: effective.imageGenerationQuality,
apiKey: effective.apiKey,
modelProfiles: settings.modelProfiles.map((profile) => {
const resolved = this.resolveProfile(settings, profile.id)
if (!resolved) {
throw new Error(`${profile.id}`)
}
return resolved
}),
modelProfiles,
defaultModelProfileId: settings.defaultModelProfileId,
opencodeModelProfile,
continueModelProfile,
@@ -1248,7 +1384,15 @@ export class RuntimeSettingsStore {
const existing = current.modelProfiles.find(
(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 (
profile.authentication === 'api-key' &&
profile.apiKey.action === 'keep' &&
@@ -1264,7 +1408,9 @@ export class RuntimeSettingsStore {
id: profile.id,
name: profile.name,
baseUrl: normalizedBaseUrl,
modelName: profile.modelName,
modelName: environmentManaged
? existing.modelName
: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput ?? false,
@@ -1280,19 +1426,14 @@ export class RuntimeSettingsStore {
profile.authentication === 'api-key' &&
profile.apiKey.action === 'replace'
) {
nextProfile.credential = {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: this.cipher
.encrypt(
JSON.stringify({
version: 1,
apiKey: profile.apiKey.value,
origin: new URL(normalizedBaseUrl).origin
})
)
.toString('base64')
}
nextProfile.credential = encryptSettingsCredential(
this.cipher,
{
version: 1,
apiKey: profile.apiKey.value,
origin: new URL(normalizedBaseUrl).origin
}
)
}
return nextProfile
})
@@ -1319,19 +1460,14 @@ export class RuntimeSettingsStore {
knowledgeEmbeddingCredential =
current.knowledgeEmbeddingCredential
} else if (embeddingApiKeyUpdate.action === 'replace') {
knowledgeEmbeddingCredential = {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: this.cipher
.encrypt(
JSON.stringify({
version: 1,
apiKey: embeddingApiKeyUpdate.value,
endpoint: embeddingEndpoint
})
)
.toString('base64')
}
knowledgeEmbeddingCredential = encryptSettingsCredential(
this.cipher,
{
version: 1,
apiKey: embeddingApiKeyUpdate.value,
endpoint: embeddingEndpoint
}
)
}
const rerankEndpoint = new URL(
@@ -1355,19 +1491,14 @@ export class RuntimeSettingsStore {
) {
knowledgeRerankCredential = current.knowledgeRerankCredential
} else if (rerankApiKeyUpdate.action === 'replace') {
knowledgeRerankCredential = {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: this.cipher
.encrypt(
JSON.stringify({
version: 1,
apiKey: rerankApiKeyUpdate.value,
endpoint: rerankEndpoint
})
)
.toString('base64')
}
knowledgeRerankCredential = encryptSettingsCredential(
this.cipher,
{
version: 1,
apiKey: rerankApiKeyUpdate.value,
endpoint: rerankEndpoint
}
)
}
const [
@@ -1490,19 +1621,9 @@ export class RuntimeSettingsStore {
toolApproval: input.toolApproval
}
await mkdir(dirname(this.filePath), { recursive: true })
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 })
}
await writeJsonFileAtomically(this.filePath, next)
this.settings = next
this.loadWarning = undefined
this.loadWarnings = []
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 { waitForCleanup } from './shutdown'
import {
runCleanupBeforeDeadline,
settleCleanupPhases,
waitForCleanup
} from './shutdown'
afterEach(() => {
vi.useRealTimers()
@@ -23,4 +27,49 @@ describe('waitForCleanup', () => {
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
}
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(
screen.getByRole('button', { name: 'Chat' })
).toBeInTheDocument()
expect(screen.getByText('Desktop workspace')).toBeInTheDocument()
expect(screen.getByText('GOODBUDDY WORKSPACE')).toBeInTheDocument()
expect(
screen.getByRole('heading', {
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 () => {
api.updates = {
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([
{
id: '00000000-0000-4000-8000-000000000020',
@@ -3306,17 +3315,14 @@ describe('App', () => {
expect.arrayContaining([
expect.objectContaining({
id: '00000000-0000-4000-8000-000000000020',
runtimeSelection: {
provider: 'model',
profileId: modelProfileId
}
runtimeSelection: { provider: 'auto' }
})
])
)
)
})
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 =
'00000000-0000-4000-8000-000000000099'
vi.mocked(api.conversations.list).mockResolvedValueOnce([
@@ -3341,9 +3347,6 @@ describe('App', () => {
])
render(<App />)
expect(
await screen.findByRole('button', { name: /.*sonnet-5/u })
).toBeInTheDocument()
await waitFor(() =>
expect(api.conversations.replace).toHaveBeenLastCalledWith(
expect.arrayContaining([
@@ -3351,7 +3354,7 @@ describe('App', () => {
id: '00000000-0000-4000-8000-000000000022',
runtimeSelection: {
provider: 'model',
profileId: modelProfileId
profileId: removedProfileId
}
})
])
+31 -106
View File
@@ -65,9 +65,12 @@ import { maximumPastedImageBytes } from '../../shared/contracts'
import {
agentRuntimeSelectionKey,
agentRuntimeSelectionSchema,
repairAgentRuntimeSelection,
type AgentRuntimeSelection
} from '../../shared/runtime-selection-contracts'
import {
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider
} from './runtime-selection'
import type {
AssistantProject,
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(
project: AssistantProject | undefined,
settings: RuntimeSettings
@@ -848,7 +812,7 @@ function getProjectDefaultRuntimeSelection(
const selection = project?.runtimeSelection
return !selection || selection.provider === 'auto'
? getDefaultRuntimeSelection(settings)
: repairAgentRuntimeSelection(selection, settings)
: selection
}
function getRuntimeSelectionLabel(
@@ -859,6 +823,7 @@ function getRuntimeSelectionLabel(
directModel: string
automatic: string
automaticSelection: string
modelUnavailable: string
}
): string {
if (!selection || !settings) {
@@ -870,36 +835,36 @@ function getRuntimeSelectionLabel(
(candidate) => candidate.id === selection.profileId
)
: undefined
const requestedProfileMissing =
'profileId' in selection &&
Boolean(selection.profileId) &&
profile === undefined
if (selection.provider === 'model') {
return profile
? `${profile.name} · ${profile.modelName}`
: status?.label ?? labels.directModel
: requestedProfileMissing
? labels.modelUnavailable
: status?.label ?? labels.directModel
}
if (selection.provider === 'opencode') {
return profile ? `OpenCode · ${profile.name}` : 'OpenCode'
return profile
? `OpenCode · ${profile.name}`
: requestedProfileMissing
? `OpenCode · ${labels.modelUnavailable}`
: 'OpenCode'
}
if (selection.provider === 'continue') {
return profile ? `Continue · ${profile.name}` : 'Continue'
return profile
? `Continue · ${profile.name}`
: requestedProfileMissing
? `Continue · ${labels.modelUnavailable}`
: 'Continue'
}
return status
? `${labels.automatic} · ${status.label}`
: 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(
settings: RuntimeSettings,
provider: 'opencode' | 'continue',
@@ -910,7 +875,7 @@ function getConfiguredAgentRuntimeSource(
useOwnConfiguration: (runtime: string) => string
}
): { label: string; detail: string } {
const selection = getConfiguredAgentRuntimeSelection(settings, provider)
const selection = getRuntimeSelectionForProvider(provider, settings)
const profile =
'profileId' in selection
? settings.modelProfiles.find(
@@ -1766,7 +1731,8 @@ function App(): React.JSX.Element {
() => ({
directModel: t('runtime.directModel'),
automatic: t('runtime.automatic'),
automaticSelection: t('runtime.automaticSelection')
automaticSelection: t('runtime.automaticSelection'),
modelUnavailable: t('runtime.modelUnavailable')
}),
[t]
)
@@ -1787,10 +1753,10 @@ function App(): React.JSX.Element {
runtimeLabels
)
const openCodeMenuSelection = runtimeSettings
? getConfiguredAgentRuntimeSelection(runtimeSettings, 'opencode')
? getRuntimeSelectionForProvider('opencode', runtimeSettings)
: undefined
const continueMenuSelection = runtimeSettings
? getConfiguredAgentRuntimeSelection(runtimeSettings, 'continue')
? getRuntimeSelectionForProvider('continue', runtimeSettings)
: undefined
const openCodeMenuSource = runtimeSettings
? getConfiguredAgentRuntimeSource(
@@ -1866,48 +1832,6 @@ function App(): React.JSX.Element {
}
}, [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(() => {
const selection = activeRuntimeSelectionRef.current
if (!selection || !runtimeSettings) {
@@ -4762,7 +4686,7 @@ function App(): React.JSX.Element {
</div>
<div className="brand__copy">
<strong>GoodBuddy</strong>
<span>Desktop workspace</span>
<span>{t('brand.desktopWorkspace')}</span>
</div>
</div>
@@ -5299,7 +5223,7 @@ function App(): React.JSX.Element {
<div className="welcome__badge">
<Sparkles size={18} />
</div>
<p className="eyebrow">GOODBUDDY WORKSPACE</p>
<p className="eyebrow">{t('chat.welcome.eyebrow')}</p>
<h1>{t('chat.welcome.title')}</h1>
<p className="welcome__description">
{t('chat.welcome.description')}
@@ -6849,6 +6773,7 @@ function App(): React.JSX.Element {
<SettingsPanel
appearanceTheme={appearanceTheme}
heartbeats={assistantHeartbeats}
magicNotesEnabled={magicNotesEnabled}
onAppearanceThemeChange={setAppearanceTheme}
onClearLocalData={clearLocalData}
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 { trapTabFocus } from './dialog-focus'
import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
type ChannelDraft = {
enabled: boolean
@@ -455,6 +458,8 @@ function ChannelEditor({
<small>
{settings.source === 'environment'
? t('channels.credential.environmentSource')
: settings.source === 'unreadable'
? t('channels.credential.secretUnreadable')
: settings.secretConfigured
? t('channels.credential.secretSaved')
: t('channels.credential.secretMissing')}
@@ -1327,7 +1332,7 @@ export function ChannelSettingsSection({
aria-label={t('channels.sectionAriaLabel')}
className="settings-section channel-settings"
>
{snapshot.warning && <p className="settings-warning">{snapshot.warning}</p>}
<SettingsWarningList warnings={snapshot.warnings} />
<div className="channel-settings__tabs">
<PageTabs
@@ -199,6 +199,36 @@ describe('DocumentParsingSettingsSection', () => {
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 () => {
const onNotify = vi.fn()
render(
@@ -29,7 +29,10 @@ import type {
DocumentParsingTestPurpose
} from '../../shared/document-parsing-contracts'
import type { AppNotificationInput } from './notifications'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
type DocumentParsingSettingsSectionProps = {
onNotify?: (notification: AppNotificationInput) => void
@@ -402,7 +405,9 @@ export function DocumentParsingSettingsSection({
error={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"
error={error}
/>
<SettingsWarningList warnings={snapshot.warnings} />
{settingsDirty && (
<p
className="settings-notice"
+10 -17
View File
@@ -31,7 +31,10 @@ import type {
WebSearchTestResult
} from '../../shared/capability-contracts'
import { trapTabFocus } from './dialog-focus'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
import { PageTabs } from './WorkspacePrimitives'
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 tRef = useRef(t)
useEffect(() => {
@@ -106,7 +113,6 @@ export function McpSettingsSection(): React.JSX.Element {
disabled: t('mcp.diagnosticStatuses.disabled')
}
const [snapshot, setSnapshot] = useState<CapabilitySnapshot>()
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
const [editor, setEditor] = useState<McpEditor>()
const [busy, setBusy] = 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(() => {
if (!editorOpen) {
return
@@ -417,6 +409,7 @@ export function McpSettingsSection(): React.JSX.Element {
error={!editor ? error : undefined}
headingId="mcp-settings-heading"
/>
<SettingsWarningList warnings={snapshot?.warnings} />
<PageTabs
ariaLabel={t('mcp.tabs.ariaLabel')}
idPrefix="mcp-settings"
@@ -6,7 +6,10 @@ import type {
} from '../../shared/application-settings-contracts'
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
import { SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
type PlatformFeaturesSettingsSectionProps = {
onMagicNotesEnabledChange: (enabled: boolean) => void
@@ -116,6 +119,7 @@ export function PlatformFeaturesSettingsSection({
error={error}
headingId="platform-features-heading"
/>
<SettingsWarningList warnings={settings?.warnings} />
<section
aria-label={t('platformFeatures.label')}
className="settings-section"
+9 -43
View File
@@ -18,8 +18,11 @@ import {
normalizeInteractiveWorkMode
} from '../../shared/assistant-contracts'
import type { RuntimeSettings } from '../../shared/contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import { trapTabFocus } from './dialog-focus'
import {
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider
} from './runtime-selection'
type ProjectSwitcherProps = {
projects: AssistantProject[]
@@ -36,43 +39,6 @@ type ProjectSwitcherProps = {
) => 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({
projects,
activeProjectId,
@@ -157,7 +123,7 @@ export function ProjectSwitcher({
? draft
: {
...draft,
runtimeSelection: defaultRuntimeSelection(runtimeSettings)
runtimeSelection: getDefaultRuntimeSelection(runtimeSettings)
}
if (dialogMode === 'settings' && activeProject) {
await onUpdate(activeProject.id, input)
@@ -276,7 +242,7 @@ export function ProjectSwitcher({
rootPath: '',
defaultWorkMode: 'ask',
runtimeSelection: runtimeSettings
? defaultRuntimeSelection(runtimeSettings)
? getDefaultRuntimeSelection(runtimeSettings)
: undefined
})
restoreFocusTarget.current = 'create'
@@ -308,7 +274,7 @@ export function ProjectSwitcher({
runtimeSelection:
activeProject.runtimeSelection ??
(runtimeSettings
? defaultRuntimeSelection(runtimeSettings)
? getDefaultRuntimeSelection(runtimeSettings)
: undefined)
})
restoreFocusTarget.current = 'settings'
@@ -441,7 +407,7 @@ export function ProjectSwitcher({
onChange={(event) =>
setDraft((current) => ({
...current,
runtimeSelection: runtimeSelectionForProvider(
runtimeSelection: getRuntimeSelectionForProvider(
event.target.value as
| 'model'
| 'opencode'
@@ -454,7 +420,7 @@ export function ProjectSwitcher({
draft.runtimeSelection?.provider === 'auto'
? 'model'
: (draft.runtimeSelection?.provider ??
defaultRuntimeSelection(runtimeSettings)
getDefaultRuntimeSelection(runtimeSettings)
.provider)
}
>
+213 -1
View File
@@ -7,6 +7,7 @@ import {
waitFor,
within
} from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssistantExpert } from '../../shared/assistant-contracts'
import type {
@@ -569,6 +570,31 @@ describe('SettingsPanel runtime files', () => {
screen.getByRole('radio', { name: /Use system language/u })
).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(
screen.getByRole('tab', { name: 'Agent Runtime' })
)
@@ -590,6 +616,69 @@ describe('SettingsPanel runtime files', () => {
).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 () => {
const onMagicNotesEnabledChange = vi.fn()
render(
@@ -617,7 +706,6 @@ describe('SettingsPanel runtime files', () => {
})
)
expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
fireEvent.click(
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', () => {
render(
<SettingsPanel
@@ -759,6 +900,77 @@ describe('SettingsPanel runtime files', () => {
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 () => {
render(
<SettingsPanel
+259 -138
View File
@@ -9,7 +9,7 @@ import {
Trash2,
X
} from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantExpert,
@@ -26,6 +26,7 @@ import type {
} from '../../shared/contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import {
defaultModelProfileId as builtInDefaultModelProfileId,
defaultRuntimeSettings,
isAgentRuntimeModelProtocol
} from '../../shared/contracts'
@@ -40,7 +41,10 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
import {
settingsCategoryList,
type SettingsCategoryId
@@ -81,6 +85,7 @@ type SettingsPanelProps = {
onRunHeartbeat: (heartbeatId: string) => Promise<void>
appearanceTheme?: AppearanceTheme
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
magicNotesEnabled?: boolean
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 = {
runtime: AgentRuntimeType
runtimeLabel: string
@@ -246,6 +363,7 @@ export function SettingsPanel({
onExpertsChanged = () => {},
appearanceTheme = 'system',
onAppearanceThemeChange = () => {},
magicNotesEnabled = false,
onMagicNotesEnabledChange = () => {}
}: SettingsPanelProps): React.JSX.Element | null {
const { i18n, t } = useTranslation('settings')
@@ -322,6 +440,13 @@ export function SettingsPanel({
subagentSmartRoutingEnabled,
setSubagentSmartRoutingEnabled
] = 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 [testing, setTesting] = useState(false)
const [embeddingConfiguration, setEmbeddingConfiguration] =
@@ -350,6 +475,47 @@ export function SettingsPanel({
const [agentRuntimeType, setAgentRuntimeType] =
useState<AgentRuntimeType>('opencode')
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 =
activeTab === 'model' ||
activeTab === 'runtime' ||
@@ -409,52 +575,7 @@ export function SettingsPanel({
setPersistedSpeechModelId(undefined)
setSpeechModelSelectionDirty(false)
setAgentRuntimeType('opencode')
setSettings(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
)
hydrateSettings(value)
})
.catch((reason: unknown) => {
setError(
@@ -475,7 +596,7 @@ export function SettingsPanel({
)
)
})
}, [i18n, open])
}, [hydrateSettings, i18n, open])
useEffect(() => {
if (open && settingsBodyRef.current) {
@@ -550,32 +671,52 @@ export function SettingsPanel({
if (!defaultProfile) {
throw new Error(t('errors.requireModelConnection'))
}
const profileInputs = 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: profile.clearApiKey
? ({ action: 'clear' } as const)
: profile.apiKey.trim()
? ({
action: 'replace',
value: profile.apiKey.trim()
} as const)
: ({ action: 'keep' } as const)
}))
const configuredProfiles = new Map(
settings?.configured?.modelProfiles.map((profile) => [
profile.id,
profile
])
)
const profileInputs = modelProfiles.map((profile) => {
const configured = configuredProfiles.get(profile.id)
const environmentManaged =
profile.credentialSource === 'environment' &&
configured !== undefined
return {
id: profile.id,
name: profile.name,
baseUrl: environmentManaged
? configured.baseUrl
: 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({
provider,
modelBaseUrl: defaultProfile.baseUrl,
modelName: defaultProfile.modelName,
modelProtocol: defaultProfile.protocol,
modelAuthentication: defaultProfile.authentication,
modelBaseUrl: defaultProfileInput.baseUrl,
modelName: defaultProfileInput.modelName,
modelProtocol: defaultProfileInput.protocol,
modelAuthentication: defaultProfileInput.authentication,
imageGenerationQuality:
defaultProfile.imageGenerationQuality,
defaultProfileInput.imageGenerationQuality,
opencodeBaseUrl,
opencodeEmbedded: !opencodeBaseUrl,
opencodeBinaryPath,
@@ -607,9 +748,7 @@ export function SettingsPanel({
}
: { action: 'keep' },
workspacePath,
apiKey: profileInputs.find(
(profile) => profile.id === defaultProfile.id
)!.apiKey,
apiKey: defaultProfileInput.apiKey,
modelProfiles: profileInputs,
defaultModelProfileId: defaultProfile.id,
opencodeModelSource,
@@ -628,48 +767,7 @@ export function SettingsPanel({
)
selectedSpeechModelId = speechSnapshot.selectedModelId
}
setSettings(value)
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
)
hydrateSettings(value, true)
if (speechModelSelectionDirty) {
setSpeechModelDraftId(selectedSpeechModelId)
setPersistedSpeechModelId(selectedSpeechModelId)
@@ -987,7 +1085,10 @@ export function SettingsPanel({
.filter((profile) =>
isAgentRuntimeModelProtocol(profile.protocol)
)
.map(({ id, name }) => ({ id, name }))
.map(({ id, name }) => ({
id,
name: modelProfileDisplayName({ id, name })
}))
const savedRoleDefaultModelProfileId =
savedRoleModelProfiles.some(
(profile) => profile.id === settings?.defaultModelProfileId
@@ -1246,9 +1347,7 @@ export function SettingsPanel({
)}
{activeTab === 'runtime' && (
<>
{settings?.warning && (
<p className="settings-warning">{settings.warning}</p>
)}
<SettingsWarningList warnings={settings?.warnings} />
<div className="settings-section">
<div className="settings-section__title">
<FolderOpen size={17} />
@@ -1327,12 +1426,16 @@ export function SettingsPanel({
})
: activeRuntimeModelProfile
? t('runtime.followGoodBuddy', {
name: activeRuntimeModelProfile.name,
name: modelProfileDisplayName(
activeRuntimeModelProfile
),
model: activeRuntimeModelProfile.modelName
})
: defaultTextModelProfile
? t('runtime.followGoodBuddy', {
name: defaultTextModelProfile.name,
name: modelProfileDisplayName(
defaultTextModelProfile
),
model: defaultTextModelProfile.modelName
})
: t('runtime.noCompatibleModel')}
@@ -1415,7 +1518,7 @@ export function SettingsPanel({
key={profile.id}
value={profile.id}
>
{profile.name}
{modelProfileDisplayName(profile)}
{isOpenCodeCompatible(profile)
? ''
: t('runtime.incompatibleSuffix')}
@@ -1440,7 +1543,12 @@ export function SettingsPanel({
path={opencodeConfigPath}
runtime="opencode"
runtimeLabel="OpenCode"
savedPath={settings?.opencodeConfigPath}
savedPath={
settings
? configuredRuntimeSettings(settings)
.opencodeConfigPath
: undefined
}
/>
)}
{opencodeModelSource.kind === 'platform' &&
@@ -1548,12 +1656,16 @@ export function SettingsPanel({
})
: activeRuntimeModelProfile
? t('runtime.followGoodBuddy', {
name: activeRuntimeModelProfile.name,
name: modelProfileDisplayName(
activeRuntimeModelProfile
),
model: activeRuntimeModelProfile.modelName
})
: defaultTextModelProfile
? t('runtime.followGoodBuddy', {
name: defaultTextModelProfile.name,
name: modelProfileDisplayName(
defaultTextModelProfile
),
model: defaultTextModelProfile.modelName
})
: t('runtime.noCompatibleModel')}
@@ -1634,7 +1746,7 @@ export function SettingsPanel({
key={profile.id}
value={profile.id}
>
{profile.name}
{modelProfileDisplayName(profile)}
{isContinueCompatible(profile)
? ''
: t('runtime.incompatibleSuffix')}
@@ -1658,7 +1770,12 @@ export function SettingsPanel({
path={continueConfigPath}
runtime="continue"
runtimeLabel="Continue"
savedPath={settings?.continueConfigPath}
savedPath={
settings
? configuredRuntimeSettings(settings)
.continueConfigPath
: undefined
}
/>
)}
<label className="field">
@@ -1798,7 +1915,7 @@ export function SettingsPanel({
: undefined
}
aria-label={t('model.profile.editAriaLabel', {
name: profile.name
name: modelProfileDisplayName(profile)
})}
onClick={() =>
setSelectedModelProfileId(profile.id)
@@ -1806,7 +1923,7 @@ export function SettingsPanel({
type="button"
>
<span className="model-connection-list__name">
<strong>{profile.name}</strong>
<strong>{modelProfileDisplayName(profile)}</strong>
<small>{profile.modelName}</small>
</span>
<span className="model-connection-list__badges">
@@ -1836,7 +1953,7 @@ export function SettingsPanel({
<div className="settings-section__title">
<div>
<strong id={`model-connection-${profile.id}`}>
{profile.name}
{modelProfileDisplayName(profile)}
</strong>
<small>{t('model.profile.detail')}</small>
</div>
@@ -1858,7 +1975,7 @@ export function SettingsPanel({
)}
<button
aria-label={t('model.profile.deleteAriaLabel', {
name: profile.name
name: modelProfileDisplayName(profile)
})}
className="danger-button danger-button--quiet"
disabled={modelProfiles.length <= 1}
@@ -1877,7 +1994,7 @@ export function SettingsPanel({
name: event.target.value
})
}
value={profile.name}
value={modelProfileDisplayName(profile)}
/>
</label>
<label className="field">
@@ -1912,7 +2029,7 @@ export function SettingsPanel({
<select
aria-label={t(
'model.profile.protocolAriaLabel',
{ name: profile.name }
{ name: modelProfileDisplayName(profile) }
)}
onChange={(event) =>
{
@@ -1979,7 +2096,7 @@ export function SettingsPanel({
<select
aria-label={t(
'model.profile.authenticationAriaLabel',
{ name: profile.name }
{ name: modelProfileDisplayName(profile) }
)}
onChange={(event) => {
const authentication = event.target
@@ -2027,7 +2144,7 @@ export function SettingsPanel({
<select
aria-label={t(
'model.profile.imageQualityAriaLabel',
{ name: profile.name }
{ name: modelProfileDisplayName(profile) }
)}
onChange={(event) =>
updateModelProfile(profile.id, {
@@ -2530,7 +2647,11 @@ export function SettingsPanel({
</>
)}
{activeTab === 'skills' && <SkillsSettingsSection />}
{activeTab === 'mcp' && <McpSettingsSection />}
{activeTab === 'mcp' && (
<McpSettingsSection
magicNotesEnabled={magicNotesEnabled}
/>
)}
{activeTab === 'about' && <UpdateSettingsSection />}
</div>
</div>
+30
View File
@@ -1,8 +1,38 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
settingsWarningKey,
type SettingsWarning
} from '../../shared/settings-warning-contracts'
import {
settingsCategories,
type SettingsCategoryId
} 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({
actions,
+5 -1
View File
@@ -6,7 +6,10 @@ import type {
VersionCheckResult
} from '../../shared/application-settings-contracts'
import type { AppInfo } from '../../shared/contracts'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
@@ -137,6 +140,7 @@ export function UpdateSettingsSection(): React.JSX.Element {
error={error}
headingId="update-settings-heading"
/>
<SettingsWarningList warnings={settings?.warnings} />
<section
aria-label={t('updates.label')}
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 { settingsSections as englishSettingsSections } from './locales/en-US/settingsSections'
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 { app as chineseApp } from './locales/zh-CN/app'
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 { settingsSections as chineseSettingsSections } from './locales/zh-CN/settingsSections'
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 type UiLocale = (typeof supportedUiLocales)[number]
@@ -32,7 +34,8 @@ export const i18nResources = {
magicNotes: chineseMagicNotes,
settings: chineseSettings,
settingsSections: chineseSettingsSections,
workspace: chineseWorkspace
workspace: chineseWorkspace,
warnings: chineseWarnings
},
'en-US': {
activity: englishActivity,
@@ -43,7 +46,8 @@ export const i18nResources = {
magicNotes: englishMagicNotes,
settings: englishSettings,
settingsSections: englishSettingsSections,
workspace: englishWorkspace
workspace: englishWorkspace,
warnings: englishWarnings
}
} as const
@@ -2,6 +2,9 @@ import type { TranslationShape } from '../../resource-types'
import type { app as chineseApp } from '../zh-CN/app'
export const app = {
brand: {
desktopWorkspace: 'Desktop workspace'
},
notifications: {
success: 'Success',
error: 'Error',
@@ -125,6 +128,7 @@ export const app = {
user: 'You',
assistantResult: 'Assistant result {{index}}',
welcome: {
eyebrow: 'GOODBUDDY WORKSPACE',
title: 'What would you like to accomplish today?',
description:
'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',
secretSaved: 'Secret saved with encryption',
secretMissing: 'Secret not configured',
secretUnreadable: 'Secret saved, but currently unreadable',
readOnly:
'This channel is managed by environment variables. Change the launch environment and restart the app.',
enable: 'Enable the {{channel}} channel',
@@ -136,6 +136,7 @@ export const settings = {
none: 'Not configured',
encrypted: 'Encrypted in secure system storage',
environment: 'Provided by an environment variable',
unreadable: 'Saved, but currently unreadable',
configuredPlaceholder: 'Configured; leave blank to keep it',
enterApiKey: 'Enter API Key',
noAuthentication: 'No authentication',
@@ -224,6 +225,7 @@ export const settings = {
}
},
documentParsing: {
loading: 'Loading…',
status: {
title: 'Runtime status',
description: 'Capabilities currently available on this device',
@@ -409,6 +411,7 @@ export const settings = {
}
},
profile: {
seededDefaultName: 'Default model',
generatedName: 'Model connection {{count}}',
title: 'LLM model connections',
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 = {
brand: {
desktopWorkspace: '桌面工作区'
},
notifications: {
success: '成功',
error: '错误',
@@ -121,6 +124,7 @@ export const app = {
user: '用户',
assistantResult: '助手成果 {{index}}',
welcome: {
eyebrow: 'GOODBUDDY 工作台',
title: '今天想一起完成什么?',
description:
'快速提问、梳理信息,或连接 OpenCode 使用文件搜索和开发工具。'
@@ -62,6 +62,7 @@ export const integrations = {
environmentSource: '由环境变量提供',
secretSaved: 'Secret 已加密保存',
secretMissing: 'Secret 尚未配置',
secretUnreadable: 'Secret 已保存,但当前无法读取',
readOnly:
'当前通道由环境变量管理。请在启动环境中修改配置后重启应用。',
enable: '启用{{channel}}通道',
@@ -124,6 +124,7 @@ export const settings = {
none: '尚未配置',
encrypted: '已由系统安全存储加密',
environment: '由环境变量提供',
unreadable: '已保存,但当前无法读取',
configuredPlaceholder: '已配置,留空保持不变',
enterApiKey: '输入 API Key',
noAuthentication: '无需认证',
@@ -205,6 +206,7 @@ export const settings = {
}
},
documentParsing: {
loading: '正在加载…',
status: {
title: '运行状态',
description: '显示当前设备实际可用的解析能力',
@@ -372,6 +374,7 @@ export const settings = {
}
},
profile: {
seededDefaultName: '默认模型',
generatedName: '模型连接 {{count}}',
title: 'LLM 模型连接',
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 integrations from './locales/zh-CN/integrations'
import workspace from './locales/zh-CN/workspace'
import warnings from './locales/zh-CN/warnings'
declare module 'i18next' {
interface CustomTypeOptions {
@@ -22,6 +23,7 @@ declare module 'i18next' {
magicNotes: typeof magicNotes
integrations: typeof integrations
workspace: typeof workspace
warnings: typeof warnings
}
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 { magicNoteCommentFormatSchema } from './magic-notes-contracts'
import { settingsWarningsSchema } from './settings-warning-contracts'
export const magicNoteCommentModeSchema = z.enum([
'immediate',
@@ -11,7 +12,7 @@ export type MagicNoteCommentMode = z.infer<
typeof magicNoteCommentModeSchema
>
export const applicationSettingsSchema = z
const applicationPreferencesSchema = z
.object({
checkUpdatesOnStartup: z.boolean(),
magicNotesEnabled: z.boolean(),
@@ -20,7 +21,13 @@ export const applicationSettingsSchema = z
})
.strict()
export const applicationSettingsUpdateSchema = applicationSettingsSchema
export const applicationSettingsSchema = applicationPreferencesSchema
.extend({
warnings: settingsWarningsSchema.optional()
})
.strict()
export const applicationSettingsUpdateSchema = applicationPreferencesSchema
.partial()
.refine((input) => Object.keys(input).length > 0, {
message: 'At least one application setting is required'
+3 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { settingsWarningsSchema } from './settings-warning-contracts'
const controlCharacterFreeString = (maximumLength: number) =>
z
@@ -331,7 +332,8 @@ export const capabilitySnapshotSchema = z
.array(computerCapabilityConfigSummarySchema)
.max(2)
.optional(),
browserProfiles: browserProfilesSummarySchema.optional()
browserProfiles: browserProfilesSummarySchema.optional(),
warnings: settingsWarningsSchema.optional()
})
.strict()
export type CapabilitySnapshot = z.infer<typeof capabilitySnapshotSchema>
+4 -7
View File
@@ -3,6 +3,7 @@ import {
projectChannelSchema,
type ProjectChannel
} from './assistant-contracts'
import { settingsWarningsSchema } from './settings-warning-contracts'
export const CHANNEL_SETTINGS_LIMITS = {
maximumIdentifierLength: 256,
@@ -106,7 +107,8 @@ export type ChannelSettingsApply = z.infer<
export const channelCredentialSourceSchema = z.enum([
'none',
'encrypted',
'environment'
'environment',
'unreadable'
])
export type ChannelCredentialSource = z.infer<
typeof channelCredentialSourceSchema
@@ -186,12 +188,7 @@ export const channelSettingsSnapshotSchema = z
weixin: weixinChannelSettingsSchema,
wecom: weComChannelSettingsSchema,
dingtalk: dingTalkChannelSettingsSchema,
warning: z
.string()
.trim()
.min(1)
.max(CHANNEL_SETTINGS_LIMITS.maximumWarningLength)
.optional()
warnings: settingsWarningsSchema.optional()
})
.strict()
export type ChannelSettingsSnapshot = z.infer<
+27 -5
View File
@@ -86,6 +86,7 @@ import type {
DocumentParsingSnapshot,
DocumentParsingTestPurpose
} from './document-parsing-contracts'
import type { SettingsWarning } from './settings-warning-contracts'
import type {
KnowledgeChunkDeleteInput,
KnowledgeChunkPage,
@@ -601,7 +602,19 @@ export type ModelConnectionSettings = {
supportsImageInput?: boolean
imageGenerationQuality: ImageGenerationQuality
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 = {
@@ -625,22 +638,31 @@ export type RuntimeSettings = {
knowledgeEmbeddingBaseUrl: string
knowledgeEmbeddingModel: string
knowledgeEmbeddingApiKeyConfigured: boolean
knowledgeEmbeddingCredentialSource: 'none' | 'encrypted' | 'environment'
knowledgeEmbeddingCredentialSource:
| 'none'
| 'encrypted'
| 'environment'
| 'unreadable'
knowledgeRerankEnabled?: boolean
knowledgeRerankEndpoint?: string
knowledgeRerankModel?: string
knowledgeRerankApiKeyConfigured?: boolean
knowledgeRerankCredentialSource?: 'none' | 'encrypted' | 'environment'
knowledgeRerankCredentialSource?:
| 'none'
| 'encrypted'
| 'environment'
| 'unreadable'
workspacePath: string
apiKeyConfigured: boolean
credentialSource: 'none' | 'encrypted' | 'environment'
credentialSource: 'none' | 'encrypted' | 'environment' | 'unreadable'
modelProfiles: ModelConnectionSettings[]
defaultModelProfileId: string
opencodeModelSource: RuntimeModelSource
continueModelSource: RuntimeModelSource
secureStorageAvailable: boolean
toolApproval: RuntimeSettingsInput['toolApproval']
warning?: string
configured?: ConfiguredRuntimeSettings
warnings?: SettingsWarning[]
}
export type ContextAttachment = ConversationAttachment
+3 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { settingsWarningsSchema } from './settings-warning-contracts'
export const maximumDocumentExtractedCharacters = 5_000_000
export const maximumDocumentOcrSectionCharacters = 1_000_000
@@ -192,7 +193,8 @@ export const documentParsingSnapshotSchema = z
.object({
settings: documentParsingSettingsSchema,
status: documentParsingStatusSchema,
ocrModels: documentOcrModelSnapshotSchema
ocrModels: documentOcrModelSnapshotSchema,
warnings: settingsWarningsSchema.optional()
})
.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)
}