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
158 changes: 158 additions & 0 deletions .playwright/scripts/__tests__/network-permissions.e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { Buffer } from 'node:buffer'
import { once } from 'node:events'
import { createServer } from 'node:http'
import path from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { expect, test, useSyntheticCredentialStorage } from '../fixtures/electron.mjs'

test('real network requests enforce saved access, file download, redirects and manual takeover', async ({ buddy }, testInfo) => {
const requests = []
const hits = []
const server = createServer(async (request, response) => {
if (request.method === 'GET') {
const pathname = new URL(request.url, 'http://permission.example.test').pathname
hits.push(pathname)
if (pathname === '/redirect') {
response.writeHead(302, { location: 'http://redirect.example.test/landing' }).end()
return
}
if (pathname === '/landing') {
response.writeHead(200, { 'content-type': 'text/html' }).end('<html><head><title>Redirect fixture</title><link rel="icon" href="data:,"></head><body>Redirected page<img src="http://blocked.example.test/pixel"></body></html>')
return
}
const file = pathname === '/file'
response.writeHead(200, file
? { 'content-type': 'application/octet-stream', 'content-disposition': 'attachment; filename="fixture.txt"' }
: { 'content-type': 'text/html' })
response.end(file ? 'File permission fixture.' : '<html><head><title>Permission fixture</title></head><body><article>A local page used to verify domain access permissions.</article></body></html>')
return
}
const chunks = []
for await (const chunk of request)
chunks.push(chunk)
const payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
requests.push(payload)
const userIndex = payload.messages.findLastIndex(message => message.role === 'user')
const input = JSON.stringify(payload.messages[userIndex]?.content)
const targetTool = input.includes('browser') ? 'lexora_browser_open' : 'lexora_web_fetch'
const fetched = payload.messages.slice(userIndex + 1).some(message => message.tool_calls?.some(tool => tool.function?.name === targetTool))
const available = payload.tools.some(tool => tool.function?.name === targetTool)
const tool = fetched ? null : available ? targetTool : 'lexora_tool_search'
const args = tool === 'lexora_tool_search'
? { query: targetTool }
: { ...(targetTool === 'lexora_browser_open' ? { kind: 'url' } : {}), url: `http://permission.example.test/${input.includes('browser') ? 'redirect' : input.includes('file') ? 'file' : 'page'}?request=${requests.length}` }
const common = { id: `permission-${requests.length}`, model: 'permission-fixture', object: 'chat.completion.chunk', created: 1 }
const delta = tool
? { role: 'assistant', tool_calls: [{ index: 0, id: `permission-call-${requests.length}`, type: 'function', function: { name: tool, arguments: JSON.stringify(args) } }] }
: { role: 'assistant', content: 'Permission fixture completed.' }
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`)
response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: {}, finish_reason: tool ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 32, completion_tokens: 8, total_tokens: 40 } })}\n\n`)
response.end('data: [DONE]\n\n')
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const instance = await buddy.createInstance('network-approval')
try {
const desktop = await instance.launch()
await useSyntheticCredentialStorage(desktop)
await desktop.page.evaluate(async (baseUrl) => {
const providers = window.lexoraDesktop.localChat.providers
await providers.upsertCustom({ id: 'permission-fixture', displayName: 'Permission fixture', api: 'openai-completions', baseUrl, enabled: true, models: [{ id: 'permission-fixture', name: 'Permission fixture', input: ['text'], reasoning: false, contextWindow: 128000, maxTokens: 1024 }] })
const stop = providers.onAuthChallenge((challenge) => {
if (challenge.providerId === 'permission-fixture' && challenge.type === 'secret')
void providers.respondToAuth(challenge.challengeId, 'offline-fixture-key')
})
try {
await providers.login('permission-fixture', 'api_key')
}
finally {
stop()
}
await providers.setDefaultModel({ providerId: 'permission-fixture', modelId: 'permission-fixture', reasoning: null })
}, `http://127.0.0.1:${server.address().port}/v1`)
await desktop.page.evaluate(server => window.lexoraDesktop.settings.update({ proxy: { mode: 'custom', server } }), `http://127.0.0.1:${server.address().port}`)
await desktop.page.reload()
const send = async (text) => {
await desktop.page.locator('.desktop-chat-composer__prosemirror:visible').fill(text)
await desktop.page.getByRole('button', { name: '发送消息', exact: true }).click()
}
const completed = () => {
const db = new DatabaseSync(path.join(instance.home, 'buddy/buddy.sqlite3'), { readOnly: true })
try {
return db.prepare('SELECT COUNT(*) AS count FROM runs WHERE status=\'completed\'').get().count
}
finally {
db.close()
}
}
const card = desktop.page.locator('.desktop-approval-card')
const settings = () => desktop.page.evaluate(async () => (await window.lexoraDesktop.settings.get()).browser.networkPermissions)

await send('Fetch the first page.')
await expect(card).toContainText('permission.example.test')
await expect(card).toContainText('所有空间和任务')
expect(hits).toEqual([])
await card.getByRole('button', { name: '允许一次', exact: true }).click()
await expect.poll(completed).toBe(1)
expect(hits).toEqual(['/page'])
expect((await settings()).rules).toEqual([])

await send('Fetch another page and save access.')
await expect(card).toBeVisible()
await desktop.page.screenshot({ path: testInfo.outputPath('network-approval.png'), animations: 'disabled' })
await card.locator('.desktop-approval-card__scope-button').click()
await desktop.page.locator('.n-dropdown-menu:visible').getByText('允许并保存网站权限', { exact: true }).click()
await expect.poll(completed).toBe(2)
expect((await settings()).rules[0]).toMatchObject({ pattern: 'permission.example.test', access: 'allow', download: 'ask', upload: 'ask' })

await send('Fetch a file.')
await expect(card).toContainText('下载文件')
await card.getByRole('button', { name: '拒绝', exact: true }).click()
await expect.poll(completed).toBe(3)
expect((await settings()).rules[0].download).toBe('ask')
await desktop.page.evaluate(async () => {
const config = await window.lexoraDesktop.settings.get()
const networkPermissions = config.browser.networkPermissions
networkPermissions.rules[0].access = 'deny'
await window.lexoraDesktop.settings.update({ browser: { networkPermissions } })
})
const prior = hits.length
await send('Fetch a denied page.')
await expect.poll(completed).toBe(4)
expect(hits).toHaveLength(prior)
await expect(card).toBeHidden()
expect(JSON.stringify(requests.at(-1).messages)).toContain('NETWORK_PERMISSION_DENIED')

await desktop.page.evaluate(async () => {
const { browser } = await window.lexoraDesktop.settings.get()
browser.networkPermissions.rules[0].access = 'allow'
browser.networkPermissions.rules.push({ id: crypto.randomUUID(), match: 'exact', pattern: 'blocked.example.test', access: 'deny', download: 'ask', upload: 'ask' })
await window.lexoraDesktop.settings.update({ browser: { networkPermissions: browser.networkPermissions } })
})
await send('Open the browser to a redirect.')
await expect(card).toContainText('redirect.example.test')
expect(hits).toContain('/redirect')
expect(hits).not.toContain('/landing')
await card.getByRole('button', { name: '允许一次', exact: true }).click()
await expect.poll(completed).toBe(5)
expect(hits).toContain('/landing')
expect(hits).not.toContain('/pixel')

await desktop.page.evaluate(async () => {
const { browser } = await window.lexoraDesktop.settings.get()
browser.networkPermissions.rules[0].access = 'deny'
await window.lexoraDesktop.settings.update({ browser: { networkPermissions: browser.networkPermissions } })
})
const address = desktop.page.getByTestId('browser-address')
await address.fill('http://permission.example.test/manual')
await address.press('Enter')
await expect.poll(() => hits.includes('/manual')).toBe(true)
await expect(card).toBeHidden()
expect(desktop.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
}
finally {
server.closeAllConnections()
await new Promise(resolve => server.close(resolve))
}
})
28 changes: 26 additions & 2 deletions apps/buddy/electron/main/__tests__/localChatIpcLifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,32 @@ describe('local chat IPC lifecycle and host authorization', () => {
await expect(create(event, input)).rejects.toThrow('LOCAL_CHAT_OPERATION_FAILED')
await expect(create(event, input)).rejects.toThrow('LOCAL_CHAT_OPERATION_FAILED')
expect(requests).toEqual([
{ ...input, primaryDirectorySelectionVerified: true },
{ ...input, primaryDirectorySelectionVerified: false },
{ ...input, primaryDirectorySelectionVerified: true, additionalDirectorySelectionsVerified: true },
{ ...input, primaryDirectorySelectionVerified: false, additionalDirectorySelectionsVerified: true },
])
})

it('requires a native selection for every additional root and consumes a batch only when all are selected', async () => {
const requests: unknown[] = []
const { event } = setup(async (_method, input) => {
requests.push(input)
throw new Error('fixture storage unavailable')
})
const update = electron.handlers.get(LOCAL_CHAT_IPC_CHANNELS.spacesUpdate)!
const input = { spaceId: 'space', memoryScope: 'space_only', name: 'Fixture', primaryDirectory: null, additionalDirectoryChanges: { add: ['/tmp/one', '/tmp/two'], remove: [] } }
await expect(update(event, { ...input, additionalDirectorySelectionsVerified: true })).rejects.toThrow('VALIDATION_FAILED')
expect(requests).toEqual([])
electron.select.mockResolvedValueOnce({ canceled: false, filePaths: ['/tmp/one'] })
await electron.handlers.get(LOCAL_CHAT_IPC_CHANNELS.spacesSelectDirectory)!(event)
await expect(update(event, input)).rejects.toThrow('LOCAL_CHAT_OPERATION_FAILED')
electron.select.mockResolvedValueOnce({ canceled: false, filePaths: ['/tmp/two'] })
await electron.handlers.get(LOCAL_CHAT_IPC_CHANNELS.spacesSelectDirectory)!(event)
await expect(update(event, input)).rejects.toThrow('LOCAL_CHAT_OPERATION_FAILED')
await expect(update(event, input)).rejects.toThrow('LOCAL_CHAT_OPERATION_FAILED')
expect(requests).toEqual([
{ ...input, primaryDirectorySelectionVerified: false, additionalDirectorySelectionsVerified: false },
{ ...input, primaryDirectorySelectionVerified: false, additionalDirectorySelectionsVerified: true },
{ ...input, primaryDirectorySelectionVerified: false, additionalDirectorySelectionsVerified: false },
])
})

Expand Down
7 changes: 7 additions & 0 deletions apps/buddy/electron/main/app/DesktopRuntimeHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { resolveWindowsPowerShell } from '../../../platform/windows/powerShell'
import { automationNotifications } from '../../../shared/automation/automationApi'
import { contextPanelRpc, contextPanelSourceSchema } from '../../../shared/context-panel/contextPanel'
import { extensionAgentInvocationSchema, extensionAgentRpc } from '../../../shared/extensions/extensionAgent'
import { networkPermissionGrantSchema, networkPermissionsRpc } from '../../../shared/permissions/networkPermissionsRpc'
import { isLinux } from '../../../shared/platform/identifiers'
import { runtimePreferencesRpc } from '../../../shared/runtime/runtimePreferences'
import { installAttachmentProtocol } from '../attachmentProtocol'
Expand Down Expand Up @@ -203,6 +204,12 @@ export class DesktopRuntimeHost {
return this.extensionAgent.invoke(extensionAgentInvocationSchema.parse(input), signal ?? new AbortController().signal)
}),
peer.onRequest(runtimePreferencesRpc.get, () => this.#config!.runtime),
peer.onRequest(networkPermissionsRpc.get, async () => (await this.configStore.read()).browser.networkPermissions),
peer.onRequest(networkPermissionsRpc.allow, async (params, signal) => {
const input = networkPermissionGrantSchema.parse(params)
await this.configStore.allowNetworkPermission(input.host, input.operation, () => signal?.throwIfAborted())
return {}
}),
registerExtensionAuthoringRpc(peer, (id) => {
if (!this.inspectExtension)
throw new Error('EXTENSION_SERVICE_UNAVAILABLE')
Expand Down
21 changes: 20 additions & 1 deletion apps/buddy/electron/main/browser/BrowserHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
DesktopBrowserState,
} from '../../../shared/browser/browserDesktopApi'
import type { BrowserLocateElementInput, BrowserPickResult } from '../../../shared/browser/browserSelection'
import type { NetworkAuthorizer } from '../../../shared/network/networkAuthorization'
import type { BrowserHostChange, BrowserHostFact } from './BrowserHostEvents'
import type { BrowserPage } from './BrowserPageSession'
import type { BrowserSessionTeardownReason } from './BrowserSessionRegistry'
Expand Down Expand Up @@ -275,13 +276,28 @@ export class BrowserHost {
return { ...this.#requireSession(sessionId).descriptor }
}

async withNetworkAuthorization<T>(sessionId: string, authorize: NetworkAuthorizer, signal: AbortSignal | undefined, operation: () => Promise<T>): Promise<T> {
const session = this.#requireSession(sessionId)
return session.networkAuthorization.run(authorize, signal, async () => {
const result = await operation()
const url = session.page?.getURL()
if (url && /^(?:https?|wss?):/.test(url))
await session.networkAuthorization.authorize(url)
return result
}, () => {
if (session.page && !session.page.isDestroyed())
session.page.stop()
})
}

acquireControl(input: BrowserAcquireControlParams): BrowserControlLease {
this.#operations.assertCanMutate()
const session = this.#requireSession(input.sessionId)
if (!session.state.conversationId)
throw new BrowserHostError('BROWSER_CONTROL_REQUIRED', 'Standalone browser sessions remain under human control')
this.#assertCurrentPage(session, input.pageId, 'before acquiring control')
session.cancelElementPick()
session.networkAuthorization.requireAgentAuthorization()
this.#advanceControlEpoch(session)
session.state.controller = 'agent'
this.#sessions.setProtected(input.sessionId, 'runtime', true)
Expand All @@ -303,7 +319,9 @@ export class BrowserHost {
}

takeControl(sessionId: string): DesktopBrowserState {
return this.#returnHumanControl(this.#requireSession(sessionId), true)
const session = this.#requireSession(sessionId)
session.networkAuthorization.useHumanControl()
return this.#returnHumanControl(session, true)
}

async observe(input: BrowserObserveParams): Promise<BrowserObservation> {
Expand Down Expand Up @@ -1208,6 +1226,7 @@ export class BrowserHost {
#acceptHumanPageInput(session: BrowserPageSession): void {
if (!session.state.visible)
return
session.networkAuthorization.useHumanControl()
this.#sessions.touch(session.state.sessionId)
if (session.state.controller === 'agent') {
this.#returnHumanControl(session, true)
Expand Down
74 changes: 74 additions & 0 deletions apps/buddy/electron/main/browser/BrowserNetworkAuthorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { NetworkAuthorizer } from '../../../shared/network/networkAuthorization'

export class BrowserNetworkAuthorization {
#current: { authorize: NetworkAuthorizer, controller: AbortController, signal: AbortSignal } | null = null

readonly authorize: NetworkAuthorizer = async (url, operation = 'access', signal) => {
const current = this.#current
if (!current)
return
current.signal.throwIfAborted()
await current.authorize(url, operation, AbortSignal.any([current.signal, ...(signal ? [signal] : [])]))
current.signal.throwIfAborted()
if (this.#current !== current)
throw new Error('BROWSER_NETWORK_CONTEXT_CHANGED')
}

async run<T>(authorize: NetworkAuthorizer, signal: AbortSignal | undefined, operation: () => Promise<T>, stop: () => void): Promise<T> {
if (this.#current && !this.#current.signal.aborted)
throw new Error('BROWSER_NETWORK_OPERATION_BUSY')
const controller = new AbortController()
const combined = AbortSignal.any([controller.signal, ...(signal ? [signal] : [])])
let failure: unknown
const current = {
controller,
signal: combined,
authorize: async (...args: Parameters<NetworkAuthorizer>) => {
try {
await authorize(...args)
}
catch (error) {
failure ??= error
throw error
}
},
}
this.#current = current
combined.addEventListener('abort', stop, { once: true })
try {
combined.throwIfAborted()
const result = await operation()
if (failure)
throw failure
combined.throwIfAborted()
return result
}
finally {
combined.removeEventListener('abort', stop)
controller.abort()
}
}

requireAgentAuthorization(): void {
if (this.#current)
return
const controller = new AbortController()
controller.abort()
this.#current = {
controller,
signal: controller.signal,
authorize: async () => {
throw new Error('NETWORK_AUTHORIZATION_UNAVAILABLE')
},
}
}

useHumanControl(): void {
this.#current?.controller.abort()
this.#current = null
}

dispose(): void {
this.#current?.controller.abort()
}
}
4 changes: 4 additions & 0 deletions apps/buddy/electron/main/browser/BrowserPageSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { browserZoomFactorSchema, stepBrowserZoom } from '../../../shared/browse
import { BrowserDebugger } from './BrowserDebugger'
import { BrowserElementPicker } from './BrowserElementPicker'
import { BrowserHostError } from './BrowserHostError'
import { BrowserNetworkAuthorization } from './BrowserNetworkAuthorization'
import { BrowserPageActivity } from './BrowserPageActivity'
import { BrowserSecurityPolicy, isLoopbackBrowserUrl } from './BrowserSecurityPolicy'
import { SemanticBrowserDriver } from './SemanticBrowserDriver'
Expand Down Expand Up @@ -88,6 +89,7 @@ interface BrowserPageSessionOptions {
}

export class BrowserPageSession {
readonly networkAuthorization = new BrowserNetworkAuthorization()
actionTail = Promise.resolve()
activeNavigationSequence: number | null = null
agentActionDepth = 0
Expand Down Expand Up @@ -234,6 +236,7 @@ export class BrowserPageSession {
page,
})
this.securityPolicy = new BrowserSecurityPolicy({
authorizeNetwork: this.networkAuthorization.authorize,
connection: this.#connection,
onCertificateError: ({ error, url }) => {
this.state.error = {
Expand Down Expand Up @@ -544,6 +547,7 @@ export class BrowserPageSession {
}

releasePage(): void {
this.networkAuthorization.dispose()
this.invalidateElementReferences()
this.elementPicker?.dispose()
this.elementPicker = null
Expand Down
Loading