Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions .playwright/scripts/__tests__/mutation-recovery.e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import path from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { expect, test } from '../fixtures/electron.mjs'

const oldOperation = '11111111-1111-4111-8111-111111111111'
const deletedOperation = '22222222-2222-4222-8222-222222222222'
const unrelatedOperation = '33333333-3333-4333-8333-333333333333'

function seedRecovery(databasePath) {
const database = new DatabaseSync(databasePath)
const now = '2026-10-15T00:00:00.000Z'
try {
database.exec('PRAGMA foreign_keys = ON')
for (const [id, title] of [['source', '此前的整理任务'], ['blocked', '新的写入任务'], ['unrelated', '其他任务']]) {
database.prepare('INSERT INTO conversations (id, title, active_branch_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(id, title, `branch-${id}`, now, now)
database.prepare('INSERT INTO conversation_branches (id, conversation_id, created_at) VALUES (?, ?, ?)').run(`branch-${id}`, id, now)
database.prepare(`INSERT INTO runs (id, conversation_id, branch_id, triggering_message_id, provider, model, purpose, status, started_at, completed_at)
VALUES (?, ?, ?, ?, 'fixture', 'fixture', 'chat', 'failed', ?, ?)`).run(`run-${id}`, id, `branch-${id}`, `input-${id}`, now, now)
}
for (const [id, runId, resource] of [
[oldOperation, 'run-source', '/example/shared/report.md'],
[deletedOperation, 'deleted-run', '/example/shared/notes.md'],
[unrelatedOperation, 'run-unrelated', '/example/shared/report.md'],
]) {
database.prepare(`INSERT INTO run_mutation_leases (invocation_id, run_id, segment_id, claims_json, status, reason, created_at, updated_at, lifetime)
VALUES (?, ?, ?, ?, 'hazard', 'MUTATION_INTERRUPTED', ?, ?, 'external')`).run(id, runId, `segment-${id}`, JSON.stringify([{ kind: 'file', canonicalPath: resource }]), now, now)
database.prepare('INSERT INTO run_mutation_processes (invocation_id, process_id, settlement, updated_at) VALUES (?, ?, \'unknown\', ?)')
.run(id, JSON.stringify({ kind: 'legacy-process', pid: 123456, job: 'not-user-facing', checkpoint: 'not-user-facing' }), now)
if (id !== unrelatedOperation) {
database.prepare(`INSERT INTO run_mutation_evidence (invocation_id, kind, outcome, reason, actor, related_run_id, created_at)
VALUES (?, 'observed', 'unknown', 'WRITE_BLOCKED', 'system', 'run-blocked', ?)`).run(id, now)
}
}
}
finally { database.close() }
}

function readRecovery(databasePath) {
const database = new DatabaseSync(databasePath, { readOnly: true })
try {
return {
leases: database.prepare('SELECT invocation_id, status, reason FROM run_mutation_leases ORDER BY invocation_id').all(),
decisions: database.prepare('SELECT invocation_id, kind, outcome, actor FROM run_mutation_evidence WHERE kind = \'waived\' ORDER BY sequence').all(),
runs: database.prepare('SELECT id FROM runs ORDER BY id').all(),
}
}
finally { database.close() }
}

test('task recovery discovers older blockers and only waives the explicitly confirmed set', async ({ buddy }, testInfo) => {
test.setTimeout(120_000)
const instance = await buddy.createInstance('mutation-recovery')
const databasePath = path.join(instance.home, 'buddy/buddy.sqlite3')
await instance.launch()
await instance.stop()
seedRecovery(databasePath)
let { app, page, diagnostics } = await instance.launch()
await page.evaluate(async () => {
const api = window.lexoraDesktop.localChat
const { defaultBuddyId } = await api.buddies.list()
for (const id of ['source', 'blocked', 'unrelated']) {
await api.conversations.addMember(id, defaultBuddyId)
await api.conversations.setBuddy(id, defaultBuddyId)
}
})
await page.locator('[data-task-id="blocked"] .desktop-task-sidebar__task').click()
const card = () => page.locator('.desktop-change-recovery:visible')
await expect(card()).toContainText('此前的整理任务')
await expect(card()).toContainText('已删除的任务')
await expect(card()).toContainText('/example/shared/report.md')
await expect(card()).not.toContainText('其他任务')
await expect(card()).not.toContainText(/123456|not-user-facing|PID|checkpoint/i)
await expect(card().getByRole('button', { name: /停止|结束/ })).toHaveCount(0)
await expect(card().getByRole('button', { name: '接受风险并恢复修改', exact: true })).toHaveCount(0)

const initial = await page.evaluate(() => window.lexoraDesktop.localChat.changes.recovery.list({ conversationId: 'blocked' }))
expect(initial.map(item => item.invocationId).sort()).toEqual([oldOperation, deletedOperation])
expect(initial.every(item => item.status === 'unknown' && item.revision)).toBe(true)
expect(readRecovery(databasePath).decisions).toEqual([])
await card().getByRole('button', { name: '重新检查', exact: true }).click()
await expect(card().getByRole('button', { name: '重新检查', exact: true })).toBeEnabled()
await expect(card()).toContainText('暂时无法确认此前的操作是否已停止')
expect(readRecovery(databasePath).decisions).toEqual([])

for (const theme of ['light', 'dark']) {
await page.evaluate(theme => window.lexoraDesktop.themes.request({ action: 'preference', preference: { id: `lexora.themes.classic-${theme}` } }), theme)
await expect(page.locator('html')).toHaveAttribute('data-buddy-theme', theme)
await page.screenshot({ path: path.join(instance.artifactDirectory, `recovery-${theme}.png`), animations: 'disabled' })
}
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(980, 640))
await expect(card().getByRole('button', { name: '重新检查', exact: true })).toBeInViewport()
expect(await card().evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true)
await page.screenshot({ path: path.join(instance.artifactDirectory, 'recovery-narrow.png'), animations: 'disabled' })

async function review() {
await card().getByRole('button', { name: '更多选项', exact: true }).click()
await page.locator('.n-dropdown-menu:visible').getByText('接受风险并恢复修改', { exact: true }).click()
}
await review()
const confirmation = page.locator('.n-dialog').filter({ hasText: '接受风险并恢复修改?' })
await expect(confirmation).toContainText('仅对以下 2 项恢复修改')
await expect(confirmation).toContainText('不会重新运行旧命令')
await page.screenshot({ path: path.join(instance.artifactDirectory, 'recovery-confirmation.png'), animations: 'disabled' })
await confirmation.getByRole('button', { name: '取消', exact: true }).click()
expect(readRecovery(databasePath).decisions).toEqual([])
await review()
await confirmation.getByRole('button', { name: '接受风险并恢复修改', exact: true }).click()
await expect(confirmation).toBeHidden()
await expect(card()).toHaveCount(0)
await expect(page.getByText('已按你的选择接受风险并恢复所选位置的修改;此前操作仍未确认结束,旧命令不会自动重跑。', { exact: true })).toBeVisible()
await page.screenshot({ path: path.join(instance.artifactDirectory, 'recovery-waived.png'), animations: 'disabled' })
const resolved = readRecovery(databasePath)
expect(resolved.leases).toEqual([
{ invocation_id: oldOperation, status: 'released', reason: 'USER_ACCEPTED_RISK' },
{ invocation_id: deletedOperation, status: 'released', reason: 'USER_ACCEPTED_RISK' },
{ invocation_id: unrelatedOperation, status: 'hazard', reason: 'MUTATION_INTERRUPTED' },
])
expect(resolved.decisions.filter(item => item.outcome === 'applied').map(item => [item.invocation_id, item.kind, item.actor]).sort())
.toEqual([[oldOperation, 'waived', 'user'], [deletedOperation, 'waived', 'user']])
expect(resolved.runs.map(run => run.id)).toEqual(['run-blocked', 'run-source', 'run-unrelated'])
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
await instance.stop()
;({ app, page, diagnostics } = await instance.launch())
expect(await page.evaluate(() => window.lexoraDesktop.localChat.changes.recovery.list({ conversationId: 'blocked' }))).toEqual([])
expect((await page.evaluate(() => window.lexoraDesktop.localChat.changes.recovery.list({ conversationId: 'unrelated' }))).map(item => item.invocationId)).toEqual([unrelatedOperation])
expect(readRecovery(databasePath).decisions).toEqual(resolved.decisions)
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
await testInfo.attach('recovery-audit', { body: JSON.stringify(resolved, null, 2), contentType: 'application/json' })
})
1 change: 1 addition & 0 deletions apps/buddy/electron/main/app/DesktopRuntimeHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export class DesktopRuntimeHost {
? join(app.isPackaged ? process.resourcesPath : app.getAppPath(), app.isPackaged ? shellSandbox.resource.to : `${shellSandbox.resource.from}/${currentTarget.id}`)
: undefined,
windowsSandbox: this.#sandboxExecutable(),
receiptDirectory: join(this.#environment.paths.buddyHome, 'process-receipts'),
windowsShell: this.#windowsPowerShell,
processControl: resolveBuddyProcessControl({ appPath: app.getAppPath(), isPackaged: app.isPackaged, resourcesPath: process.resourcesPath }),
}
Expand Down
5 changes: 4 additions & 1 deletion apps/buddy/electron/main/local-chat/activity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { LocalChatIpcContext } from './registrar'
import { artifactsRequestSchemas, artifactsRpc } from '../../../shared/artifacts/artifactApi'
import { changeOverviewRequestSchema, changesRequestSchemas, changesRpc } from '../../../shared/changes/changeApi'
import { changeOverviewRequestSchema, changesRecoveryRpc, changesRequestSchemas, changesRpc } from '../../../shared/changes/changeApi'
import { notificationsRequestSchemas, notificationsRpc } from '../../../shared/notifications/notificationApi'
import { approvalsRequestSchemas, approvalsRpc } from '../../../shared/permissions/approvalApi'
import { conversationStatusRequestSchema, runsStatusRpc } from '../../../shared/runs/conversationStatusApi'
Expand All @@ -12,6 +12,9 @@ import { LOCAL_CHAT_IPC_CHANNELS } from '../../shared/localChatApi'

export function registerActivityIpc(context: LocalChatIpcContext): void {
const { handle, request } = context
handle(LOCAL_CHAT_IPC_CHANNELS.changesRecoveryList, (_event, input) => request(changesRecoveryRpc.list, changesRecoveryRpc.list.input.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.changesRecoveryRecheck, (_event, input) => request(changesRecoveryRpc.recheck, changesRecoveryRpc.recheck.input.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.changesRecoveryResolve, (_event, input) => request(changesRecoveryRpc.resolve, changesRecoveryRpc.resolve.input.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.changesOverview, (_event, input) => request(changesRpc.overview, changeOverviewRequestSchema.parse(input)))

handle(LOCAL_CHAT_IPC_CHANNELS.artifactsReadText, (_event, input) => request(artifactsRpc.readText, artifactsRequestSchemas.artifactText.parse(input)))
Expand Down
19 changes: 17 additions & 2 deletions apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ import { resolveSensitivePathRoots } from '../../../service/src/permissions/sens
import sandboxProcessPath from '../../../service/src/sandbox/sandboxProcess?modulePath'
import { Emitter } from '../../../shared/events/Emitter'
import { sandboxLifecycleNotificationSchema } from '../../../shared/permissions/sandboxLifecycle'
import { SANDBOX_RPC_TIMEOUT_MS, sandboxCancelSchema, sandboxCommandSchema, sandboxNetworkRequestSchema, sandboxOutputSchema, sandboxPreparationSchema } from '../../../shared/permissions/shellSandbox'
import { SANDBOX_RPC_TIMEOUT_MS, sandboxCancelSchema, sandboxCommandSchema, sandboxNetworkRequestSchema, sandboxOutputSchema, sandboxPreparationSchema, sandboxSettlementAckSchema, sandboxSettlementSchema } from '../../../shared/permissions/shellSandbox'
import { isLinux, isWindows, OPERATING_SYSTEM, SHELL_SANDBOX_BACKEND } from '../../../shared/platform/identifiers'
import { BuddyServicePeer } from '../runtime/BuddyServicePeer'
import { WindowsSandboxPreparation } from './WindowsSandboxPreparation'

export interface SandboxHostOptions {
buddyHome: string
receiptDirectory?: string
protectedRoots?: string[]
protectionScope?: string
proxyUrl?: string
Expand All @@ -34,6 +35,7 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa
const publish = (event: Parameters<typeof changes.fire>[0]) => changes.fire(Object.freeze(event))
const preparation = isWindows(process.platform) ? new WindowsSandboxPreparation({ ...options, protectedRoots: options.protectedRoots ?? [dirname(options.buddyHome)] }) : undefined
const active = new Map<string, AbortController>()
const accepted = new Set<string>()
let disposed = false
const disposers = [
peer.onRequest('host.sandbox.prepare', async (params) => {
Expand All @@ -54,10 +56,11 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa
const input = sandboxCommandSchema.parse(params)
if (!Object.values<string>(OPERATING_SYSTEM).includes(process.platform))
return { ok: false, code: 'SANDBOX_UNAVAILABLE', notStarted: true }
if (active.has(input.requestId))
if (accepted.has(input.requestId))
return { ok: false, code: 'SANDBOX_BUSY' }
if (disposed || active.size >= 8)
return { ok: false, code: 'SANDBOX_BUSY', notStarted: true }
accepted.add(input.requestId)
preparation?.start()
const controller = new AbortController()
active.set(input.requestId, controller)
Expand Down Expand Up @@ -143,6 +146,17 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa
if (output.requestId === input.requestId)
peer.notify('host.sandbox.output', output)
})
processPeer.onRequest('sandbox.settlement', async (params) => {
const record = sandboxSettlementSchema.parse(params)
if (disposed || record.requestId !== input.requestId || record.executionId !== input.executionId || record.dispatchId !== input.dispatchId
|| (record.launchState === 'authorized' && controller.signal.aborted)) {
throw new Error('SANDBOX_CANCELLED')
}
const result = sandboxSettlementAckSchema.parse(await peer.request('sandbox.settlement', record, 60_000))
if (record.launchState === 'authorized' && (disposed || controller.signal.aborted))
throw new Error('SANDBOX_CANCELLED')
return result
})
processPeer.onRequest('sandbox.network', async (params) => {
const target = sandboxNetworkRequestSchema.parse(params)
if (disposed || controller.signal.aborted || target.requestId !== input.requestId)
Expand All @@ -156,6 +170,7 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa
sensitiveRoots: resolveSensitivePathRoots(),
path: process.env.PATH ?? '',
privateRoot: directory,
receiptDirectory: options.receiptDirectory,
protectedRoots: options.protectedRoots ?? [dirname(options.buddyHome)],
searchDirectory: options.searchDirectory,
backend: windowsSandbox
Expand Down
12 changes: 12 additions & 0 deletions apps/buddy/electron/preload/local-chat/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ export function createActivityApi(): Pick<LocalChatApi, 'artifacts' | 'notificat
),
}),
changes: Object.freeze({
recovery: Object.freeze<LocalChatApi['changes']['recovery']>({
list: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.changesRecoveryList, { conversationId: input.conversationId }),
recheck: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.changesRecoveryRecheck, {
conversationId: input.conversationId,
invocationIds: [...input.invocationIds],
}),
resolve: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.changesRecoveryResolve, {
conversationId: input.conversationId,
items: input.items.map(item => ({ invocationId: item.invocationId, revision: item.revision })),
decision: input.decision,
}),
}),
onChanged: listener => subscribe<ChangeSetChangeNotice>(LOCAL_CHAT_IPC_CHANNELS.changesChanged, listener),
overview: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.changesOverview, { ...input }),
get: changeSetId => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.changesGet, { changeSetId }),
Expand Down
10 changes: 9 additions & 1 deletion apps/buddy/electron/shared/localChatApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { LocalConversationStatus } from '@buddy-shared/runs/conversationSta
import type { ArtifactChangeNotice, LocalArtifactText } from '../../shared/artifacts/artifactApi'
import type { LocalAutomation, LocalAutomationCreateRequest, LocalAutomationListRequest, LocalAutomationMutationRequest, LocalAutomationOccurrenceListRequest, LocalAutomationOccurrencePage, LocalAutomationPage, LocalAutomationPreviewRequest, LocalAutomationPreviewResult, LocalAutomationRunNowResult, LocalAutomationUpdateRequest } from '../../shared/automation/automationApi'
import type { BuddyCatalog, BuddyCreate, BuddyDefaultUpdate, BuddyMutation, BuddyToolOption, BuddyUpdate, LocalBuddy } from '../../shared/buddies/buddyApi'
import type { ChangeOverviewRequest, ChangeSetChangeNotice, LocalChangeOverview, LocalChangeSetDetail } from '../../shared/changes/changeApi'
import type { ChangeOverviewRequest, ChangeRecoveryListRequest, ChangeRecoveryRecheckRequest, ChangeRecoveryResolveRequest, ChangeSetChangeNotice, LocalChangeOverview, LocalChangeRecoveryItem, LocalChangeSetDetail } from '../../shared/changes/changeApi'
import type { ConnectorChangeNotice, LocalConnector, LocalConnectorConfig, LocalConnectorCredential, LocalConnectorCredentialMutation } from '../../shared/connectors/connectorApi'
import type { ConnectorRuntimeState, ConnectorToolSummary } from '../../shared/connectors/connectorState'
import type { LocalChatCommandRequest, LocalStartTurnRequest, LocalTurnStart } from '../../shared/conversation/chatApi'
Expand Down Expand Up @@ -75,6 +75,9 @@ export const LOCAL_CHAT_IPC_CHANNELS = {
taskMarksSetRead: 'lexora:buddy:task-marks:set-read',
taskMarksClear: 'lexora:buddy:task-marks:clear',
changesOverview: 'lexora:buddy:changes:overview',
changesRecoveryList: 'lexora:buddy:changes:recovery:list',
changesRecoveryRecheck: 'lexora:buddy:changes:recovery:recheck',
changesRecoveryResolve: 'lexora:buddy:changes:recovery:resolve',
spaceFilesMutate: 'lexora:buddy:space-files:mutate',
spaceFilesHistory: 'lexora:buddy:space-files:history',
spaceFilesQuery: 'lexora:buddy:space-files:query',
Expand Down Expand Up @@ -493,6 +496,11 @@ export interface LocalChatApi {
}) => Promise<LocalConversationTimelinePage>
}
changes: {
recovery: {
list: (input: ChangeRecoveryListRequest) => Promise<readonly LocalChangeRecoveryItem[]>
recheck: (input: ChangeRecoveryRecheckRequest) => Promise<readonly LocalChangeRecoveryItem[]>
resolve: (input: ChangeRecoveryResolveRequest) => Promise<readonly LocalChangeRecoveryItem[]>
}
onChanged: (listener: (event: ChangeSetChangeNotice) => void) => () => void
overview: (input: ChangeOverviewRequest) => Promise<LocalChangeOverview>
get: (changeSetId: string) => Promise<LocalChangeSetDetail>
Expand Down
6 changes: 6 additions & 0 deletions apps/buddy/native/host/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ path = "__tests__/process_control_linux.rs"
name = "supervised-shell-linux"
path = "__tests__/supervised_shell_linux.rs"

[[test]]
name = "supervised-receipts-linux"
path = "__tests__/supervised_receipts_linux.rs"

[[test]]
name = "sandbox-ipc-linux"
path = "__tests__/sandbox_ipc_linux.rs"
Expand Down Expand Up @@ -157,6 +161,8 @@ windows-sys = { workspace = true, features = [
"Win32_System_Rpc",
"Win32_UI_Shell",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Diagnostics_Debug",
"Win32_System_SystemInformation",
"Win32_System_Threading",
"Win32_System_Time",
"Win32_UI_Input_KeyboardAndMouse",
Expand Down
Loading
Loading