chore: prepare GoodBuddy 0.8.2
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
This commit is contained in:
@@ -24,7 +24,35 @@ async function createDatabase(): Promise<AssistantDatabase> {
|
||||
}
|
||||
|
||||
describe('AssistantDatabase', () => {
|
||||
it('migrates existing databases to schema version 7', async () => {
|
||||
it('rejects a newer unsupported schema without changing its version', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-assistant-future-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const initial = new AssistantDatabase(databasePath)
|
||||
initial.initialize('C:\\Workspace')
|
||||
initial.close()
|
||||
const future = new DatabaseSync(databasePath)
|
||||
future.exec('PRAGMA user_version = 99;')
|
||||
future.close()
|
||||
|
||||
const downgraded = new AssistantDatabase(databasePath)
|
||||
expect(() => downgraded.initialize('C:\\Workspace')).toThrow(
|
||||
'不支持助理数据库版本 99'
|
||||
)
|
||||
const unchanged = new DatabaseSync(databasePath)
|
||||
expect(
|
||||
(
|
||||
unchanged.prepare('PRAGMA user_version').get() as {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(99)
|
||||
unchanged.close()
|
||||
})
|
||||
|
||||
it('migrates existing databases to schema version 8', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||
)
|
||||
@@ -52,7 +80,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(7)
|
||||
).toBe(8)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -125,7 +153,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(7)
|
||||
).toBe(8)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -242,6 +270,83 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('roundtrips expert model profiles and tolerates malformed model policies', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-expert-model-policy-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const firstModelProfileId =
|
||||
'00000000-0000-4000-8000-000000000401'
|
||||
const secondModelProfileId =
|
||||
'00000000-0000-4000-8000-000000000402'
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.initialize('C:\\Workspace')
|
||||
|
||||
const expert = database.createExpert({
|
||||
name: '模型绑定专家',
|
||||
description: '验证模型策略持久化',
|
||||
systemInstructions: 'Use the assigned model connection.',
|
||||
modelProfileId: firstModelProfileId,
|
||||
routingKeywords: ['模型绑定']
|
||||
})
|
||||
expect(expert.modelProfileId).toBe(firstModelProfileId)
|
||||
expect(
|
||||
database.listExperts().find((item) => item.id === expert.id)
|
||||
).toMatchObject({
|
||||
modelProfileId: firstModelProfileId,
|
||||
routingKeywords: ['模型绑定']
|
||||
})
|
||||
|
||||
const updated = database.updateExpert(expert.id, {
|
||||
name: expert.name,
|
||||
description: expert.description,
|
||||
systemInstructions: expert.systemInstructions,
|
||||
modelProfileId: secondModelProfileId,
|
||||
routingKeywords: expert.routingKeywords
|
||||
})
|
||||
expect(updated.modelProfileId).toBe(secondModelProfileId)
|
||||
database.close()
|
||||
|
||||
const persisted = new DatabaseSync(databasePath)
|
||||
expect(
|
||||
JSON.parse(
|
||||
(
|
||||
persisted
|
||||
.prepare(
|
||||
'SELECT model_policy_json FROM experts WHERE id = ?'
|
||||
)
|
||||
.get(expert.id) as { model_policy_json: string }
|
||||
).model_policy_json
|
||||
)
|
||||
).toEqual({ modelProfileId: secondModelProfileId })
|
||||
expect(
|
||||
(
|
||||
persisted.prepare('PRAGMA table_info(experts)').all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
).some((column) => column.name === 'model_profile_id')
|
||||
).toBe(false)
|
||||
persisted
|
||||
.prepare(
|
||||
'UPDATE experts SET model_policy_json = ? WHERE id = ?'
|
||||
)
|
||||
.run('{malformed-json', expert.id)
|
||||
persisted.close()
|
||||
|
||||
const reopened = new AssistantDatabase(databasePath)
|
||||
reopened.initialize('C:\\Workspace')
|
||||
const recoveredExpert = reopened
|
||||
.listExperts()
|
||||
.find((item) => item.id === expert.id)
|
||||
reopened.close()
|
||||
expect(recoveredExpert).toMatchObject({
|
||||
id: expert.id,
|
||||
routingKeywords: ['模型绑定']
|
||||
})
|
||||
expect(recoveredExpert?.modelProfileId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('persists task lifecycle and events', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.listProjects()[0]!
|
||||
@@ -472,6 +577,10 @@ describe('AssistantDatabase', () => {
|
||||
{
|
||||
id: conversationId,
|
||||
projectId: project.id,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: '00000000-0000-4000-8000-000000000299'
|
||||
},
|
||||
title: '发布讨论',
|
||||
updatedAt: 1_775_000_000_000,
|
||||
messages: [
|
||||
@@ -530,6 +639,10 @@ describe('AssistantDatabase', () => {
|
||||
expect.objectContaining({
|
||||
id: conversationId,
|
||||
projectId: project.id,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: '00000000-0000-4000-8000-000000000299'
|
||||
},
|
||||
messages: [
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
@@ -570,6 +683,57 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('rebinds persisted conversations whose model profile was removed', async () => {
|
||||
const database = await createDatabase()
|
||||
const removedProfileId =
|
||||
'00000000-0000-4000-8000-000000000291'
|
||||
const defaultProfileId =
|
||||
'00000000-0000-4000-8000-000000000292'
|
||||
const runtimeProfileId =
|
||||
'00000000-0000-4000-8000-000000000293'
|
||||
database.replaceConversations(
|
||||
([
|
||||
['model', removedProfileId],
|
||||
['opencode', removedProfileId],
|
||||
['continue', removedProfileId],
|
||||
['model', runtimeProfileId]
|
||||
] as const).map(([provider, profileId], index) => ({
|
||||
id: `00000000-0000-4000-8000-00000000030${index}`,
|
||||
runtimeSelection: { provider, profileId },
|
||||
title: `对话 ${index}`,
|
||||
updatedAt: index + 1,
|
||||
messages: []
|
||||
}))
|
||||
)
|
||||
|
||||
expect(
|
||||
database.repairConversationRuntimeSelections({
|
||||
modelProfiles: [
|
||||
{ id: defaultProfileId },
|
||||
{ id: runtimeProfileId }
|
||||
],
|
||||
defaultModelProfileId: defaultProfileId,
|
||||
opencodeModelSource: {
|
||||
kind: 'profile',
|
||||
profileId: runtimeProfileId
|
||||
},
|
||||
continueModelSource: { kind: 'platform' }
|
||||
})
|
||||
).toBe(3)
|
||||
expect(
|
||||
database
|
||||
.listConversations()
|
||||
.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: runtimeProfileId }
|
||||
])
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('durably interrupts active tool metadata during startup recovery', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-conversation-recovery-')
|
||||
|
||||
@@ -30,6 +30,12 @@ import {
|
||||
type ComputerControlErrorCode,
|
||||
type ComputerControlRisk
|
||||
} from '../../shared/computer-control-contracts'
|
||||
import {
|
||||
agentRuntimeSelectionKey,
|
||||
agentRuntimeSelectionSchema,
|
||||
repairAgentRuntimeSelection,
|
||||
type RuntimeSelectionRepairSettings
|
||||
} from '../../shared/runtime-selection-contracts'
|
||||
import type { ComputerControlAuditEvent } from '../computer-control/audit'
|
||||
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
|
||||
|
||||
@@ -65,6 +71,7 @@ type TaskRow = {
|
||||
type ConversationRow = {
|
||||
id: string
|
||||
project_id: string | null
|
||||
runtime_selection_json: string | null
|
||||
title: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -89,6 +96,22 @@ type MessageMetadata = {
|
||||
attachments?: ConversationSnapshot['messages'][number]['attachments']
|
||||
}
|
||||
|
||||
function parseRuntimeSelection(value: string | null):
|
||||
| ConversationSnapshot['runtimeSelection']
|
||||
| undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const parsed = agentRuntimeSelectionSchema.safeParse(
|
||||
JSON.parse(value)
|
||||
)
|
||||
return parsed.success ? parsed.data : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
type ArtifactRow = {
|
||||
id: string
|
||||
project_id: string | null
|
||||
@@ -133,6 +156,7 @@ type ExpertRow = {
|
||||
description: string
|
||||
system_instructions: string
|
||||
capability_policy_json: string
|
||||
model_policy_json: string
|
||||
enabled: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -341,6 +365,7 @@ function toSchedule(row: ScheduleRow): AssistantSchedule {
|
||||
|
||||
function toExpert(row: ExpertRow): AssistantExpert {
|
||||
let routingKeywords: string[]
|
||||
let modelProfileId: string | undefined
|
||||
try {
|
||||
const policy = JSON.parse(row.capability_policy_json) as {
|
||||
routingKeywords?: unknown
|
||||
@@ -356,11 +381,24 @@ function toExpert(row: ExpertRow): AssistantExpert {
|
||||
} catch {
|
||||
routingKeywords = []
|
||||
}
|
||||
try {
|
||||
const policy = JSON.parse(row.model_policy_json) as {
|
||||
modelProfileId?: unknown
|
||||
}
|
||||
modelProfileId = expertCreateSchema
|
||||
.pick({ modelProfileId: true })
|
||||
.parse({
|
||||
modelProfileId: policy.modelProfileId
|
||||
}).modelProfileId
|
||||
} catch {
|
||||
modelProfileId = undefined
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
systemInstructions: row.system_instructions,
|
||||
...(modelProfileId ? { modelProfileId } : {}),
|
||||
routingKeywords,
|
||||
enabled: row.enabled === 1,
|
||||
createdAt: row.created_at,
|
||||
@@ -825,7 +863,7 @@ export class AssistantDatabase {
|
||||
const database = this.requireDatabase()
|
||||
const conversations = database
|
||||
.prepare(
|
||||
`SELECT id, project_id, title, updated_at
|
||||
`SELECT id, project_id, runtime_selection_json, title, updated_at
|
||||
FROM conversations
|
||||
WHERE status = 'active'
|
||||
ORDER BY updated_at DESC
|
||||
@@ -843,6 +881,9 @@ export class AssistantDatabase {
|
||||
return conversations.map((conversation) => ({
|
||||
id: conversation.id,
|
||||
projectId: conversation.project_id ?? undefined,
|
||||
runtimeSelection: parseRuntimeSelection(
|
||||
conversation.runtime_selection_json
|
||||
),
|
||||
title: conversation.title,
|
||||
updatedAt: Date.parse(conversation.updated_at),
|
||||
messages: (
|
||||
@@ -874,6 +915,53 @@ export class AssistantDatabase {
|
||||
}))
|
||||
}
|
||||
|
||||
repairConversationRuntimeSelections(
|
||||
settings: RuntimeSelectionRepairSettings
|
||||
): number {
|
||||
const database = this.requireDatabase()
|
||||
const conversations = database
|
||||
.prepare(
|
||||
`SELECT id, runtime_selection_json
|
||||
FROM conversations
|
||||
WHERE runtime_selection_json IS NOT NULL`
|
||||
)
|
||||
.all() as Array<{
|
||||
id: string
|
||||
runtime_selection_json: string
|
||||
}>
|
||||
const update = database.prepare(
|
||||
`UPDATE conversations
|
||||
SET runtime_selection_json = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
let repaired = 0
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
for (const conversation of conversations) {
|
||||
const current = parseRuntimeSelection(
|
||||
conversation.runtime_selection_json
|
||||
)
|
||||
if (!current) {
|
||||
continue
|
||||
}
|
||||
const next = repairAgentRuntimeSelection(current, settings)
|
||||
if (
|
||||
agentRuntimeSelectionKey(next) ===
|
||||
agentRuntimeSelectionKey(current)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
update.run(JSON.stringify(next), conversation.id)
|
||||
repaired += 1
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
return repaired
|
||||
}
|
||||
|
||||
replaceConversations(
|
||||
conversations: ConversationSnapshot[]
|
||||
): void {
|
||||
@@ -883,8 +971,9 @@ export class AssistantDatabase {
|
||||
database.exec('DELETE FROM messages; DELETE FROM conversations;')
|
||||
const insertConversation = database.prepare(
|
||||
`INSERT INTO conversations
|
||||
(id, project_id, work_mode, title, status, created_at, updated_at)
|
||||
VALUES (?, ?, 'ask', ?, 'active', ?, ?)`
|
||||
(id, project_id, runtime_selection_json, work_mode, title, status,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||
)
|
||||
const insertMessage = database.prepare(
|
||||
`INSERT INTO messages
|
||||
@@ -897,6 +986,9 @@ export class AssistantDatabase {
|
||||
insertConversation.run(
|
||||
conversation.id,
|
||||
conversation.projectId ?? null,
|
||||
conversation.runtimeSelection
|
||||
? JSON.stringify(conversation.runtimeSelection)
|
||||
: null,
|
||||
conversation.title,
|
||||
updatedAt,
|
||||
updatedAt
|
||||
@@ -2618,7 +2710,7 @@ export class AssistantDatabase {
|
||||
(id, name, description, system_instructions,
|
||||
capability_policy_json, model_policy_json, enabled,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, '{}', 1, ?, ?)`
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
@@ -2628,6 +2720,9 @@ export class AssistantDatabase {
|
||||
JSON.stringify({
|
||||
routingKeywords: normalized.routingKeywords
|
||||
}),
|
||||
JSON.stringify({
|
||||
modelProfileId: normalized.modelProfileId
|
||||
}),
|
||||
now,
|
||||
now
|
||||
)
|
||||
@@ -2644,6 +2739,7 @@ export class AssistantDatabase {
|
||||
`UPDATE experts
|
||||
SET name = ?, description = ?, system_instructions = ?,
|
||||
capability_policy_json = ?,
|
||||
model_policy_json = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND enabled = 1`
|
||||
)
|
||||
@@ -2654,6 +2750,9 @@ export class AssistantDatabase {
|
||||
JSON.stringify({
|
||||
routingKeywords: normalized.routingKeywords
|
||||
}),
|
||||
JSON.stringify({
|
||||
modelProfileId: normalized.modelProfileId
|
||||
}),
|
||||
new Date().toISOString(),
|
||||
expertId
|
||||
)
|
||||
@@ -2730,7 +2829,12 @@ export class AssistantDatabase {
|
||||
const version = database
|
||||
.prepare('PRAGMA user_version')
|
||||
.get() as { user_version: number }
|
||||
if (version.user_version >= 7) {
|
||||
if (version.user_version > 8) {
|
||||
throw new Error(
|
||||
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
|
||||
)
|
||||
}
|
||||
if (version.user_version === 8) {
|
||||
return
|
||||
}
|
||||
if (version.user_version < 1) {
|
||||
@@ -2750,6 +2854,7 @@ export class AssistantDatabase {
|
||||
CREATE TABLE conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
||||
runtime_selection_json TEXT,
|
||||
work_mode TEXT NOT NULL DEFAULT 'ask'
|
||||
CHECK(work_mode IN ('ask', 'plan', 'execute')),
|
||||
title TEXT NOT NULL,
|
||||
@@ -3123,6 +3228,27 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (version.user_version < 8) {
|
||||
const conversationColumns = new Set(
|
||||
(
|
||||
database.prepare('PRAGMA table_info(conversations)').all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
).map((column) => column.name)
|
||||
)
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
if (!conversationColumns.has('runtime_selection_json')) {
|
||||
database.exec(
|
||||
'ALTER TABLE conversations ADD COLUMN runtime_selection_json TEXT'
|
||||
)
|
||||
}
|
||||
database.exec('PRAGMA user_version = 8; COMMIT;')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requireDatabase(): DatabaseSync {
|
||||
|
||||
@@ -59,7 +59,7 @@ const summary = {
|
||||
}
|
||||
|
||||
describe('AssistantDatabase heartbeat persistence', () => {
|
||||
it('migrates v2 to v3 without changing existing schedules', async () => {
|
||||
it('migrates a v2 database without changing existing schedules', async () => {
|
||||
const { database, path } = await createDatabase()
|
||||
const schedule = database.createSchedule({
|
||||
title: 'Existing schedule',
|
||||
@@ -85,25 +85,23 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
||||
})
|
||||
])
|
||||
const check = new DatabaseSync(path)
|
||||
expect(
|
||||
(
|
||||
check.prepare('PRAGMA user_version').get() as {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(7)
|
||||
expect(
|
||||
(
|
||||
check
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM sqlite_master
|
||||
WHERE type = 'table' AND name LIKE 'heartbeat_%'`
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count
|
||||
).toBe(3)
|
||||
const version = (
|
||||
check.prepare('PRAGMA user_version').get() as {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
const heartbeatTableCount = (
|
||||
check
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM sqlite_master
|
||||
WHERE type = 'table' AND name LIKE 'heartbeat_%'`
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count
|
||||
check.close()
|
||||
migrated.close()
|
||||
expect(version).toBe(8)
|
||||
expect(heartbeatTableCount).toBe(3)
|
||||
})
|
||||
|
||||
it('claims one scheduled run durably and advances local recurrence', async () => {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setIntranetCompatibilityReader } from '../intranet-compatibility-policy'
|
||||
import { RemoteDelegationService } from './remote-delegation-service'
|
||||
|
||||
beforeEach(() => {
|
||||
setIntranetCompatibilityReader(() => false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
})
|
||||
|
||||
describe('RemoteDelegationService', () => {
|
||||
it('polls a public HTTPS endpoint and posts a bounded result', async () => {
|
||||
const transport = vi
|
||||
@@ -22,7 +31,7 @@ describe('RemoteDelegationService', () => {
|
||||
const service = new RemoteDelegationService({
|
||||
endpoint: 'https://delegate.example',
|
||||
token: 'test-token',
|
||||
lookup: async () => [{ address: '203.0.113.10', family: 4 }],
|
||||
lookup: async () => [{ address: '1.1.1.1', family: 4 }],
|
||||
transport,
|
||||
onTask
|
||||
})
|
||||
@@ -66,7 +75,7 @@ describe('RemoteDelegationService', () => {
|
||||
const service = new RemoteDelegationService({
|
||||
endpoint: 'https://delegate.example',
|
||||
token: 'test-token',
|
||||
lookup: async () => [{ address: '203.0.113.10', family: 4 }],
|
||||
lookup: async () => [{ address: '1.1.1.1', family: 4 }],
|
||||
transport,
|
||||
onTask
|
||||
})
|
||||
@@ -122,7 +131,7 @@ describe('RemoteDelegationService', () => {
|
||||
const service = new RemoteDelegationService({
|
||||
endpoint: 'https://delegate.example',
|
||||
token: 'test-token',
|
||||
lookup: async () => [{ address: '203.0.113.10', family: 4 }],
|
||||
lookup: async () => [{ address: '1.1.1.1', family: 4 }],
|
||||
transport,
|
||||
onTask,
|
||||
outbox
|
||||
@@ -140,7 +149,7 @@ describe('RemoteDelegationService', () => {
|
||||
const service = new RemoteDelegationService({
|
||||
endpoint: 'https://delegate.example',
|
||||
token: 'test-token',
|
||||
lookup: async () => [{ address: '203.0.113.10', family: 4 }],
|
||||
lookup: async () => [{ address: '1.1.1.1', family: 4 }],
|
||||
transport: async (_url, _address, _token, _method, signal) => {
|
||||
observedSignal = signal
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
@@ -174,4 +183,94 @@ describe('RemoteDelegationService', () => {
|
||||
|
||||
await expect(service.pollOnce()).rejects.toThrow('私有或不安全网络')
|
||||
})
|
||||
|
||||
it('allows pinned HTTP private endpoints in compatibility mode', async () => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
const transport = vi.fn(async () => ({ status: 204, body: '' }))
|
||||
const service = new RemoteDelegationService({
|
||||
endpoint: 'http://delegate.internal',
|
||||
token: 'test-token',
|
||||
lookup: async () => [{ address: '10.20.30.40', family: 4 }],
|
||||
transport,
|
||||
onTask: vi.fn()
|
||||
})
|
||||
|
||||
await service.pollOnce()
|
||||
|
||||
expect(transport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
protocol: 'http:',
|
||||
pathname: '/goodbuddy/tasks/next'
|
||||
}),
|
||||
{ address: '10.20.30.40', family: 4 },
|
||||
'test-token',
|
||||
'GET',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
})
|
||||
|
||||
it('requires HTTPS for public endpoints even in compatibility mode', async () => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
const transport = vi.fn()
|
||||
const service = new RemoteDelegationService({
|
||||
endpoint: 'http://delegate.example',
|
||||
token: 'test-token',
|
||||
lookup: async () => [{ address: '1.1.1.1', family: 4 }],
|
||||
transport,
|
||||
onTask: vi.fn()
|
||||
})
|
||||
|
||||
await expect(service.pollOnce()).rejects.toThrow(
|
||||
'HTTP 远程委派仅允许解析到内网地址'
|
||||
)
|
||||
expect(transport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps unsafe endpoints and mixed DNS answers blocked in compatibility mode', async () => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
expect(
|
||||
() =>
|
||||
new RemoteDelegationService({
|
||||
endpoint: 'http://metadata.google.internal',
|
||||
token: 'test-token',
|
||||
onTask: vi.fn()
|
||||
})
|
||||
).toThrow('元数据')
|
||||
expect(
|
||||
() =>
|
||||
new RemoteDelegationService({
|
||||
endpoint: 'http://user:secret@delegate.internal',
|
||||
token: 'test-token',
|
||||
onTask: vi.fn()
|
||||
})
|
||||
).toThrow('无凭据')
|
||||
|
||||
const mixed = new RemoteDelegationService({
|
||||
endpoint: 'http://delegate.internal',
|
||||
token: 'test-token',
|
||||
lookup: async () => [
|
||||
{ address: '10.20.30.40', family: 4 },
|
||||
{ address: '1.1.1.1', family: 4 }
|
||||
],
|
||||
transport: vi.fn(),
|
||||
onTask: vi.fn()
|
||||
})
|
||||
await expect(mixed.pollOnce()).rejects.toThrow('不安全网络')
|
||||
})
|
||||
|
||||
it('re-applies strict transport policy after compatibility mode is disabled', async () => {
|
||||
setIntranetCompatibilityReader(() => true)
|
||||
const transport = vi.fn()
|
||||
const service = new RemoteDelegationService({
|
||||
endpoint: 'http://delegate.internal',
|
||||
token: 'test-token',
|
||||
lookup: async () => [{ address: '10.20.30.40', family: 4 }],
|
||||
transport,
|
||||
onTask: vi.fn()
|
||||
})
|
||||
setIntranetCompatibilityReader(() => false)
|
||||
|
||||
await expect(service.pollOnce()).rejects.toThrow('HTTPS')
|
||||
expect(transport).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { lookup as dnsLookup } from 'node:dns/promises'
|
||||
import { request as httpRequest } from 'node:http'
|
||||
import { request as httpsRequest } from 'node:https'
|
||||
import { isIP } from 'node:net'
|
||||
import { z } from 'zod'
|
||||
import { isPublicAddress } from '../knowledge/url-importer'
|
||||
import { isIntranetCompatibilityEnabled } from '../intranet-compatibility-policy'
|
||||
import {
|
||||
isIntranetAddress,
|
||||
isPublicAddress
|
||||
} from '../knowledge/url-importer'
|
||||
|
||||
const remoteTaskSchema = z
|
||||
.object({
|
||||
@@ -52,17 +58,39 @@ type RemoteDelegationOptions = {
|
||||
}
|
||||
}
|
||||
|
||||
const BLOCKED_REMOTE_HOSTS = new Set([
|
||||
'instance-data',
|
||||
'instance-data.ec2.internal',
|
||||
'metadata',
|
||||
'metadata.aws.internal',
|
||||
'metadata.google.internal'
|
||||
])
|
||||
|
||||
function normalizeEndpoint(input: string): URL {
|
||||
const url = new URL(input.trim())
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
(
|
||||
url.protocol !== 'https:' &&
|
||||
(
|
||||
url.protocol !== 'http:' ||
|
||||
!isIntranetCompatibilityEnabled()
|
||||
)
|
||||
) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
(url.pathname !== '' && url.pathname !== '/')
|
||||
) {
|
||||
throw new Error('远程委派地址必须是无凭据和路径的 HTTPS origin')
|
||||
throw new Error(
|
||||
isIntranetCompatibilityEnabled()
|
||||
? '远程委派地址必须是无凭据和路径的 HTTP(S) origin'
|
||||
: '远程委派地址必须是无凭据和路径的 HTTPS origin'
|
||||
)
|
||||
}
|
||||
const hostname = url.hostname.toLowerCase().replace(/\.$/u, '')
|
||||
if (BLOCKED_REMOTE_HOSTS.has(hostname)) {
|
||||
throw new Error('远程委派地址不允许访问云元数据服务')
|
||||
}
|
||||
return url
|
||||
}
|
||||
@@ -88,7 +116,7 @@ function defaultTransport(
|
||||
settled = true
|
||||
reject(error)
|
||||
}
|
||||
const request = httpsRequest(
|
||||
const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(
|
||||
url,
|
||||
{
|
||||
method,
|
||||
@@ -103,7 +131,9 @@ function defaultTransport(
|
||||
lookup: (_hostname, _options, callback) => {
|
||||
callback(null, address.address, address.family)
|
||||
},
|
||||
servername: url.hostname,
|
||||
...(url.protocol === 'https:'
|
||||
? { servername: url.hostname }
|
||||
: {}),
|
||||
signal
|
||||
},
|
||||
(response) => {
|
||||
@@ -187,7 +217,7 @@ export class RemoteDelegationService {
|
||||
const controller = new AbortController()
|
||||
this.activeRequest = controller
|
||||
try {
|
||||
const address = await this.resolvePublicAddress()
|
||||
const address = await this.resolveAddress()
|
||||
const durablePending = this.options.outbox?.listPending()[0]
|
||||
const memoryPending = this.pendingResults.entries().next().value
|
||||
const pending = durablePending
|
||||
@@ -295,12 +325,45 @@ export class RemoteDelegationService {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolvePublicAddress(): Promise<ResolvedAddress> {
|
||||
private async resolveAddress(): Promise<ResolvedAddress> {
|
||||
if (
|
||||
this.endpoint.protocol === 'http:' &&
|
||||
!isIntranetCompatibilityEnabled()
|
||||
) {
|
||||
throw new Error('远程委派地址必须使用 HTTPS')
|
||||
}
|
||||
const addresses = await this.lookup(this.endpoint.hostname)
|
||||
const address = addresses.find((candidate) =>
|
||||
isPublicAddress(candidate.address)
|
||||
const addressTypes = addresses.map((candidate) =>
|
||||
candidate.family !== isIP(candidate.address)
|
||||
? 'blocked'
|
||||
: isPublicAddress(candidate.address)
|
||||
? 'public'
|
||||
: isIntranetAddress(candidate.address)
|
||||
? 'intranet'
|
||||
: 'blocked'
|
||||
)
|
||||
if (!address || addresses.some((candidate) => !isPublicAddress(candidate.address))) {
|
||||
const address = addresses[0]
|
||||
const compatibilityEnabled = isIntranetCompatibilityEnabled()
|
||||
const plaintextOutsideIntranet =
|
||||
this.endpoint.protocol === 'http:' &&
|
||||
addressTypes.some((addressType) => addressType !== 'intranet')
|
||||
if (
|
||||
!address ||
|
||||
addressTypes.includes('blocked') ||
|
||||
new Set(addressTypes).size !== 1 ||
|
||||
plaintextOutsideIntranet ||
|
||||
(
|
||||
!compatibilityEnabled &&
|
||||
addressTypes.some((addressType) => addressType !== 'public')
|
||||
)
|
||||
) {
|
||||
if (
|
||||
plaintextOutsideIntranet &&
|
||||
!addressTypes.includes('blocked') &&
|
||||
new Set(addressTypes).size === 1
|
||||
) {
|
||||
throw new Error('HTTP 远程委派仅允许解析到内网地址')
|
||||
}
|
||||
throw new Error('远程委派地址解析到私有或不安全网络')
|
||||
}
|
||||
return address
|
||||
|
||||
@@ -107,4 +107,57 @@ describe('SubagentService', () => {
|
||||
)
|
||||
await service.dispose()
|
||||
})
|
||||
|
||||
it('uses an expert model profile and falls back to the default runtime', async () => {
|
||||
const calls: string[] = []
|
||||
const createRuntime = (label: string): AgentRuntime =>
|
||||
({
|
||||
run: async function* (request: AgentExecutionRequest) {
|
||||
calls.push(label)
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: label
|
||||
} as const
|
||||
yield { requestId: request.requestId, type: 'done' } as const
|
||||
},
|
||||
releaseConversation: vi.fn(async () => undefined),
|
||||
dispose: vi.fn(async () => undefined)
|
||||
}) as unknown as AgentRuntime
|
||||
const defaultRuntime = createRuntime('default')
|
||||
const profileRuntime = createRuntime('profile')
|
||||
const profileId = '00000000-0000-4000-8000-000000000002'
|
||||
const service = new SubagentService(
|
||||
defaultRuntime,
|
||||
database() as never,
|
||||
new SubagentScheduler({ timeoutMs: 1_000 }),
|
||||
new Map([[profileId, profileRuntime]])
|
||||
)
|
||||
|
||||
const selected = await service.run({
|
||||
parentRequest,
|
||||
expert: { ...expert, modelProfileId: profileId },
|
||||
routingMode: 'manual',
|
||||
signal: new AbortController().signal,
|
||||
onEvent: vi.fn()
|
||||
})
|
||||
const fallback = await service.run({
|
||||
parentRequest: {
|
||||
...parentRequest,
|
||||
requestId: '00000000-0000-4000-8000-000000000011'
|
||||
},
|
||||
expert: {
|
||||
...expert,
|
||||
modelProfileId: '00000000-0000-4000-8000-000000000099'
|
||||
},
|
||||
routingMode: 'manual',
|
||||
signal: new AbortController().signal,
|
||||
onEvent: vi.fn()
|
||||
})
|
||||
|
||||
expect(selected.output).toBe('profile')
|
||||
expect(fallback.output).toBe('default')
|
||||
expect(calls).toEqual(['profile', 'default'])
|
||||
await service.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,24 +45,53 @@ export class SubagentService {
|
||||
constructor(
|
||||
private runtime: AgentRuntime,
|
||||
private readonly database: AssistantDatabase,
|
||||
private readonly scheduler = new SubagentScheduler()
|
||||
private readonly scheduler = new SubagentScheduler(),
|
||||
private profileRuntimes: ReadonlyMap<string, AgentRuntime> =
|
||||
new Map()
|
||||
) {}
|
||||
|
||||
async replaceRuntime(runtime: AgentRuntime): Promise<void> {
|
||||
if (runtime === this.runtime) {
|
||||
await this.replaceRuntimes(runtime, new Map())
|
||||
}
|
||||
|
||||
async replaceRuntimes(
|
||||
runtime: AgentRuntime,
|
||||
profileRuntimes: ReadonlyMap<string, AgentRuntime>
|
||||
): Promise<void> {
|
||||
const nextProfiles = new Map(profileRuntimes)
|
||||
if (
|
||||
runtime === this.runtime &&
|
||||
nextProfiles.size === this.profileRuntimes.size &&
|
||||
[...nextProfiles].every(
|
||||
([profileId, profileRuntime]) =>
|
||||
this.profileRuntimes.get(profileId) === profileRuntime
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.scheduler.cancelAll(new Error('默认模型设置已更改'))
|
||||
const previous = this.runtime
|
||||
const previous = new Set([
|
||||
this.runtime,
|
||||
...this.profileRuntimes.values()
|
||||
])
|
||||
this.runtime = runtime
|
||||
this.profileRuntimes = nextProfiles
|
||||
await this.scheduler.waitForIdle()
|
||||
await previous.dispose()
|
||||
const retained = new Set([runtime, ...nextProfiles.values()])
|
||||
await Promise.allSettled(
|
||||
[...previous]
|
||||
.filter((candidate) => !retained.has(candidate))
|
||||
.map((candidate) => candidate.dispose())
|
||||
)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.scheduler.dispose()
|
||||
await this.scheduler.waitForIdle()
|
||||
await this.runtime.dispose()
|
||||
await Promise.allSettled(
|
||||
[...new Set([this.runtime, ...this.profileRuntimes.values()])]
|
||||
.map((runtime) => runtime.dispose())
|
||||
)
|
||||
}
|
||||
|
||||
cancelAll(reason: string): void {
|
||||
@@ -149,7 +178,10 @@ export class SubagentService {
|
||||
started = true
|
||||
this.database.updateTaskStatus(childTaskId, 'running')
|
||||
this.emit(input, { childTaskId, state: 'running' })
|
||||
const runtime = this.runtime
|
||||
const runtime =
|
||||
(input.expert.modelProfileId
|
||||
? this.profileRuntimes.get(input.expert.modelProfileId)
|
||||
: undefined) ?? this.runtime
|
||||
let output = ''
|
||||
let completed = false
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user