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
44 changes: 44 additions & 0 deletions apps/desktop/src/main/app-shell-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
import type { WebContents } from 'electron'

const { openExternal } = vi.hoisted(() => ({ openExternal: vi.fn() }))

vi.mock('electron', () => ({
shell: { openExternal },
}))

import { attachAppShellNavigationGuard } from './app-shell-guard'

function fakeContents(): {
contents: WebContents
fire: (event: string, url: string) => boolean
} {
const listeners = new Map<string, Array<(event: { preventDefault(): void }, url: string) => void>>()
const contents = {
setWindowOpenHandler: vi.fn(),
on: (event: string, listener: (event: { preventDefault(): void }, url: string) => void) => {
const list = listeners.get(event) ?? []
list.push(listener)
listeners.set(event, list)
return contents
},
} as unknown as WebContents
return {
contents,
fire: (event, url) => {
let prevented = false
const navEvent = { preventDefault: () => { prevented = true } }
for (const listener of listeners.get(event) ?? []) listener(navEvent, url)
return prevented
},
}
}

describe('attachAppShellNavigationGuard', () => {
it('sends YouTube to the OS browser instead of replacing the ADE', () => {
const { contents, fire } = fakeContents()
attachAppShellNavigationGuard(contents)
expect(fire('will-navigate', 'https://youtube.com/')).toBe(true)
expect(openExternal).toHaveBeenCalledWith('https://youtube.com/')
})
})
27 changes: 27 additions & 0 deletions apps/desktop/src/main/app-shell-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { shell, type WebContents } from 'electron'
import { isAppUrl, isExternalOpenable } from './external-links'

/**
* Pins the ADE renderer so transcript links cannot replace the app. Must be
* attached only to a BrowserWindow's own webContents — a WebContentsView
* guest (the in-app browser) has to navigate http(s) itself.
*/
export function attachAppShellNavigationGuard(contents: WebContents): void {
contents.setWindowOpenHandler(({ url }) => {
if (isExternalOpenable(url)) void shell.openExternal(url)
return { action: 'deny' }
})
const devServerUrl = process.env['ELECTRON_RENDERER_URL']
const appFileUrl = devServerUrl
? undefined
: pathToFileURL(join(import.meta.dirname, '../renderer/index.html')).href
const guardNavigation = (event: { preventDefault(): void }, url: string): void => {
if (isAppUrl(url, devServerUrl, appFileUrl)) return
event.preventDefault()
if (isExternalOpenable(url)) void shell.openExternal(url)
}
contents.on('will-navigate', guardNavigation)
contents.on('will-redirect', guardNavigation)
}
88 changes: 88 additions & 0 deletions apps/desktop/src/main/browser-electron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { WebContentsView, type BrowserWindow, type WebContents } from 'electron'
import type { BrowserGuest } from './browser-service'
import { isBrowserNavigable } from './browser-url'

export const BROWSER_PARTITION = 'persist:ari-browser'

/** True when this webContents is the in-app browser, not the ADE shell. */
export function isBrowserGuest(contents: WebContents): boolean {
const session = contents.session as unknown as { partition?: string }
return session.partition === BROWSER_PARTITION
}

