fix: harden scoped tools and settings persistence
This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user