/**
* Chromium guest for one in-app tab. Isolated partition, sandboxed, no Node.
* Popups navigate the same view instead of spawning windows.
*/
export function createElectronBrowserGuest(
win: BrowserWindow,
onUpdated: () => void,
): BrowserGuest {
const view = new WebContentsView({
webPreferences: {
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
partition: BROWSER_PARTITION,
},
Comment on lines +21 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Browser credentials persist across sessions

All guests share one persistent partition, so cookies survive restarts and cross projects and providers. Later agents can inherit earlier authenticated sessions.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

})
win.contentView.addChildView(view)
view.setBounds({ x: 0, y: 0, width: 800, height: 600 })
view.setVisible(false)
const wc = view.webContents
wc.setUserAgent(wc.getUserAgent().replace(/\sElectron\/\S+/g, ''))

const emit = (): void => onUpdated()
wc.on('did-navigate', emit)
wc.on('did-navigate-in-page', emit)
wc.on('page-title-updated', emit)
wc.on('did-start-loading', emit)
wc.on('did-stop-loading', emit)
wc.on('did-fail-load', (_event, _code, _desc, _url, isMainFrame) => {
if (isMainFrame) emit()
})

const blockDisallowed = (event: { preventDefault(): void }, url: string): void => {
if (!isBrowserNavigable(url)) event.preventDefault()
}
// will-navigate misses 3xx. A guest redirect to file: or an OS protocol
// has to be cancelled on will-redirect, same as the shell guard.
wc.on('will-navigate', blockDisallowed)
wc.on('will-redirect', blockDisallowed)
wc.setWindowOpenHandler(({ url }) => {
if (isBrowserNavigable(url)) void wc.loadURL(url)
return { action: 'deny' }
})

return {
loadURL: (url) => wc.loadURL(url),
goBack: () => {
if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack()
},
goForward: () => {
if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward()
},
reload: () => wc.reload(),
canGoBack: () => wc.navigationHistory.canGoBack(),
canGoForward: () => wc.navigationHistory.canGoForward(),
getURL: () => wc.getURL(),
getTitle: () => wc.getTitle(),
isLoading: () => wc.isLoading(),
executeJavaScript: (code) => wc.executeJavaScript(code, true),
capturePage: async (rect) => {
try {
const image = await wc.capturePage(rect)
const png = image.toPNG()
return png.length > 0 ? png : null
} catch {
return null
}
},
setBounds: (bounds) => view.setBounds(bounds),
setVisible: (visible) => view.setVisible(visible),
destroy: () => {
if (!win.isDestroyed()) win.contentView.removeChildView(view)
if (!wc.isDestroyed()) wc.close()
},
}
}
38 changes: 38 additions & 0 deletions apps/desktop/src/main/browser-element.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
elementChipLabel,
formatElementContext,
formatElementContexts,
isPickedElement,
type PickedElement,
} from './browser-element'

const button: PickedElement = {
url: 'http://localhost:5173/',
selector: 'button.primary',
tag: 'button',
text: 'Save changes',
role: 'button',
ariaLabel: null,
html: '<button class="primary">Save changes</button>',
x: 10,
y: 20,
width: 80,
height: 32,
}

describe('formatElementContext', () => {
it('names the page, selector, and text so the agent can find the node', () => {
const block = formatElementContext(button)
expect(block).toContain('http://localhost:5173/')
expect(block).toContain('button.primary')
expect(block).toContain('Save changes')
expect(formatElementContexts([button])).toContain('<element_context>')
})

it('labels chips from visible text', () => {
expect(elementChipLabel(button)).toBe('Save changes')
expect(isPickedElement(button)).toBe(true)
expect(isPickedElement({ tag: 'div' })).toBe(false)
})
})
60 changes: 60 additions & 0 deletions apps/desktop/src/main/browser-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/** One DOM node the user (or agent snapshot) named on the in-app page. */
export interface PickedElement {
url: string
selector: string
tag: string
text: string
role: string | null
ariaLabel: string | null
html: string
x: number
y: number
width: number
height: number
}

const TEXT_CAP = 400
const HTML_CAP = 1_500

export function isPickedElement(value: unknown): value is PickedElement {
if (typeof value !== 'object' || value === null) return false
const row = value as Record<string, unknown>
return (
typeof row['url'] === 'string' &&
typeof row['selector'] === 'string' &&
typeof row['tag'] === 'string' &&
typeof row['text'] === 'string' &&
typeof row['html'] === 'string' &&
typeof row['x'] === 'number' &&
typeof row['y'] === 'number' &&
typeof row['width'] === 'number' &&
typeof row['height'] === 'number'
)
}

/** Prompt block the agent reads when the user mentions a picked element. */
export function formatElementContext(element: PickedElement): string {
const lines = [
`Selected element on ${element.url}`,
`- tag: ${element.tag}`,
`- selector: ${element.selector}`,
]
if (element.role) lines.push(`- role: ${element.role}`)
if (element.ariaLabel) lines.push(`- aria-label: ${element.ariaLabel}`)
if (element.text) lines.push(`- text: ${element.text.slice(0, TEXT_CAP)}`)
if (element.html) lines.push(`- html: ${element.html.slice(0, HTML_CAP)}`)
return lines.join('\n')
}

export function formatElementContexts(elements: readonly PickedElement[]): string {
if (elements.length === 0) return ''
const body = elements.map((el, i) => `Element ${String(i + 1)}\n${formatElementContext(el)}`).join('\n\n')
return `<element_context>\n${body}\n</element_context>`
}

export function elementChipLabel(element: PickedElement): string {
const text = element.text.trim().replace(/\s+/g, ' ')
if (text.length > 0) return text.length > 32 ? `${text.slice(0, 31)}…` : text
if (element.ariaLabel) return element.ariaLabel
return element.selector
}
79 changes: 79 additions & 0 deletions apps/desktop/src/main/browser-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { BrowserService, type BrowserGuest } from './browser-service'
import { dispatchBrowserMcpTool, startBrowserMcpServer, writeBrowserMcpProxy } from './browser-mcp'

function guest(): BrowserGuest {
let url = 'about:blank'
return {
loadURL: vi.fn(async (next: string) => {
url = next
}),
goBack: vi.fn(),
goForward: vi.fn(),
reload: vi.fn(),
canGoBack: () => false,
canGoForward: () => false,
getURL: () => url,
getTitle: () => 'Example',
isLoading: () => false,
executeJavaScript: vi.fn(async (code: string) => {
if (code.includes('el.click()') || code.includes('el.focus()')) return true
return { url, title: 'Example', text: 'hi', elements: [] }
}),
capturePage: vi.fn(async () => null),
setBounds: vi.fn(),
setVisible: vi.fn(),
destroy: vi.fn(),
}
}

describe('dispatchBrowserMcpTool', () => {
it('navigates and snapshots the shared guest', async () => {
const service = new BrowserService(() => guest(), vi.fn())
const nav = await dispatchBrowserMcpTool(service, 'browser_navigate', { url: 'example.com' })
expect(nav.isError).toBeUndefined()
expect(nav.content[0]?.text).toContain('https://example.com')
const snap = await dispatchBrowserMcpTool(service, 'browser_snapshot', {})
expect(snap.content[0]?.text).toContain('Example')
const bad = await dispatchBrowserMcpTool(service, 'browser_navigate', { url: 'file:///x' })
expect(bad.isError).toBe(true)
})
})

describe('writeBrowserMcpProxy', () => {
it('writes a stdio proxy the ACP agent can spawn', async () => {
const dir = await mkdtemp(join(tmpdir(), 'ari-mcp-'))
const path = await writeBrowserMcpProxy(dir)
const source = await readFile(path, 'utf8')
expect(source).toContain('ARI_BROWSER_MCP_URL')
expect(source).toContain('fetch(url')
})
})

describe('startBrowserMcpServer', () => {
it('serves tools/list over authorized HTTP', async () => {
const service = new BrowserService(() => guest(), vi.fn())
const handle = await startBrowserMcpServer(service)
try {
const denied = await fetch(handle.url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
})
expect(denied.status).toBe(401)
const ok = await fetch(handle.url, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${handle.token}` },
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }),
})
expect(ok.status).toBe(200)
const body = (await ok.json()) as { result?: { tools?: { name: string }[] } }
expect(body.result?.tools?.map((t) => t.name)).toContain('browser_navigate')
} finally {
handle.close()
}
})
})
Loading
Loading