mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
feat(stage-tamagotchi,stage-ui,computer-use-mcp): intro agent-owned session and ghost pointer phases (#1649)
This commit is contained in:
@@ -12,6 +12,7 @@ import { Format, LogLevel, setGlobalFormat, setGlobalHookPostLog, setGlobalLogLe
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import { initScreenCaptureForMain } from '@proj-airi/electron-screen-capture/main'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { noop } from 'es-toolkit'
|
||||
import { createLoggLogger, injeca, lifecycle } from 'injeca'
|
||||
import { isLinux } from 'std-env'
|
||||
|
||||
@@ -112,9 +113,9 @@ app.whenReady().then(async () => {
|
||||
build: ({ dependsOn }) => setupAutoUpdater({
|
||||
getStoredUpdateLane: () => dependsOn.appConfig.get()?.updateChannel,
|
||||
setStoredUpdateLane: (lane) => {
|
||||
const current = dependsOn.appConfig.get()
|
||||
const currentConfig = dependsOn.appConfig.get()
|
||||
dependsOn.appConfig.update({
|
||||
language: current?.language ?? 'en',
|
||||
language: currentConfig?.language ?? 'en',
|
||||
updateChannel: lane,
|
||||
})
|
||||
},
|
||||
@@ -143,12 +144,22 @@ app.whenReady().then(async () => {
|
||||
build: async () => setupMcpStdioManager(),
|
||||
})
|
||||
|
||||
const widgetsManager = injeca.provide('windows:widgets', {
|
||||
dependsOn: { serverChannel, i18n },
|
||||
build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn),
|
||||
})
|
||||
|
||||
const pluginHost = injeca.provide('modules:plugin-host', {
|
||||
dependsOn: { serverChannel, widgetsManager },
|
||||
build: ({ dependsOn }) => setupPluginHost(dependsOn),
|
||||
})
|
||||
|
||||
const windowAuthManager = injeca.provide('services:window-auth-manager', () => createWindowAuthManagerService())
|
||||
|
||||
// BeatSync will create a background window to capture and process audio.
|
||||
const beatSync = injeca.provide('windows:beat-sync', () => setupBeatSync())
|
||||
|
||||
const devtoolsWindow = injeca.provide('windows:devtools', () => setupDevtoolsWindow())
|
||||
const devtoolsMarkdownStressWindow = injeca.provide('windows:devtools:markdown-stress', () => setupDevtoolsWindow())
|
||||
|
||||
const onboardingWindowManager = injeca.provide('windows:onboarding', {
|
||||
dependsOn: { serverChannel, i18n, windowAuthManager },
|
||||
@@ -160,16 +171,6 @@ app.whenReady().then(async () => {
|
||||
build: ({ dependsOn }) => setupNoticeWindowManager(dependsOn),
|
||||
})
|
||||
|
||||
const widgetsManager = injeca.provide('windows:widgets', {
|
||||
dependsOn: { serverChannel, i18n },
|
||||
build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn),
|
||||
})
|
||||
|
||||
const pluginHost = injeca.provide('modules:plugin-host', {
|
||||
dependsOn: { serverChannel, widgetsManager },
|
||||
build: ({ dependsOn }) => setupPluginHost({ widgetsManager: dependsOn.widgetsManager }),
|
||||
})
|
||||
|
||||
const aboutWindow = injeca.provide('windows:about', {
|
||||
dependsOn: { autoUpdater, i18n, serverChannel },
|
||||
build: ({ dependsOn }) => setupAboutWindowReusable(dependsOn),
|
||||
@@ -181,7 +182,7 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
const settingsWindow = injeca.provide('windows:settings', {
|
||||
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, windowAuthManager },
|
||||
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, windowAuthManager },
|
||||
build: async ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn),
|
||||
})
|
||||
|
||||
@@ -212,7 +213,7 @@ app.whenReady().then(async () => {
|
||||
// provider depends on 'windows:desktop-overlay'.
|
||||
injeca.invoke({
|
||||
dependsOn: { desktopOverlay },
|
||||
callback: () => {},
|
||||
callback: noop,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { DesktopOverlayReadiness } from '../../../../shared/eventa'
|
||||
export { getDesktopOverlayReadinessContract } from '../../../../shared/eventa'
|
||||
@@ -14,10 +14,13 @@ import type { BrowserWindow } from 'electron'
|
||||
import type { I18n } from '../../../libs/i18n'
|
||||
import type { ServerChannel } from '../../../services/airi/channel-server'
|
||||
import type { McpStdioManager } from '../../../services/airi/mcp-servers'
|
||||
import type { DesktopOverlayReadiness } from './contracts'
|
||||
|
||||
import { defineInvokeHandler } from '@moeru/eventa'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
import { getDesktopOverlayReadinessContract } from '../../../../shared/eventa'
|
||||
import { createMcpServersService } from '../../../services/airi/mcp-servers'
|
||||
import { setupBaseWindowElectronInvokes } from '../../shared/window'
|
||||
|
||||
@@ -34,6 +37,23 @@ export async function setupDesktopOverlayElectronInvokes(params: {
|
||||
|
||||
const { context } = createContext(ipcMain, params.window)
|
||||
|
||||
await setupBaseWindowElectronInvokes({ context, window: params.window, i18n: params.i18n, serverChannel: params.serverChannel })
|
||||
createMcpServersService({ context, manager: params.mcpStdioManager })
|
||||
let readiness: DesktopOverlayReadiness = { state: 'booting' }
|
||||
|
||||
defineInvokeHandler(context, getDesktopOverlayReadinessContract, async () => {
|
||||
return readiness
|
||||
})
|
||||
|
||||
try {
|
||||
await setupBaseWindowElectronInvokes({ context, window: params.window, i18n: params.i18n, serverChannel: params.serverChannel })
|
||||
createMcpServersService({ context, manager: params.mcpStdioManager })
|
||||
readiness = { state: 'ready' }
|
||||
}
|
||||
catch (error) {
|
||||
readiness = {
|
||||
state: 'degraded',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
// We intentionally don't throw here so the window still opens and
|
||||
// the renderer gracefully detects the degraded state via polling.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,8 +435,8 @@ export function setupWidgetsWindowManager(params: {
|
||||
const minHeight = clamp(windowSize.minHeight ?? 160, 1, work.height)
|
||||
const maxWidth = clamp(windowSize.maxWidth ?? work.width, minWidth, work.width)
|
||||
const maxHeight = clamp(windowSize.maxHeight ?? work.height, minHeight, work.height)
|
||||
const width = clamp(windowSize.width, minWidth, maxWidth)
|
||||
const height = clamp(windowSize.height, minHeight, maxHeight)
|
||||
const width = clamp(windowSize.width ?? minWidth, minWidth, maxWidth)
|
||||
const height = clamp(windowSize.height ?? minHeight, minHeight, maxHeight)
|
||||
const currentBounds = window.getBounds()
|
||||
|
||||
window.setMinimumSize(minWidth, minHeight)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ElectronMcpCallToolResult } from '../../shared/eventa'
|
||||
import type { McpCallToolResult } from '@proj-airi/stage-ui/stores/mcp-tool-bridge'
|
||||
|
||||
import type { OverlayState } from './desktop-overlay-polling'
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -23,6 +24,7 @@ describe('extractOverlayState', () => {
|
||||
expect(result.candidates).toEqual([])
|
||||
expect(result.pointerIntent).toBeNull()
|
||||
expect(result.staleFlags).toEqual({ screenshot: false, ax: false, chromeSemantic: false })
|
||||
expect(result.bootstrapState).toBe('booting')
|
||||
})
|
||||
|
||||
it('extracts candidates from lastGroundingSnapshot', () => {
|
||||
@@ -142,6 +144,7 @@ describe('createEmptyOverlayState', () => {
|
||||
expect(a.hasSnapshot).toBe(false)
|
||||
expect(a.candidates).toEqual([])
|
||||
expect(a.pointerIntent).toBeNull()
|
||||
expect(a.bootstrapState).toBe('booting')
|
||||
|
||||
// Should not be the same reference (no shared mutation)
|
||||
a.candidates.push({ id: 'x', source: 'raw', role: 'button', label: 'X', bounds: { x: 0, y: 0, width: 10, height: 10 }, confidence: 1 })
|
||||
@@ -161,7 +164,7 @@ describe('createOverlayPollController', () => {
|
||||
it('calls tool and delivers state on successful poll', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const mockResult: ElectronMcpCallToolResult = {
|
||||
const mockResult: McpCallToolResult = {
|
||||
structuredContent: {
|
||||
runState: {
|
||||
lastGroundingSnapshot: {
|
||||
@@ -175,13 +178,16 @@ describe('createOverlayPollController', () => {
|
||||
},
|
||||
}
|
||||
|
||||
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
|
||||
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
|
||||
.mockResolvedValue(mockResult)
|
||||
|
||||
const received: OverlayState[] = []
|
||||
|
||||
const getReadiness = vi.fn().mockResolvedValue({ state: 'ready' })
|
||||
|
||||
const controller = createOverlayPollController({
|
||||
callTool,
|
||||
getReadiness,
|
||||
onState: (s) => { received.push(s) },
|
||||
intervalMs: 100,
|
||||
fallbackIntervalMs: 200,
|
||||
@@ -193,9 +199,11 @@ describe('createOverlayPollController', () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(callTool).toHaveBeenCalledWith(MCP_TOOL_NAME)
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0].hasSnapshot).toBe(true)
|
||||
expect(received[0].candidates[0].id).toBe('t_0')
|
||||
expect(received).toHaveLength(2)
|
||||
expect(received[0].bootstrapState).toBe('ready')
|
||||
expect(received[0].hasSnapshot).toBe(false)
|
||||
expect(received[1].hasSnapshot).toBe(true)
|
||||
expect(received[1].candidates[0].id).toBe('t_0')
|
||||
|
||||
controller.stop()
|
||||
})
|
||||
@@ -203,11 +211,14 @@ describe('createOverlayPollController', () => {
|
||||
it('stops polling after stop() is called', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
|
||||
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
|
||||
.mockResolvedValue({ structuredContent: {} })
|
||||
|
||||
const getReadiness = vi.fn().mockResolvedValue({ state: 'ready' })
|
||||
|
||||
const controller = createOverlayPollController({
|
||||
callTool,
|
||||
getReadiness,
|
||||
onState: () => {},
|
||||
intervalMs: 100,
|
||||
})
|
||||
@@ -227,7 +238,7 @@ describe('createOverlayPollController', () => {
|
||||
it('continues polling after a single failure', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
|
||||
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
|
||||
.mockRejectedValueOnce(new Error('MCP down'))
|
||||
.mockResolvedValue({
|
||||
structuredContent: {
|
||||
@@ -243,8 +254,11 @@ describe('createOverlayPollController', () => {
|
||||
|
||||
const received: OverlayState[] = []
|
||||
|
||||
const getReadiness = vi.fn().mockResolvedValue({ state: 'ready' })
|
||||
|
||||
const controller = createOverlayPollController({
|
||||
callTool,
|
||||
getReadiness,
|
||||
onState: (s) => { received.push(s) },
|
||||
intervalMs: 100,
|
||||
fallbackIntervalMs: 200,
|
||||
@@ -252,16 +266,17 @@ describe('createOverlayPollController', () => {
|
||||
|
||||
controller.start()
|
||||
|
||||
// First poll: fails
|
||||
// First poll: fails (but empty ready state was emitted)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(callTool).toHaveBeenCalledTimes(1)
|
||||
expect(received).toHaveLength(0)
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0].bootstrapState).toBe('ready')
|
||||
|
||||
// Wait for fallback interval
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(callTool).toHaveBeenCalledTimes(2)
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0].snapshotId).toBe('dg_recover')
|
||||
expect(received).toHaveLength(2)
|
||||
expect(received[1].snapshotId).toBe('dg_recover')
|
||||
|
||||
controller.stop()
|
||||
})
|
||||
@@ -269,11 +284,14 @@ describe('createOverlayPollController', () => {
|
||||
it('is a no-op to call start() twice', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
|
||||
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
|
||||
.mockResolvedValue({ structuredContent: {} })
|
||||
|
||||
const getReadiness = vi.fn().mockResolvedValue({ state: 'ready' })
|
||||
|
||||
const controller = createOverlayPollController({
|
||||
callTool,
|
||||
getReadiness,
|
||||
onState: () => {},
|
||||
intervalMs: 100,
|
||||
})
|
||||
@@ -291,7 +309,7 @@ describe('createOverlayPollController', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
// First call hangs forever (simulates startup race when RPC not ready)
|
||||
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
|
||||
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
|
||||
.mockImplementationOnce(() => new Promise(() => {})) // never resolves
|
||||
.mockResolvedValue({
|
||||
structuredContent: {
|
||||
@@ -307,8 +325,11 @@ describe('createOverlayPollController', () => {
|
||||
|
||||
const received: OverlayState[] = []
|
||||
|
||||
const getReadiness = vi.fn().mockResolvedValue({ state: 'ready' })
|
||||
|
||||
const controller = createOverlayPollController({
|
||||
callTool,
|
||||
getReadiness,
|
||||
onState: (s) => { received.push(s) },
|
||||
intervalMs: 100,
|
||||
fallbackIntervalMs: 200,
|
||||
@@ -317,20 +338,201 @@ describe('createOverlayPollController', () => {
|
||||
|
||||
controller.start()
|
||||
|
||||
// First poll fires immediately, callTool hangs
|
||||
// First poll fires immediately (emits ready state), callTool hangs
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(callTool).toHaveBeenCalledTimes(1)
|
||||
expect(received).toHaveLength(0)
|
||||
expect(received).toHaveLength(1)
|
||||
|
||||
// Advance past the 500ms timeout → catch triggers, schedules fallback
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(received).toHaveLength(0)
|
||||
expect(received).toHaveLength(1)
|
||||
|
||||
// Advance past the 200ms fallback interval → second poll fires and succeeds
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(callTool).toHaveBeenCalledTimes(2)
|
||||
expect(received).toHaveLength(2)
|
||||
expect(received[1].snapshotId).toBe('dg_after_timeout')
|
||||
|
||||
controller.stop()
|
||||
})
|
||||
|
||||
it('caps outstanding timed-out polls to avoid unbounded buildup', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
|
||||
.mockImplementation(() => new Promise<McpCallToolResult>(() => {}))
|
||||
|
||||
const controller = createOverlayPollController({
|
||||
callTool,
|
||||
getReadiness: vi.fn().mockResolvedValue({ state: 'ready' }),
|
||||
onState: () => {},
|
||||
intervalMs: 100,
|
||||
fallbackIntervalMs: 200,
|
||||
callTimeoutMs: 500,
|
||||
})
|
||||
|
||||
controller.start()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(callTool).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(callTool).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(callTool).toHaveBeenCalledTimes(2)
|
||||
|
||||
controller.stop()
|
||||
})
|
||||
|
||||
it('issues a low-frequency recovery probe when all tracked polls are permanently hung', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
|
||||
.mockImplementation(() => new Promise<McpCallToolResult>(() => {}))
|
||||
|
||||
const controller = createOverlayPollController({
|
||||
callTool,
|
||||
getReadiness: vi.fn().mockResolvedValue({ state: 'ready' }),
|
||||
onState: () => {},
|
||||
intervalMs: 100,
|
||||
fallbackIntervalMs: 200,
|
||||
callTimeoutMs: 500,
|
||||
})
|
||||
|
||||
controller.start()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(callTool).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(callTool).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(callTool).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(callTool).toHaveBeenCalledTimes(3)
|
||||
|
||||
controller.stop()
|
||||
})
|
||||
|
||||
it('releases timed-out poll slots only when the original promise settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
let resolveFirst: (value: McpCallToolResult) => void = () => {}
|
||||
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
|
||||
.mockImplementationOnce(() => new Promise<McpCallToolResult>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
}))
|
||||
.mockImplementationOnce(() => new Promise<McpCallToolResult>(() => {}))
|
||||
.mockResolvedValue({
|
||||
structuredContent: {
|
||||
runState: {
|
||||
lastGroundingSnapshot: {
|
||||
snapshotId: 'dg_after_lease',
|
||||
targetCandidates: [],
|
||||
staleFlags: { screenshot: false, ax: false, chromeSemantic: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const received: OverlayState[] = []
|
||||
|
||||
const controller = createOverlayPollController({
|
||||
callTool,
|
||||
getReadiness: vi.fn().mockResolvedValue({ state: 'ready' }),
|
||||
onState: (state) => {
|
||||
received.push(state)
|
||||
},
|
||||
intervalMs: 100,
|
||||
fallbackIntervalMs: 200,
|
||||
callTimeoutMs: 500,
|
||||
})
|
||||
|
||||
controller.start()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(callTool).toHaveBeenCalledTimes(1)
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0].snapshotId).toBe('dg_after_timeout')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(callTool).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(callTool).toHaveBeenCalledTimes(2)
|
||||
expect(received).toHaveLength(1)
|
||||
|
||||
resolveFirst({ structuredContent: {} })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(callTool).toHaveBeenCalledTimes(3)
|
||||
expect(received).toHaveLength(2)
|
||||
expect(received[1].snapshotId).toBe('dg_after_lease')
|
||||
|
||||
controller.stop()
|
||||
})
|
||||
|
||||
it('waits for readiness before entering main poll loop', async () => {
|
||||
vi.useFakeTimers()
|
||||
const callTool = vi.fn()
|
||||
const getReadiness = vi.fn()
|
||||
.mockResolvedValueOnce({ state: 'booting' })
|
||||
.mockResolvedValueOnce({ state: 'booting' })
|
||||
.mockResolvedValueOnce({ state: 'ready' })
|
||||
const received: OverlayState[] = []
|
||||
|
||||
const controller = createOverlayPollController({
|
||||
callTool,
|
||||
getReadiness,
|
||||
onState: s => received.push(s),
|
||||
intervalMs: 100,
|
||||
fallbackIntervalMs: 200,
|
||||
})
|
||||
controller.start()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(callTool).not.toHaveBeenCalled()
|
||||
expect(received[0].bootstrapState).toBe('booting')
|
||||
|
||||
// First retry
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(callTool).not.toHaveBeenCalled()
|
||||
|
||||
// Second retry triggers ready and immediately polls
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(callTool).toHaveBeenCalledTimes(1)
|
||||
expect(received.at(-1)?.bootstrapState).toBe('ready')
|
||||
|
||||
controller.stop()
|
||||
})
|
||||
|
||||
it('reports degraded state if getReadiness throws', async () => {
|
||||
vi.useFakeTimers()
|
||||
const callTool = vi.fn()
|
||||
const getReadiness = vi.fn().mockRejectedValue(new Error('RPC failed'))
|
||||
const received: OverlayState[] = []
|
||||
|
||||
const controller = createOverlayPollController({
|
||||
callTool,
|
||||
getReadiness,
|
||||
onState: s => received.push(s),
|
||||
intervalMs: 100,
|
||||
fallbackIntervalMs: 200,
|
||||
})
|
||||
controller.start()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(callTool).not.toHaveBeenCalled()
|
||||
expect(received[0].bootstrapState).toBe('degraded')
|
||||
expect(received[0].lastBootstrapError).toBe('RPC failed')
|
||||
|
||||
controller.stop()
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* without a DOM environment or Vue test-utils.
|
||||
*/
|
||||
|
||||
import type { ElectronMcpCallToolResult } from '../../shared/eventa'
|
||||
import type { McpCallToolResult } from '@proj-airi/stage-ui/stores/mcp-tool-bridge'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — minimal shapes matching RunState fields the overlay consumes
|
||||
@@ -26,6 +26,8 @@ export interface OverlayPointerIntent {
|
||||
source: string
|
||||
confidence: number
|
||||
mode: string
|
||||
phase?: 'preview' | 'executing' | 'completed'
|
||||
executionResult?: 'success' | 'fallback' | 'error'
|
||||
}
|
||||
|
||||
export interface OverlayStaleFlags {
|
||||
@@ -40,6 +42,8 @@ export interface OverlayState {
|
||||
candidates: OverlayTargetCandidate[]
|
||||
staleFlags: OverlayStaleFlags
|
||||
pointerIntent: OverlayPointerIntent | null
|
||||
bootstrapState: 'booting' | 'ready' | 'degraded'
|
||||
lastBootstrapError?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -58,6 +62,7 @@ export function createEmptyOverlayState(): OverlayState {
|
||||
candidates: [],
|
||||
staleFlags: { ...EMPTY_STALE },
|
||||
pointerIntent: null,
|
||||
bootstrapState: 'booting',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +95,7 @@ export function extractOverlayState(runState: Record<string, unknown>): OverlayS
|
||||
* Extract runState from an MCP call result.
|
||||
* Returns undefined if the result is an error or has no structured content.
|
||||
*/
|
||||
export function extractRunStateFromResult(result: ElectronMcpCallToolResult): Record<string, unknown> | undefined {
|
||||
export function extractRunStateFromResult(result: McpCallToolResult): Record<string, unknown> | undefined {
|
||||
if (result.isError)
|
||||
return undefined
|
||||
|
||||
@@ -121,9 +126,11 @@ export interface OverlayPollController {
|
||||
|
||||
export interface OverlayPollConfig {
|
||||
/** Function to call MCP tool. */
|
||||
callTool: (name: string) => Promise<ElectronMcpCallToolResult>
|
||||
callTool: (name: string) => Promise<McpCallToolResult>
|
||||
/** Callback with extracted state on each successful poll. */
|
||||
onState: (state: OverlayState) => void
|
||||
/** Function to ping main process readiness contract via Eventa. */
|
||||
getReadiness: () => Promise<{ state: 'booting' | 'ready' | 'degraded', error?: string }>
|
||||
/** Normal poll interval in ms. Default: 250. */
|
||||
intervalMs?: number
|
||||
/** Fallback interval on error in ms. Default: 500. */
|
||||
@@ -135,6 +142,8 @@ export interface OverlayPollConfig {
|
||||
const DEFAULT_INTERVAL = 250
|
||||
const DEFAULT_FALLBACK_INTERVAL = 500
|
||||
const DEFAULT_CALL_TIMEOUT = 5000
|
||||
const MAX_BACKGROUND_HUNG_CALLS = 2
|
||||
const HUNG_CALL_RECOVERY_INTERVAL_MS = 10_000
|
||||
|
||||
/**
|
||||
* MCP server name for computer-use-mcp. Matches the key in mcp.json.
|
||||
@@ -150,25 +159,146 @@ export function createOverlayPollController(config: OverlayPollConfig): OverlayP
|
||||
const fallbackInterval = config.fallbackIntervalMs ?? DEFAULT_FALLBACK_INTERVAL
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let bootstrapTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let running = false
|
||||
let inFlightCall: Promise<McpCallToolResult> | null = null
|
||||
let backgroundHungCalls: Array<{
|
||||
call: Promise<McpCallToolResult>
|
||||
timedOutAt: number
|
||||
}> = []
|
||||
let lastHungRecoveryProbeAt: number | null = null
|
||||
|
||||
let currentBootstrapState: 'booting' | 'ready' | 'degraded' = 'booting'
|
||||
let currentBootstrapError: string | undefined
|
||||
|
||||
function scheduleNext(nextInterval: number) {
|
||||
if (running) {
|
||||
timer = setTimeout(poll, nextInterval)
|
||||
}
|
||||
}
|
||||
|
||||
function emitEmptyState() {
|
||||
const empty = createEmptyOverlayState()
|
||||
empty.bootstrapState = currentBootstrapState
|
||||
empty.lastBootstrapError = currentBootstrapError
|
||||
config.onState(empty)
|
||||
}
|
||||
|
||||
function removeHungCall(call: Promise<McpCallToolResult>) {
|
||||
backgroundHungCalls = backgroundHungCalls.filter(slot => slot.call !== call)
|
||||
if (backgroundHungCalls.length < MAX_BACKGROUND_HUNG_CALLS) {
|
||||
lastHungRecoveryProbeAt = null
|
||||
}
|
||||
}
|
||||
|
||||
function canStartPoll(now: number) {
|
||||
if (inFlightCall)
|
||||
return false
|
||||
|
||||
if (backgroundHungCalls.length < MAX_BACKGROUND_HUNG_CALLS)
|
||||
return true
|
||||
|
||||
if (lastHungRecoveryProbeAt === null) {
|
||||
lastHungRecoveryProbeAt = now
|
||||
return false
|
||||
}
|
||||
|
||||
if ((now - lastHungRecoveryProbeAt) < HUNG_CALL_RECOVERY_INTERVAL_MS)
|
||||
return false
|
||||
|
||||
// NOTICE: Eventa does not expose abort semantics for callTool here. If all
|
||||
// tracked calls are permanently hung, waiting for settlement also makes the
|
||||
// overlay permanently stale. Drop one old tracking slot only after a long
|
||||
// recovery interval so the overlay can probe again without returning to a
|
||||
// per-poll unbounded RPC backlog.
|
||||
backgroundHungCalls = backgroundHungCalls.slice(1)
|
||||
lastHungRecoveryProbeAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
async function bootstrapPoll() {
|
||||
try {
|
||||
const res = await config.getReadiness()
|
||||
currentBootstrapState = res.state
|
||||
currentBootstrapError = res.error
|
||||
}
|
||||
catch (e) {
|
||||
currentBootstrapState = 'degraded'
|
||||
currentBootstrapError = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
|
||||
if (!running)
|
||||
return
|
||||
|
||||
if (currentBootstrapState === 'ready') {
|
||||
emitEmptyState()
|
||||
poll()
|
||||
}
|
||||
else {
|
||||
emitEmptyState()
|
||||
bootstrapTimer = setTimeout(bootstrapPoll, fallbackInterval)
|
||||
}
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!canStartPoll(Date.now())) {
|
||||
scheduleNext(fallbackInterval)
|
||||
return
|
||||
}
|
||||
|
||||
let nextInterval = normalInterval
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
try {
|
||||
// NOTICE: Wrap callTool with a timeout to prevent the poll loop from
|
||||
// hanging forever if the eventa invoke never resolves (e.g. during
|
||||
// startup when the main-process RPC handlers may not be ready yet).
|
||||
// NOTICE: Eventa does not expose abort semantics here, so a timed-out
|
||||
// invoke can still be unresolved in the background. Track timed-out calls
|
||||
// and allow only a low-frequency recovery probe when all tracked slots
|
||||
// are hung, balancing bounded IPC pressure with eventual overlay recovery.
|
||||
let timedOut = false
|
||||
const currentCall = config.callTool(MCP_TOOL_NAME)
|
||||
inFlightCall = currentCall
|
||||
currentCall.then(() => {
|
||||
if (timedOut) {
|
||||
removeHungCall(currentCall)
|
||||
}
|
||||
else if (inFlightCall === currentCall) {
|
||||
inFlightCall = null
|
||||
}
|
||||
}, () => {
|
||||
if (timedOut) {
|
||||
removeHungCall(currentCall)
|
||||
}
|
||||
else if (inFlightCall === currentCall) {
|
||||
inFlightCall = null
|
||||
}
|
||||
})
|
||||
|
||||
const result = await Promise.race([
|
||||
config.callTool(MCP_TOOL_NAME),
|
||||
currentCall,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('callTool timeout')), config.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT),
|
||||
timeoutId = setTimeout(() => {
|
||||
timedOut = true
|
||||
backgroundHungCalls = [...backgroundHungCalls, {
|
||||
call: currentCall,
|
||||
timedOutAt: Date.now(),
|
||||
}]
|
||||
if (inFlightCall === currentCall) {
|
||||
inFlightCall = null
|
||||
}
|
||||
reject(new Error('callTool timeout'))
|
||||
}, config.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT),
|
||||
),
|
||||
])
|
||||
const runState = extractRunStateFromResult(result)
|
||||
|
||||
if (runState) {
|
||||
config.onState(extractOverlayState(runState))
|
||||
const state = extractOverlayState(runState)
|
||||
state.bootstrapState = currentBootstrapState
|
||||
state.lastBootstrapError = currentBootstrapError
|
||||
config.onState(state)
|
||||
}
|
||||
else {
|
||||
nextInterval = fallbackInterval
|
||||
@@ -178,10 +308,13 @@ export function createOverlayPollController(config: OverlayPollConfig): OverlayP
|
||||
// MCP server not running, bridge disconnected, or timeout — graceful degradation
|
||||
nextInterval = fallbackInterval
|
||||
}
|
||||
|
||||
if (running) {
|
||||
timer = setTimeout(poll, nextInterval)
|
||||
finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
scheduleNext(nextInterval)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -189,8 +322,8 @@ export function createOverlayPollController(config: OverlayPollConfig): OverlayP
|
||||
if (running)
|
||||
return
|
||||
running = true
|
||||
// Start first poll immediately
|
||||
poll()
|
||||
// First handshake with the host before starting actual MCP polling
|
||||
bootstrapPoll()
|
||||
},
|
||||
|
||||
stop() {
|
||||
@@ -199,6 +332,10 @@ export function createOverlayPollController(config: OverlayPollConfig): OverlayP
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
if (bootstrapTimer !== null) {
|
||||
clearTimeout(bootstrapTimer)
|
||||
bootstrapTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
isRunning() {
|
||||
|
||||
@@ -18,9 +18,10 @@ import type { OverlayState } from './desktop-overlay-polling'
|
||||
|
||||
import { electron } from '@proj-airi/electron-eventa'
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { getMcpToolBridge } from '@proj-airi/stage-ui/stores/mcp-tool-bridge'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { electronMcpCallTool } from '../../shared/eventa'
|
||||
import { getDesktopOverlayReadinessContract } from '../../shared/eventa'
|
||||
import { pointInOverlay, rectIntersectsOverlay, screenRectToLocal, screenToLocal } from './desktop-overlay-coordinates'
|
||||
import { createEmptyOverlayState, createOverlayPollController } from './desktop-overlay-polling'
|
||||
|
||||
@@ -29,11 +30,7 @@ import { createEmptyOverlayState, createOverlayPollController } from './desktop-
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getWindowBounds = useElectronEventaInvoke(electron.window.getBounds)
|
||||
// Use Eventa invoke for MCP tool calls — McpToolBridge requires a
|
||||
// setMcpToolBridge() caller that does not exist in the overlay renderer.
|
||||
// electronMcpCallTool is already wired in setupDesktopOverlayElectronInvokes
|
||||
// via createMcpServersService, so it works without any extra bootstrap.
|
||||
const mcpCallTool = useElectronEventaInvoke(electronMcpCallTool)
|
||||
const getReadiness = useElectronEventaInvoke(getDesktopOverlayReadinessContract)
|
||||
const overlayBounds = ref<Rect | null>(null)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,8 +71,18 @@ const matchedCandidate = computed(() => {
|
||||
// Polling controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let bridgeAvailable = false
|
||||
|
||||
const controller = createOverlayPollController({
|
||||
callTool: async name => mcpCallTool({ name }),
|
||||
callTool: async (name) => {
|
||||
// Probe bridge availability lazily
|
||||
if (!bridgeAvailable) {
|
||||
getMcpToolBridge() // Throws if not set
|
||||
bridgeAvailable = true
|
||||
}
|
||||
return getMcpToolBridge().callTool({ name })
|
||||
},
|
||||
getReadiness: async () => getReadiness(),
|
||||
onState: (newState) => {
|
||||
state.value = newState
|
||||
},
|
||||
@@ -94,6 +101,25 @@ function sourceColor(source: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const pointerPhase = computed(() => pointerIntent.value?.phase ?? 'preview')
|
||||
const executionResult = computed(() => pointerIntent.value?.executionResult)
|
||||
|
||||
function phaseColor(phase: string, result?: string): { bg: string, shadow: string } {
|
||||
if (phase === 'completed') {
|
||||
switch (result) {
|
||||
case 'success': return { bg: '#22c55e', shadow: 'rgba(34, 197, 94, 0.5)' }
|
||||
case 'fallback': return { bg: '#f59e0b', shadow: 'rgba(245, 158, 11, 0.5)' }
|
||||
case 'error': return { bg: '#ef4444', shadow: 'rgba(239, 68, 68, 0.5)' }
|
||||
default: return { bg: '#6b7280', shadow: 'rgba(107, 114, 128, 0.5)' }
|
||||
}
|
||||
}
|
||||
if (phase === 'executing') {
|
||||
return { bg: '#ef4444', shadow: 'rgba(239, 68, 68, 0.6)' }
|
||||
}
|
||||
// preview / default
|
||||
return { bg: '#3b82f6', shadow: 'rgba(59, 130, 246, 0.5)' }
|
||||
}
|
||||
|
||||
const pointerStyle = computed(() => {
|
||||
if (!pointerIntent.value || !overlayBounds.value)
|
||||
return { display: 'none' }
|
||||
@@ -102,15 +128,41 @@ const pointerStyle = computed(() => {
|
||||
if (!pointInOverlay(screenPoint, ob))
|
||||
return { display: 'none' }
|
||||
const local = screenToLocal(screenPoint, ob)
|
||||
const isExecute = pointerIntent.value.mode === 'execute'
|
||||
const phase = pointerPhase.value
|
||||
const colors = phaseColor(phase, executionResult.value)
|
||||
return {
|
||||
left: `${local.x - 8}px`,
|
||||
top: `${local.y - 8}px`,
|
||||
display: 'block',
|
||||
backgroundColor: isExecute ? '#ef4444' : '#3b82f6',
|
||||
boxShadow: isExecute
|
||||
? '0 0 12px 4px rgba(239, 68, 68, 0.5)'
|
||||
: '0 0 12px 4px rgba(59, 130, 246, 0.5)',
|
||||
backgroundColor: colors.bg,
|
||||
boxShadow: `0 0 12px 4px ${colors.shadow}`,
|
||||
}
|
||||
})
|
||||
|
||||
// Click ripple — shown briefly when phase transitions to 'completed'
|
||||
const showRipple = ref(false)
|
||||
const rippleStyle = computed(() => {
|
||||
if (!pointerIntent.value || !overlayBounds.value || !showRipple.value)
|
||||
return { display: 'none' }
|
||||
const ob = overlayBounds.value
|
||||
const screenPoint = pointerIntent.value.snappedPoint
|
||||
if (!pointInOverlay(screenPoint, ob))
|
||||
return { display: 'none' }
|
||||
const local = screenToLocal(screenPoint, ob)
|
||||
const colors = phaseColor('completed', executionResult.value)
|
||||
return {
|
||||
left: `${local.x - 20}px`,
|
||||
top: `${local.y - 20}px`,
|
||||
display: 'block',
|
||||
borderColor: colors.bg,
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for phase changes to trigger ripple
|
||||
watch(pointerPhase, (newPhase) => {
|
||||
if (newPhase === 'completed') {
|
||||
showRipple.value = true
|
||||
setTimeout(() => { showRipple.value = false }, 600)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -168,10 +220,21 @@ onUnmounted(() => {
|
||||
<!-- Ghost pointer dot -->
|
||||
<div
|
||||
v-if="pointerIntent"
|
||||
:class="['ghost-pointer']"
|
||||
:class="[
|
||||
'ghost-pointer',
|
||||
pointerPhase === 'executing' && 'ghost-pointer--executing',
|
||||
pointerPhase === 'completed' && 'ghost-pointer--completed',
|
||||
]"
|
||||
:style="pointerStyle"
|
||||
/>
|
||||
|
||||
<!-- Click ripple (brief expanding ring on click completion) -->
|
||||
<div
|
||||
v-if="showRipple"
|
||||
:class="['click-ripple']"
|
||||
:style="rippleStyle"
|
||||
/>
|
||||
|
||||
<!-- Target bounding box (matched candidate from pointer intent) -->
|
||||
<div
|
||||
v-if="matchedCandidate"
|
||||
@@ -238,10 +301,48 @@ onUnmounted(() => {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
transition: left 0.15s ease, top 0.15s ease;
|
||||
transition: left 0.15s ease, top 0.15s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Pulsing animation when the agent is executing a click */
|
||||
.ghost-pointer--executing {
|
||||
animation: ghost-pulse 0.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Fade out after execution completes */
|
||||
.ghost-pointer--completed {
|
||||
animation: ghost-fadeout 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes ghost-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.4); opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes ghost-fadeout {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.2); opacity: 0.6; }
|
||||
100% { transform: scale(0.8); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Expanding ring ripple on click */
|
||||
.click-ripple {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid;
|
||||
animation: ripple-expand 0.6s ease-out forwards;
|
||||
pointer-events: none;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
@keyframes ripple-expand {
|
||||
0% { transform: scale(0.5); opacity: 1; }
|
||||
100% { transform: scale(2); opacity: 0; }
|
||||
}
|
||||
|
||||
.target-box {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(59, 130, 246, 0.6);
|
||||
|
||||
@@ -22,16 +22,7 @@ export const electronOpenSettings = defineInvokeEventa<void, { route?: string }>
|
||||
export const electronSettingsNavigate = defineEventa<{ route: string }>('eventa:event:electron:windows:settings:navigate')
|
||||
export const electronOpenChat = defineInvokeEventa('eventa:invoke:electron:windows:chat:open')
|
||||
export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:electron:windows:settings:devtools:open')
|
||||
|
||||
export interface OpenDevtoolsWindowParams {
|
||||
key: string
|
||||
route?: string
|
||||
width?: number
|
||||
height?: number
|
||||
x?: number
|
||||
y?: number
|
||||
}
|
||||
export const electronOpenDevtoolsWindow = defineInvokeEventa<void, OpenDevtoolsWindowParams>('eventa:invoke:electron:windows:devtools:open')
|
||||
export const electronOpenDevtoolsWindow = defineInvokeEventa<void, { key: string, route?: string, width?: number, height?: number, x?: number, y?: number }>('eventa:invoke:electron:windows:devtools:open')
|
||||
|
||||
export interface ElectronServerChannelConfig {
|
||||
tlsConfig?: ServerOptions['tlsConfig'] | null
|
||||
@@ -51,10 +42,20 @@ export interface ElectronUpdaterPreferences {
|
||||
export const electronGetUpdaterPreferences = defineInvokeEventa<ElectronUpdaterPreferences>('eventa:invoke:electron:auto-updater:get-preferences')
|
||||
export const electronSetUpdaterPreferences = defineInvokeEventa<ElectronUpdaterPreferences, ElectronUpdaterPreferences>('eventa:invoke:electron:auto-updater:set-preferences')
|
||||
|
||||
export * from './plugin/assets'
|
||||
export * from './plugin/capabilities'
|
||||
export * from './plugin/host'
|
||||
export * from './plugin/tools'
|
||||
|
||||
export interface DesktopOverlayReadiness {
|
||||
state: 'booting' | 'ready' | 'degraded'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const getDesktopOverlayReadinessContract = defineInvokeEventa<DesktopOverlayReadiness>('eventa:invoke:electron:windows:desktop-overlay:get-readiness')
|
||||
|
||||
export const captionIsFollowingWindowChanged = defineEventa<boolean>('eventa:event:electron:windows:caption-overlay:is-following-window-changed')
|
||||
export const captionGetIsFollowingWindow = defineInvokeEventa<boolean>('eventa:invoke:electron:windows:caption-overlay:get-is-following-window')
|
||||
export const electronCaptionToggleVisibility = defineInvokeEventa<void>('eventa:invoke:electron:windows:caption:toggle-visibility')
|
||||
export const electronCaptionSyncDocking = defineInvokeEventa<void, 'top' | 'bottom' | undefined>('eventa:invoke:electron:windows:caption:sync-docking')
|
||||
|
||||
export type RequestWindowActionDefault = 'confirm' | 'cancel' | 'close'
|
||||
export interface RequestWindowPayload {
|
||||
@@ -87,40 +88,87 @@ export const noticeWindowEventa = createRequestWindowEventa('notice')
|
||||
|
||||
// Widgets / Adhoc window events
|
||||
export interface WidgetWindowSize {
|
||||
width: number
|
||||
height: number
|
||||
width?: number
|
||||
height?: number
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
export type WidgetGridSize = 's' | 'm' | 'l' | { cols?: number, rows?: number }
|
||||
|
||||
export interface WidgetsAddPayload {
|
||||
id?: string
|
||||
componentName: string
|
||||
componentProps?: Record<string, any>
|
||||
// size presets or explicit spans; renderer decides mapping
|
||||
size?: 's' | 'm' | 'l' | { cols?: number, rows?: number }
|
||||
windowSize?: WidgetWindowSize
|
||||
size?: WidgetGridSize
|
||||
windowSize?: WidgetWindowSize | Record<string, unknown>
|
||||
// auto-dismiss in ms; if omitted, persistent until closed by user
|
||||
ttlMs?: number
|
||||
}
|
||||
|
||||
export interface WidgetsUpdatePayload {
|
||||
id: string
|
||||
componentProps?: Record<string, any>
|
||||
size?: WidgetGridSize
|
||||
windowSize?: WidgetWindowSize | Record<string, unknown>
|
||||
ttlMs?: number
|
||||
}
|
||||
|
||||
export interface WidgetSnapshot {
|
||||
id: string
|
||||
componentName: string
|
||||
componentProps: Record<string, any>
|
||||
size: 's' | 'm' | 'l' | { cols?: number, rows?: number }
|
||||
size: WidgetGridSize
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlMs: number
|
||||
}
|
||||
|
||||
export interface WidgetsUpdatePayload {
|
||||
export interface PluginManifestSummary {
|
||||
name: string
|
||||
entrypoints: Record<string, string | undefined>
|
||||
path: string
|
||||
enabled: boolean
|
||||
loaded: boolean
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
export interface PluginRegistrySnapshot {
|
||||
root: string
|
||||
plugins: PluginManifestSummary[]
|
||||
}
|
||||
|
||||
// TODO: Replace these manually duplicated IPC types with re-exports from
|
||||
// @proj-airi/plugin-sdk (CapabilityDescriptor) once stage-ui and the shared
|
||||
// eventa layer can depend on the SDK without introducing unwanted coupling.
|
||||
export interface PluginCapabilityPayload {
|
||||
key: string
|
||||
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PluginCapabilityState {
|
||||
key: string
|
||||
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
|
||||
metadata?: Record<string, unknown>
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface PluginHostSessionSummary {
|
||||
id: string
|
||||
componentProps?: Record<string, any>
|
||||
size?: 's' | 'm' | 'l' | { cols?: number, rows?: number }
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlMs?: number
|
||||
manifestName: string
|
||||
phase: string
|
||||
runtime: 'electron' | 'node' | 'web'
|
||||
moduleId: string
|
||||
}
|
||||
|
||||
export interface PluginHostDebugSnapshot {
|
||||
registry: PluginRegistrySnapshot
|
||||
sessions: PluginHostSessionSummary[]
|
||||
capabilities: PluginCapabilityState[]
|
||||
refreshedAt: number
|
||||
}
|
||||
|
||||
export interface ElectronMcpStdioServerConfig {
|
||||
@@ -182,9 +230,6 @@ export const electronMcpApplyAndRestart = defineInvokeEventa<ElectronMcpStdioApp
|
||||
export const electronMcpGetRuntimeStatus = defineInvokeEventa<ElectronMcpStdioRuntimeStatus>('eventa:invoke:electron:mcp:get-runtime-status')
|
||||
export const electronMcpListTools = defineInvokeEventa<ElectronMcpToolDescriptor[]>('eventa:invoke:electron:mcp:list-tools')
|
||||
export const electronMcpCallTool = defineInvokeEventa<ElectronMcpCallToolResult, ElectronMcpCallToolPayload>('eventa:invoke:electron:mcp:call-tool')
|
||||
export const electronMcpGetConfig = defineInvokeEventa<ElectronMcpStdioConfigFile>('eventa:invoke:electron:mcp:get-config')
|
||||
export const electronMcpUpdateConfig = defineInvokeEventa<void, Partial<ElectronMcpStdioConfigFile>>('eventa:invoke:electron:mcp:update-config')
|
||||
export const electronMcpConfigChanged = defineEventa<ElectronMcpStdioConfigFile>('eventa:event:electron:mcp:config-changed')
|
||||
|
||||
export const widgetsOpenWindow = defineInvokeEventa<void, { id?: string }>('eventa:invoke:electron:windows:widgets:open')
|
||||
export const widgetsHideWindow = defineInvokeEventa<void, { id?: string }>('eventa:invoke:electron:windows:widgets:hide')
|
||||
@@ -301,8 +346,6 @@ export const widgetsUpdateEvent = defineEventa<WidgetsUpdatePayload>('eventa:eve
|
||||
|
||||
// Onboarding window events
|
||||
export const electronOnboardingClose = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:close')
|
||||
export const electronOnboardingCompleted = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:completed')
|
||||
export const electronOnboardingSkipped = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:skipped')
|
||||
export const electronOpenOnboarding = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:open')
|
||||
|
||||
// Auth — OIDC Authorization Code + PKCE flow via system browser
|
||||
@@ -320,9 +363,5 @@ export const electronAuthLogout = defineInvokeEventa<void>('eventa:invoke:electr
|
||||
export const i18nSetLocale = defineInvokeEventa<void, Locale>('eventa:invoke:electron:i18n:set-locale')
|
||||
export const i18nGetLocale = defineInvokeEventa<Locale>('eventa:invoke:electron:i18n:get-locale')
|
||||
|
||||
export * from './plugin/assets'
|
||||
export * from './plugin/capabilities'
|
||||
export * from './plugin/host'
|
||||
export * from './plugin/tools'
|
||||
export { electron } from '@proj-airi/electron-eventa'
|
||||
export * from '@proj-airi/electron-eventa/electron-updater'
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Desktop Lane Status
|
||||
|
||||
Updated: 2026-04-14
|
||||
|
||||
This note is a factual status memo for the current desktop lane work around PR #1649. It is intentionally narrow: only current state, actual blockers, and what should happen now vs later.
|
||||
|
||||
## What is already true
|
||||
|
||||
- The desktop lane direction is stable:
|
||||
- macOS only
|
||||
- Chrome-first
|
||||
- visual + semantic tree + OS input
|
||||
- overlay is a visualization layer, not a second system cursor
|
||||
- The following baselines already exist in code:
|
||||
- `/Users/liuziheng/airi/services/computer-use-mcp/src/executors/macos-local.ts`
|
||||
- saves the real cursor position and restores it with `CGWarpMouseCursorPosition(...)`
|
||||
- `/Users/liuziheng/airi/apps/stage-tamagotchi/src/main/windows/shared/window.ts`
|
||||
- `makeWindowPassThrough()` uses ignore-mouse-events + non-focusable overlay behavior
|
||||
- `/Users/liuziheng/airi/services/computer-use-mcp/src/browser-dom/cdp-bridge.ts`
|
||||
- 5-second heartbeat with teardown after 3 consecutive failures
|
||||
- The Chrome extension bridge and iframe offset work are no longer hypothetical:
|
||||
- PR #1649 already contains a real extension-side WebSocket client bridge
|
||||
- PR #1649 already contains frame offset propagation for iframe DOM candidates
|
||||
|
||||
## What is actually still blocking
|
||||
|
||||
These are the remaining real issues, ordered by severity.
|
||||
|
||||
### 1. Extension unknown actions still return `ok: true`
|
||||
|
||||
- File:
|
||||
- `/Users/liuziheng/airi-pr1649/services/computer-use-mcp/chrome-extension/background.js`
|
||||
- Current behavior:
|
||||
- unsupported actions fall into `result = { error: ... }`
|
||||
- but the response still returns `{ ok: true, result }`
|
||||
- Why this matters:
|
||||
- upper layers can interpret unsupported DOM actions as successful bridge execution
|
||||
- that can suppress OS-input fallback even though nothing actually happened
|
||||
- This is still a real unresolved review blocker.
|
||||
|
||||
### 2. Browser-dom click routing still ignores non-default click semantics
|
||||
|
||||
- File:
|
||||
- `/Users/liuziheng/airi-pr1649/services/computer-use-mcp/src/browser-action-router.ts`
|
||||
- called from `/Users/liuziheng/airi-pr1649/services/computer-use-mcp/src/server/register-desktop-grounding.ts`
|
||||
- Current behavior:
|
||||
- `chrome_dom` candidates route to browser-dom if selector + bridge are available
|
||||
- routing does not currently incorporate `button` / `clickCount`
|
||||
- Why this matters:
|
||||
- right-click or double-click can still be routed to a DOM path that only performs a standard primary click
|
||||
- This is not as severe as the first issue, but it is still a real correctness gap.
|
||||
|
||||
### 3. Overlay lifecycle / RPC readiness is not fully closed yet
|
||||
|
||||
- Files currently being worked on:
|
||||
- `/Users/liuziheng/airi-pr1649/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/contracts.ts`
|
||||
- `/Users/liuziheng/airi-pr1649/apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.ts`
|
||||
- `/Users/liuziheng/airi-pr1649/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.ts`
|
||||
- `/Users/liuziheng/airi-pr1649/apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-polling.test.ts`
|
||||
- Current state:
|
||||
- there is already a preload-order mitigation in `desktop-overlay/index.ts`
|
||||
- there is already a per-call timeout in `desktop-overlay-polling.ts`
|
||||
- there is now work-in-progress code for an explicit readiness contract
|
||||
- Why this is not yet "done":
|
||||
- the readiness flow is still uncommitted work
|
||||
- the live window context still needs one narrow verification pass
|
||||
- This is not proven broken today, but it is the most likely remaining runtime risk on the overlay path.
|
||||
|
||||
## What is not a current blocker
|
||||
|
||||
These items are real ideas or cleanup work, but they are not the thing that should block the line right now.
|
||||
|
||||
- Eager overlay init cleanliness in `apps/stage-tamagotchi/src/main/index.ts`
|
||||
- Refactoring nested browser-dom routing logic for readability
|
||||
- Turning `macos-local.ts` into instant-warp-only fallback with zero motion trace
|
||||
- Rewriting overlay visuals, ghost pointer polish, or extra renderer debug UI
|
||||
|
||||
## How to interpret m13v's comments
|
||||
|
||||
m13v's comments were useful because they matched the real platform constraints, but they should be split correctly:
|
||||
|
||||
- Already aligned with current code:
|
||||
- save → act → restore cursor pattern
|
||||
- overlay should not intercept user input
|
||||
- heartbeat teardown for crashed CDP sessions
|
||||
- Still useful as future refinement:
|
||||
- reducing native motion trace so UI owns more of the visible pointer animation
|
||||
- deeper runtime discipline around session lifecycle
|
||||
|
||||
In short: m13v gave good runtime advice. That does not mean every suggestion is a current blocker.
|
||||
|
||||
## What should happen now
|
||||
|
||||
1. Fix the extension unknown-action response contract so unsupported actions return `ok: false`.
|
||||
2. Restrict browser-dom click routing to left single-click only; force OS-input for right-click or multi-click.
|
||||
3. Finish or explicitly shelve the overlay readiness contract work:
|
||||
- if kept, validate it in a live overlay window context before merging
|
||||
- if not finished now, do not half-merge it
|
||||
|
||||
## What should happen later
|
||||
|
||||
Only after the above is clean:
|
||||
|
||||
1. Optional follow-up:
|
||||
- `fix(stage-tamagotchi): validate desktop overlay lifecycle and RPC readiness in live window context`
|
||||
2. Optional follow-up:
|
||||
- `refactor(computer-use-mcp): evaluate instant-warp-only macOS fallback against ghost-pointer UX`
|
||||
3. Optional follow-up:
|
||||
- strengthen iframe anchor matching when sibling iframes are highly similar
|
||||
|
||||
## Bottom line
|
||||
|
||||
The desktop lane is not blocked by direction. It is blocked by a small number of correctness issues and one still-open overlay lifecycle validation step.
|
||||
|
||||
Do not reopen architecture. Do not mix in polish. Do not keep piling unrelated changes onto the same PR.
|
||||
Generated
+3727
-3033
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,6 @@ patchedDependencies:
|
||||
'@xsai/shared-chat@0.5.0-beta.2': patches/@xsai__shared-chat@0.5.0-beta.2.patch
|
||||
'@xsai/stream-text@0.5.0-beta.2': patches/@xsai__stream-text@0.5.0-beta.2.patch
|
||||
mineflayer-pathfinder: patches/mineflayer-pathfinder.patch
|
||||
mineflayer@4.37.0: patches/mineflayer@4.37.0.patch
|
||||
pixi-live2d-display: patches/pixi-live2d-display.patch
|
||||
catalog:
|
||||
'@ax-llm/ax': ^19.0.43
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
# AIRI Desktop Grounding — Chrome Extension
|
||||
|
||||
Chrome DOM observation and interaction bridge for the AIRI Desktop Grounding layer.
|
||||
Read-only Chrome DOM observation bridge for the AIRI Desktop Grounding layer.
|
||||
|
||||
## What it does
|
||||
|
||||
- Collects interactive elements (buttons, links, inputs, etc.) from all frames in the active Chrome tab
|
||||
- Reports element positions, ARIA roles, text, and rect coordinates
|
||||
- Feeds this data into the desktop grounding snap resolver for coordinate mapping
|
||||
- Performs targeted DOM interactions (set input values, check checkboxes, trigger events) when routed by the action executor
|
||||
|
||||
## What it does NOT do
|
||||
|
||||
- ❌ No DOM mutations (no clicking, typing, scrolling on DOM elements)
|
||||
- ❌ No `eval` / `new Function` / `chrome.scripting.executeScript`
|
||||
- ❌ No external network requests (no Python bridge, no offscreen documents)
|
||||
- ❌ No popup UI
|
||||
|
||||
Physical click/type/scroll actions are performed via real macOS OS-level input events (CGEvent) through the desktop grounding executor. DOM mutations are limited to form-field writes and synthetic event dispatch via the bridge.
|
||||
All user interactions are performed via real macOS OS-level input events (CGEvent) through the desktop grounding executor.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -27,8 +27,6 @@ msg_bridge.js (ISOLATED world)
|
||||
content.js (MAIN world, window.__AIRI_DG__)
|
||||
```
|
||||
|
||||
The background service worker also maintains a native WebSocket connection to `BrowserDomExtensionBridge` (default port 8765) to relay commands from the AIRI host process.
|
||||
|
||||
## Installation (development)
|
||||
|
||||
1. Open `chrome://extensions/`
|
||||
@@ -48,15 +46,7 @@ The background service worker also maintains a native WebSocket connection to `B
|
||||
| `findElements` | Find multiple elements by CSS selector |
|
||||
| `getClickTarget` | Get element center point for click targeting |
|
||||
| `getElementAttributes` | Get all attributes of an element |
|
||||
| `setInputValue` | Set value of a text input or textarea |
|
||||
| `checkCheckbox` | Check or uncheck a native checkbox/radio |
|
||||
| `selectOption` | Select an option in a `<select>` element |
|
||||
| `readInputValue` | Read the current value of an input/textarea/select |
|
||||
| `getComputedStyles` | Get computed CSS styles for an element |
|
||||
| `triggerEvent` | Dispatch a DOM event on an element |
|
||||
| `waitForElement` | Wait for an element to appear in the DOM |
|
||||
| `clickAt` | Dispatch a click event at viewport coordinates |
|
||||
|
||||
## Provenance
|
||||
|
||||
Adapted from the upstream computer-use chrome-extension.
|
||||
Adapted from the repository's Chrome extension source with DOM-action methods stripped.
|
||||
|
||||
@@ -12,12 +12,159 @@
|
||||
* All DOM-mutating actions (click, type, hover, scroll) have been removed
|
||||
* because the desktop lane uses real macOS OS-level input events.
|
||||
*
|
||||
* Adapted from the upstream computer-use chrome-extension.
|
||||
* Adapted from /Users/liuziheng/computer_use/chrome-extension/background.js.
|
||||
* Stripped: offscreen management, Python bridge, all DOM-action commands
|
||||
* (clickAt, typeAt, hoverAt, scrollAt, simulateDragDrop, readStorage,
|
||||
* setStorage, readCanvasData, injectCSS, executeScript, etc.)
|
||||
*/
|
||||
|
||||
/* global chrome */
|
||||
|
||||
const AIRI_BRIDGE_URL = 'ws://127.0.0.1:8765'
|
||||
const AIRI_BRIDGE_HELLO = {
|
||||
type: 'hello',
|
||||
source: 'airi-chrome-extension',
|
||||
version: '1.1.0',
|
||||
}
|
||||
const BRIDGE_RECONNECT_MIN_MS = 1_000
|
||||
const BRIDGE_RECONNECT_MAX_MS = 10_000
|
||||
|
||||
let bridgeSocket = null
|
||||
let bridgeReconnectDelayMs = BRIDGE_RECONNECT_MIN_MS
|
||||
let bridgeReconnectTimer = null
|
||||
|
||||
function normalizeUrl(value) {
|
||||
if (typeof value !== 'string' || value.trim() === '')
|
||||
return ''
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
}
|
||||
catch {
|
||||
return value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapBridgePayload(value) {
|
||||
if (!value || typeof value !== 'object')
|
||||
return value
|
||||
|
||||
if (value.data && typeof value.data === 'object')
|
||||
return value.data
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function mergePayloadWithFrameOffset(result, frameOffset) {
|
||||
if (!frameOffset || typeof frameOffset.x !== 'number' || typeof frameOffset.y !== 'number')
|
||||
return result
|
||||
|
||||
if (!result || typeof result !== 'object')
|
||||
return result
|
||||
|
||||
if (result.data && typeof result.data === 'object') {
|
||||
return {
|
||||
...result,
|
||||
data: {
|
||||
...result.data,
|
||||
frameOffset,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
frameOffset,
|
||||
}
|
||||
}
|
||||
|
||||
function clearBridgeReconnectTimer() {
|
||||
if (!bridgeReconnectTimer)
|
||||
return
|
||||
|
||||
clearTimeout(bridgeReconnectTimer)
|
||||
bridgeReconnectTimer = null
|
||||
}
|
||||
|
||||
function scheduleBridgeReconnect() {
|
||||
if (bridgeReconnectTimer)
|
||||
return
|
||||
|
||||
const delay = bridgeReconnectDelayMs
|
||||
bridgeReconnectDelayMs = Math.min(bridgeReconnectDelayMs * 2, BRIDGE_RECONNECT_MAX_MS)
|
||||
bridgeReconnectTimer = setTimeout(() => {
|
||||
bridgeReconnectTimer = null
|
||||
ensureBridgeConnected().catch(() => {})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function sendBridgePayload(payload) {
|
||||
if (!bridgeSocket || bridgeSocket.readyState !== WebSocket.OPEN)
|
||||
return false
|
||||
|
||||
bridgeSocket.send(JSON.stringify(payload))
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleBridgeSocketMessage(raw) {
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(String(raw))
|
||||
}
|
||||
catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!data || typeof data !== 'object' || typeof data.id !== 'string')
|
||||
return
|
||||
|
||||
const response = await handleCommand(data)
|
||||
sendBridgePayload(response)
|
||||
}
|
||||
|
||||
async function ensureBridgeConnected() {
|
||||
if (bridgeSocket && (bridgeSocket.readyState === WebSocket.OPEN || bridgeSocket.readyState === WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
|
||||
clearBridgeReconnectTimer()
|
||||
|
||||
try {
|
||||
const socket = new WebSocket(AIRI_BRIDGE_URL)
|
||||
bridgeSocket = socket
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
bridgeReconnectDelayMs = BRIDGE_RECONNECT_MIN_MS
|
||||
sendBridgePayload(AIRI_BRIDGE_HELLO)
|
||||
})
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
handleBridgeSocketMessage(event.data).catch(() => {})
|
||||
})
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
if (bridgeSocket === socket) {
|
||||
bridgeSocket = null
|
||||
}
|
||||
scheduleBridgeReconnect()
|
||||
})
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
try {
|
||||
socket.close()
|
||||
}
|
||||
catch {
|
||||
// Ignore close failures and rely on reconnect scheduling.
|
||||
}
|
||||
})
|
||||
}
|
||||
catch {
|
||||
scheduleBridgeReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tab / Frame utilities ----
|
||||
|
||||
async function getActiveTab() {
|
||||
@@ -82,12 +229,170 @@ async function runCUAction(tabId, frameIds, method, args) {
|
||||
)
|
||||
}
|
||||
|
||||
async function readParentFrameAnchors(tabId, frameInfos, targetFrameIds) {
|
||||
const targetIdSet = new Set(Array.isArray(targetFrameIds) ? targetFrameIds : frameInfos.map(frame => frame.frameId))
|
||||
const parentFrameIds = [...new Set(
|
||||
frameInfos
|
||||
.filter(frame => targetIdSet.has(frame.frameId))
|
||||
.map(frame => frame.parentFrameId)
|
||||
.filter(parentFrameId => typeof parentFrameId === 'number' && parentFrameId >= 0),
|
||||
)]
|
||||
|
||||
const anchorMap = new Map()
|
||||
await Promise.all(parentFrameIds.map(async (parentFrameId) => {
|
||||
const response = await sendCUAction(tabId, parentFrameId, 'collectChildFrames', [])
|
||||
const payload = unwrapBridgePayload(response)
|
||||
const childFrames = Array.isArray(payload?.childFrames) ? payload.childFrames : []
|
||||
anchorMap.set(parentFrameId, childFrames)
|
||||
}))
|
||||
|
||||
return anchorMap
|
||||
}
|
||||
|
||||
function pickBestChildAnchor(parentAnchors, childMeta, siblingCount) {
|
||||
if (!Array.isArray(parentAnchors) || parentAnchors.length === 0)
|
||||
return null
|
||||
|
||||
// NOTICE: Chrome's extension frame tree does not expose iframe screen bounds.
|
||||
// We reconstruct child-frame origins by matching the webNavigation frame tree
|
||||
// back to iframe shells discovered in the parent document. URL/name/title
|
||||
// matching is a heuristic, but it is materially better than treating every
|
||||
// subframe rect as top-level viewport coordinates.
|
||||
const childUrl = normalizeUrl(childMeta.url)
|
||||
const childFrameName = typeof childMeta.frameName === 'string' ? childMeta.frameName.trim() : ''
|
||||
const childTitle = typeof childMeta.title === 'string' ? childMeta.title.trim() : ''
|
||||
|
||||
let best = null
|
||||
let bestScore = -1
|
||||
|
||||
for (const anchor of parentAnchors) {
|
||||
if (!anchor || typeof anchor !== 'object' || !anchor.rect)
|
||||
continue
|
||||
|
||||
let score = 0
|
||||
const anchorSrc = normalizeUrl(anchor.src)
|
||||
const anchorContentUrl = normalizeUrl(anchor.contentUrl)
|
||||
const anchorName = typeof anchor.name === 'string' ? anchor.name.trim() : ''
|
||||
const anchorTitle = typeof anchor.title === 'string' ? anchor.title.trim() : ''
|
||||
|
||||
if (childUrl && anchorContentUrl && anchorContentUrl === childUrl)
|
||||
score += 100
|
||||
else if (childUrl && anchorSrc && anchorSrc === childUrl)
|
||||
score += 90
|
||||
else if (childUrl && anchorSrc && childUrl.startsWith(anchorSrc))
|
||||
score += 70
|
||||
|
||||
if (childFrameName && anchorName && anchorName === childFrameName)
|
||||
score += 40
|
||||
|
||||
if (childTitle && anchorTitle && anchorTitle === childTitle)
|
||||
score += 15
|
||||
|
||||
if (siblingCount === 1 && parentAnchors.length === 1)
|
||||
score += 10
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
best = anchor
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore > 0 ? best : null
|
||||
}
|
||||
|
||||
function buildFrameOffsets(frameInfos, domResults, parentAnchorsByFrameId) {
|
||||
const frameInfoById = new Map(frameInfos.map(frame => [frame.frameId, frame]))
|
||||
const domPayloadByFrameId = new Map(domResults.map(entry => [entry.frameId, unwrapBridgePayload(entry.result)]))
|
||||
const directChildCountByParentId = new Map()
|
||||
|
||||
for (const frame of frameInfos) {
|
||||
if (typeof frame.parentFrameId !== 'number' || frame.parentFrameId < 0)
|
||||
continue
|
||||
directChildCountByParentId.set(frame.parentFrameId, (directChildCountByParentId.get(frame.parentFrameId) || 0) + 1)
|
||||
}
|
||||
|
||||
const cache = new Map()
|
||||
|
||||
function resolve(frameId) {
|
||||
if (cache.has(frameId))
|
||||
return cache.get(frameId)
|
||||
|
||||
if (frameId === 0) {
|
||||
const rootOffset = { x: 0, y: 0 }
|
||||
cache.set(frameId, rootOffset)
|
||||
return rootOffset
|
||||
}
|
||||
|
||||
const frameInfo = frameInfoById.get(frameId)
|
||||
if (!frameInfo || typeof frameInfo.parentFrameId !== 'number' || frameInfo.parentFrameId < 0) {
|
||||
cache.set(frameId, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const parentOffset = resolve(frameInfo.parentFrameId)
|
||||
if (!parentOffset) {
|
||||
cache.set(frameId, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const payload = domPayloadByFrameId.get(frameId)
|
||||
const directOffset = payload?.frameOffsetInParent
|
||||
if (directOffset && typeof directOffset.x === 'number' && typeof directOffset.y === 'number') {
|
||||
const resolved = {
|
||||
x: parentOffset.x + directOffset.x,
|
||||
y: parentOffset.y + directOffset.y,
|
||||
}
|
||||
cache.set(frameId, resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
const parentAnchors = parentAnchorsByFrameId.get(frameInfo.parentFrameId) || []
|
||||
const bestAnchor = pickBestChildAnchor(parentAnchors, payload || frameInfo, directChildCountByParentId.get(frameInfo.parentFrameId) || 0)
|
||||
|
||||
if (!bestAnchor?.rect) {
|
||||
cache.set(frameId, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const resolved = {
|
||||
x: parentOffset.x + bestAnchor.rect.x,
|
||||
y: parentOffset.y + bestAnchor.rect.y,
|
||||
}
|
||||
cache.set(frameId, resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
for (const entry of domResults) {
|
||||
resolve(entry.frameId)
|
||||
}
|
||||
|
||||
return cache
|
||||
}
|
||||
|
||||
async function readAllFramesDOMWithOffsets(tabId, frameIds, opts) {
|
||||
const frameInfos = await chrome.webNavigation.getAllFrames({ tabId })
|
||||
const targetIds = Array.isArray(frameIds) && frameIds.length > 0
|
||||
? frameIds
|
||||
: frameInfos.map(frame => frame.frameId)
|
||||
const domResults = await runCUAction(tabId, targetIds, 'collectFrameDOM', [opts || {}])
|
||||
const parentAnchorsByFrameId = await readParentFrameAnchors(tabId, frameInfos, targetIds)
|
||||
const frameOffsets = buildFrameOffsets(frameInfos, domResults, parentAnchorsByFrameId)
|
||||
|
||||
return domResults.map((entry) => {
|
||||
const frameOffset = frameOffsets.get(entry.frameId) || null
|
||||
return {
|
||||
...entry,
|
||||
result: mergePayloadWithFrameOffset(entry.result, frameOffset),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Handle external commands (from AIRI extension bridge) ----
|
||||
|
||||
/**
|
||||
* Handle a command from the AIRI BrowserDomExtensionBridge.
|
||||
*
|
||||
* Supported actions:
|
||||
* Only read-only observation commands are supported:
|
||||
* - getActiveTab: get the active tab info
|
||||
* - getAllFrames: list all frames in the active tab
|
||||
* - readAllFramesDOM: collect interactive elements from all frames
|
||||
@@ -95,14 +400,6 @@ async function runCUAction(tabId, frameIds, method, args) {
|
||||
* - findElements: find multiple elements by CSS selector
|
||||
* - getClickTarget: get center point of an element for click targeting
|
||||
* - getElementAttributes: get all attributes of an element
|
||||
* - setInputValue: set value of a text input or textarea
|
||||
* - checkCheckbox: check or uncheck a native checkbox/radio
|
||||
* - selectOption: select an option in a <select> element
|
||||
* - readInputValue: read the current value of an input/textarea/select
|
||||
* - getComputedStyles: get computed CSS styles for an element
|
||||
* - triggerEvent: dispatch a DOM event on an element
|
||||
* - waitForElement: wait for an element to appear in the DOM
|
||||
* - clickAt: dispatch a click event at viewport coordinates
|
||||
*/
|
||||
async function handleCommand(cmd) {
|
||||
const { action, id } = cmd
|
||||
@@ -125,7 +422,7 @@ async function handleCommand(cmd) {
|
||||
break
|
||||
|
||||
case 'readAllFramesDOM':
|
||||
result = await runCUAction(tabId, cmd.frameIds || null, 'collectFrameDOM', [cmd.opts || {}])
|
||||
result = await readAllFramesDOMWithOffsets(tabId, cmd.frameIds || null, cmd.opts || {})
|
||||
break
|
||||
|
||||
case 'findElement':
|
||||
@@ -144,67 +441,7 @@ async function handleCommand(cmd) {
|
||||
result = await runCUAction(tabId, cmd.frameIds || null, 'getElementAttributes', [cmd.selector || ''])
|
||||
break
|
||||
|
||||
case 'setInputValue':
|
||||
result = await runCUAction(tabId, cmd.frameIds || null, 'setInputValue', [
|
||||
cmd.selector || '',
|
||||
cmd.value || '',
|
||||
{ blur: cmd.opts?.blur !== false, simulateKeystrokes: !!cmd.opts?.simulateKeystrokes },
|
||||
])
|
||||
break
|
||||
|
||||
case 'checkCheckbox':
|
||||
result = await runCUAction(tabId, cmd.frameIds || null, 'checkCheckbox', [
|
||||
cmd.selector || '',
|
||||
cmd.checked,
|
||||
])
|
||||
break
|
||||
|
||||
case 'selectOption':
|
||||
result = await runCUAction(tabId, cmd.frameIds || null, 'selectOption', [
|
||||
cmd.selector || '',
|
||||
cmd.value || '',
|
||||
])
|
||||
break
|
||||
|
||||
case 'readInputValue':
|
||||
result = await runCUAction(tabId, cmd.frameIds || null, 'readInputValue', [
|
||||
cmd.selector || '',
|
||||
])
|
||||
break
|
||||
|
||||
case 'getComputedStyles':
|
||||
result = await runCUAction(tabId, cmd.frameIds || null, 'getComputedStyles', [
|
||||
cmd.selector || '',
|
||||
cmd.properties || [],
|
||||
])
|
||||
break
|
||||
|
||||
case 'triggerEvent':
|
||||
result = await runCUAction(tabId, cmd.frameIds || null, 'triggerEvent', [
|
||||
cmd.selector || '',
|
||||
cmd.eventName || '',
|
||||
cmd.opts || {},
|
||||
])
|
||||
break
|
||||
|
||||
case 'waitForElement':
|
||||
result = await runCUAction(tabId, cmd.frameIds || null, 'waitForElement', [
|
||||
cmd.selector || '',
|
||||
cmd.timeoutMs || 5000,
|
||||
])
|
||||
break
|
||||
|
||||
case 'clickAt':
|
||||
result = await runCUAction(tabId, cmd.frameIds || null, 'clickAt', [
|
||||
cmd.x ?? 0,
|
||||
cmd.y ?? 0,
|
||||
])
|
||||
break
|
||||
|
||||
default:
|
||||
// NOTICE: unknown actions must return ok:false so BrowserDomExtensionBridge
|
||||
// rejects the pending promise; returning ok:true would make callers like
|
||||
// setInputValue/checkCheckbox see a resolved promise and skip fallback paths.
|
||||
return { id, ok: false, error: `unknown action: ${action}` }
|
||||
}
|
||||
|
||||
@@ -227,64 +464,28 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
return true // Keep sendResponse async
|
||||
}
|
||||
|
||||
// Support the existing ws-incoming format from BrowserDomExtensionBridge
|
||||
if (msg.type === 'ws-incoming') {
|
||||
handleCommand(msg.data)
|
||||
.then((resp) => {
|
||||
// Send response back via the same channel
|
||||
chrome.runtime.sendMessage({ type: 'ws-send', data: resp })
|
||||
})
|
||||
.catch((e) => {
|
||||
chrome.runtime.sendMessage({ type: 'ws-send', data: { id: msg.data?.id, ok: false, error: String(e) } })
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// ---- WebSocket Relay ----
|
||||
// Injects the WebSocket connection directly in the background worker,
|
||||
// replacing the deleted offscreen document.
|
||||
// TODO: Add shared-secret auth handshake to prevent rogue localhost processes
|
||||
// from hijacking the bridge. The bridge server should generate a token and
|
||||
// inject it into chrome.storage.local so the extension can present it on hello.
|
||||
const WS_URL = 'ws://localhost:8765'
|
||||
const BRIDGE_VERSION = 'cu-bridge-2026-02-06-no-eval'
|
||||
let ws = null
|
||||
let reconnectDelay = 1000
|
||||
const MAX_DELAY = 30000
|
||||
chrome.runtime.onStartup?.addListener(() => {
|
||||
ensureBridgeConnected().catch(() => {})
|
||||
})
|
||||
|
||||
function connectWS() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING))
|
||||
return
|
||||
chrome.runtime.onInstalled?.addListener(() => {
|
||||
ensureBridgeConnected().catch(() => {})
|
||||
})
|
||||
|
||||
ws = new WebSocket(WS_URL)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[background] WebSocket connected')
|
||||
reconnectDelay = 1000
|
||||
ws.send(JSON.stringify({ type: 'hello', source: 'chrome-extension', version: BRIDGE_VERSION }))
|
||||
}
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const data = JSON.parse(evt.data)
|
||||
handleCommand(data)
|
||||
.then((resp) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(resp))
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ id: data?.id, ok: false, error: String(e) }))
|
||||
}
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
console.error('[background] parse error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log(`[background] WebSocket closed, reconnect in ${reconnectDelay}ms`)
|
||||
ws = null
|
||||
setTimeout(connectWS, reconnectDelay)
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_DELAY)
|
||||
}
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('[background] WebSocket error:', e)
|
||||
ws?.close()
|
||||
}
|
||||
}
|
||||
|
||||
connectWS()
|
||||
ensureBridgeConnected().catch(() => {})
|
||||
|
||||
@@ -4,17 +4,15 @@
|
||||
* Injected into every frame (including cross-origin iframes) in the MAIN world.
|
||||
* Namespace: window.__AIRI_DG__
|
||||
*
|
||||
* IMPORTANT: Direct DOM mutations here are limited to bridge-triggered write
|
||||
* actions (setInputValue, checkCheckbox, selectOption) that are only reachable
|
||||
* via a WebSocket command from the AIRI computer-use-mcp service. Physical
|
||||
* pointer/keyboard actions still go through real macOS OS-level input.
|
||||
* IMPORTANT: This script is READ-ONLY. It does NOT perform any DOM mutations,
|
||||
* clicks, typing, or navigation. All execution is done via real macOS OS-level
|
||||
* input events through the desktop grounding executor.
|
||||
*
|
||||
* Adapted from the upstream computer-use chrome-extension.
|
||||
* Adapted from the repository's Chrome extension source.
|
||||
* Stripped: clickAt, typeAt, hoverAt, scrollAt, simulateDragDrop, readStorage,
|
||||
* setStorage, readCanvasData, injectCSS, and all other untracked DOM mutations.
|
||||
* setStorage, readCanvasData, injectCSS, and all other DOM-mutating methods.
|
||||
* Kept: collectFrameDOM, _describeElement, _collectInteractiveElements,
|
||||
* findElement, findElements, getClickTarget.
|
||||
* Added: setInputValue, checkCheckbox, selectOption.
|
||||
*/
|
||||
(function () {
|
||||
'use strict'
|
||||
@@ -39,8 +37,7 @@
|
||||
name: el.name || '',
|
||||
type: el.type || '',
|
||||
className: typeof el.className === 'string' ? el.className.slice(0, 120) : '',
|
||||
// eslint-disable-next-line unicorn/prefer-dom-node-text-content -- intentional: innerText returns visible text only
|
||||
text: (el.innerText || el.textContent || '').slice(0, 120).trim(),
|
||||
text: (el.textContent || '').slice(0, 120).trim(),
|
||||
value: el.value !== undefined ? String(el.value).slice(0, 60) : '',
|
||||
href: el.href || '',
|
||||
placeholder: el.placeholder || '',
|
||||
@@ -70,6 +67,71 @@
|
||||
return els
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect direct child frame anchors in the current frame.
|
||||
*
|
||||
* NOTICE: This only describes the iframe/frame shell that lives in the
|
||||
* current document. The background worker uses these anchors together with
|
||||
* the Chrome frame tree to reconstruct per-frame viewport offsets.
|
||||
*/
|
||||
function _collectChildFrames() {
|
||||
const nodes = document.querySelectorAll('iframe,frame')
|
||||
const frames = []
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i]
|
||||
const r = node.getBoundingClientRect()
|
||||
if (r.width <= 0 || r.height <= 0)
|
||||
continue
|
||||
|
||||
let contentUrl = ''
|
||||
try {
|
||||
contentUrl = node.contentWindow?.location?.href || ''
|
||||
}
|
||||
catch {
|
||||
// Cross-origin child frames cannot reveal contentWindow.location here.
|
||||
}
|
||||
|
||||
frames.push({
|
||||
index: i,
|
||||
id: node.id || '',
|
||||
name: node.name || '',
|
||||
title: node.getAttribute('title') || '',
|
||||
src: node.getAttribute('src') || '',
|
||||
contentUrl,
|
||||
rect: {
|
||||
x: Math.round(r.left),
|
||||
y: Math.round(r.top),
|
||||
w: Math.round(r.width),
|
||||
h: Math.round(r.height),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
function _readFrameOffsetInParent() {
|
||||
if (window.top === window) {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
try {
|
||||
const frameEl = window.frameElement
|
||||
if (!frameEl)
|
||||
return null
|
||||
|
||||
const r = frameEl.getBoundingClientRect()
|
||||
return {
|
||||
x: Math.round(r.left),
|
||||
y: Math.round(r.top),
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Core API (read-only) ----
|
||||
|
||||
const __AIRI_DG__ = {
|
||||
@@ -86,12 +148,22 @@
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title || '',
|
||||
// eslint-disable-next-line unicorn/prefer-dom-node-text-content -- intentional: innerText returns visible text only
|
||||
bodyText: includeText ? (document.body ? document.body.innerText || '' : '').slice(0, 3000) : '',
|
||||
frameName: window.name || '',
|
||||
frameOffsetInParent: _readFrameOffsetInParent(),
|
||||
bodyText: includeText ? (document.body?.textContent || '').slice(0, 3000) : '',
|
||||
interactiveElements: _collectInteractiveElements(maxElements),
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Describe direct child iframe/frame shells in the current document.
|
||||
*/
|
||||
collectChildFrames() {
|
||||
return {
|
||||
childFrames: _collectChildFrames(),
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Find a single element by CSS selector and describe it.
|
||||
*/
|
||||
@@ -130,10 +202,6 @@
|
||||
/**
|
||||
* Get the center point of an element for click targeting.
|
||||
* Returns the element description with center coordinates.
|
||||
*
|
||||
* Coordinates are exposed both at the top level (x, y) and under
|
||||
* `center` for backward compatibility. The extension bridge reads
|
||||
* top-level x/y via unwrapResultPayload.
|
||||
*/
|
||||
getClickTarget(selector) {
|
||||
try {
|
||||
@@ -141,16 +209,15 @@
|
||||
if (!el)
|
||||
return { success: false, error: 'not found' }
|
||||
const r = el.getBoundingClientRect()
|
||||
const x = Math.round(r.left + r.width / 2)
|
||||
const y = Math.round(r.top + r.height / 2)
|
||||
return {
|
||||
success: true,
|
||||
element: _describeElement(el),
|
||||
// Top-level x/y are read by extension-bridge.ts → clickSelector
|
||||
x,
|
||||
y,
|
||||
// Keep center for any callers that read it directly
|
||||
center: { x, y },
|
||||
x: Math.round(r.left + r.width / 2),
|
||||
y: Math.round(r.top + r.height / 2),
|
||||
center: {
|
||||
x: Math.round(r.left + r.width / 2),
|
||||
y: Math.round(r.top + r.height / 2),
|
||||
},
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
@@ -176,227 +243,12 @@
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the value of a text input or textarea via the DOM.
|
||||
* Dispatches input + change events so frameworks (React, Vue, etc.) detect
|
||||
* the change. Optionally blurs the element when done.
|
||||
*/
|
||||
setInputValue(selector, value, opts) {
|
||||
try {
|
||||
opts = opts || {}
|
||||
// TODO: opts.simulateKeystrokes is accepted but ignored — we always do
|
||||
// a single direct value assignment. Implement per-character KeyboardEvent
|
||||
// dispatch for autocomplete/masker/validation flows that depend on keydown/keyup.
|
||||
const el = document.querySelector(selector)
|
||||
if (!el)
|
||||
return { success: false, error: 'not found' }
|
||||
// NOTICE: must pick the setter matching the element's prototype —
|
||||
// calling HTMLInputElement.prototype.value.set on a <textarea> (or
|
||||
// vice-versa) throws "Illegal invocation" in Chromium.
|
||||
const proto = el instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(proto, 'value')
|
||||
if (nativeInputValueSetter && nativeInputValueSetter.set) {
|
||||
nativeInputValueSetter.set.call(el, value)
|
||||
}
|
||||
else {
|
||||
el.value = value
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
if (opts.blur)
|
||||
el.blur()
|
||||
return { success: true }
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check or uncheck a native checkbox or radio input.
|
||||
* Sets checked programmatically and dispatches a change event so framework
|
||||
* bindings (React onChange, Vue @change) pick up the update.
|
||||
*
|
||||
* NOTICE: only works on real <input type="checkbox|radio"> elements.
|
||||
* Custom ARIA checkboxes (e.g. <div role="checkbox">) do not have a native
|
||||
* .checked property — writing to it just adds an expando attribute and
|
||||
* changes nothing visible. Return success:false in that case so the caller
|
||||
* falls back to an OS-level click.
|
||||
*
|
||||
* NOTICE: we do NOT dispatch a fake click event — the browser's true event
|
||||
* order is click→change, and a synthetic click after we've already set
|
||||
* el.checked can cause React controlled-component handlers to toggle back.
|
||||
*/
|
||||
checkCheckbox(selector, checked) {
|
||||
try {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el)
|
||||
return { success: false, error: 'not found' }
|
||||
// Guard: only native checkbox/radio inputs have a meaningful .checked
|
||||
if (!(el instanceof HTMLInputElement) || (el.type !== 'checkbox' && el.type !== 'radio'))
|
||||
return { success: false, error: 'not a native checkbox or radio input' }
|
||||
const target = checked !== undefined ? !!checked : !el.checked
|
||||
if (el.checked !== target) {
|
||||
el.checked = target
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
return { success: true, checked: el.checked }
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Select an option in a <select> element by value.
|
||||
* Dispatches change event so framework bindings update.
|
||||
*/
|
||||
selectOption(selector, value) {
|
||||
try {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el)
|
||||
return { success: false, error: 'not found' }
|
||||
el.value = value
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
return { success: true, selectedValue: el.value }
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the current value of an input, textarea, or select element.
|
||||
*/
|
||||
readInputValue(selector) {
|
||||
try {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el)
|
||||
return { success: false, error: 'not found' }
|
||||
return { success: true, value: el.value, tagName: el.tagName.toLowerCase() }
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get computed CSS styles for an element.
|
||||
* If properties is a non-empty array, only those properties are returned.
|
||||
* Otherwise all computed styles are returned.
|
||||
*/
|
||||
getComputedStyles(selector, properties) {
|
||||
try {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el)
|
||||
return { success: false, error: 'not found' }
|
||||
const computed = window.getComputedStyle(el)
|
||||
const styles = {}
|
||||
if (Array.isArray(properties) && properties.length > 0) {
|
||||
for (const prop of properties) {
|
||||
styles[prop] = computed.getPropertyValue(prop)
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Return a small useful subset to avoid serializing 300+ properties
|
||||
const useful = ['display', 'visibility', 'opacity', 'position', 'width', 'height', 'color', 'background-color', 'font-size', 'overflow', 'pointer-events', 'z-index', 'cursor']
|
||||
for (const prop of useful) {
|
||||
styles[prop] = computed.getPropertyValue(prop)
|
||||
}
|
||||
}
|
||||
return { success: true, styles }
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispatch a DOM event on the element matching the selector.
|
||||
* opts.type overrides the Event constructor (default: 'Event').
|
||||
*/
|
||||
triggerEvent(selector, eventName, opts) {
|
||||
try {
|
||||
opts = opts || {}
|
||||
const el = document.querySelector(selector)
|
||||
if (!el)
|
||||
return { success: false, error: 'not found' }
|
||||
const EventCtor = opts.type === 'MouseEvent'
|
||||
? MouseEvent
|
||||
: opts.type === 'KeyboardEvent'
|
||||
? KeyboardEvent
|
||||
: opts.type === 'FocusEvent'
|
||||
? FocusEvent
|
||||
: Event
|
||||
const eventOpts = { bubbles: true, cancelable: true, ...opts }
|
||||
delete eventOpts.type
|
||||
el.dispatchEvent(new EventCtor(eventName, eventOpts))
|
||||
return { success: true }
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Wait for an element matching the selector to appear in the DOM.
|
||||
* Returns a promise. The message handler awaits it.
|
||||
*/
|
||||
waitForElement(selector, timeoutMs) {
|
||||
timeoutMs = timeoutMs || 5000
|
||||
const existing = document.querySelector(selector)
|
||||
if (existing)
|
||||
return { success: true, found: true }
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timer
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.querySelector(selector)) {
|
||||
observer.disconnect()
|
||||
clearTimeout(timer)
|
||||
resolve({ success: true, found: true })
|
||||
}
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
timer = setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve({ success: false, error: 'timeout' })
|
||||
}, timeoutMs)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispatch a click event at viewport coordinates (x, y).
|
||||
* Used by clickSelector as the final step after getClickTarget resolves
|
||||
* the element center.
|
||||
*/
|
||||
clickAt(x, y) {
|
||||
try {
|
||||
const el = document.elementFromPoint(x, y)
|
||||
if (!el)
|
||||
return { success: false, error: 'no element at point' }
|
||||
el.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
}))
|
||||
return { success: true, tagName: el.tagName.toLowerCase() }
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
window.__AIRI_DG__ = __AIRI_DG__
|
||||
|
||||
// ---- Message handler: ISOLATED world bridge → MAIN world ----
|
||||
// NOTICE: handler is async-aware so waitForElement (returns Promise) works.
|
||||
window.addEventListener('message', async (evt) => {
|
||||
window.addEventListener('message', (evt) => {
|
||||
if (evt.source !== window)
|
||||
return
|
||||
const data = evt.data
|
||||
@@ -409,13 +261,7 @@
|
||||
|
||||
if (typeof fn === 'function') {
|
||||
try {
|
||||
const ret = fn.apply(__AIRI_DG__, args || [])
|
||||
// Support async methods (e.g. waitForElement)
|
||||
// NOTICE: return the method result directly — each method already
|
||||
// returns its own { success, data/error } shape. Wrapping it again
|
||||
// as { success: true, data: <result> } created a double-envelope
|
||||
// that made transport-level success hide DOM-level failures.
|
||||
result = ret && typeof ret.then === 'function' ? await ret : ret
|
||||
result = { success: true, data: fn.apply(__AIRI_DG__, args || []) }
|
||||
}
|
||||
catch (e) {
|
||||
result = { success: false, error: e.message || String(e) }
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* - window.__AIRI_DG__ lives in the MAIN world (needs real DOM access)
|
||||
* - The two worlds communicate via window.postMessage
|
||||
*
|
||||
* Adapted from the upstream computer-use chrome-extension.
|
||||
* Adapted from an earlier Chrome extension message bridge.
|
||||
* No functional changes — this is a pure relay.
|
||||
*/
|
||||
(function () {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { env } from 'node:process'
|
||||
import { createInterface, exit, stdin, stdout } from 'node:readline'
|
||||
import { env, exit, stdin, stdout } from 'node:process'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
// TODO(@nekomeowww): try now to directly embed binary / base64, even tests. `xz` warned us.
|
||||
const tinyPngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wn8vO0AAAAASUVORK5CYII='
|
||||
|
||||
@@ -61,26 +61,21 @@ function checkBrowserDomPreconditions(
|
||||
/**
|
||||
* Decide whether a click on a candidate should go through browser-dom
|
||||
* bridge or OS-level input. Also handles checkbox toggling via checkCheckbox.
|
||||
*
|
||||
* Non-left-button clicks and multi-click requests are not supported by the
|
||||
* browser-dom bridge and will always be routed to os_input.
|
||||
*/
|
||||
export function decideBrowserAction(
|
||||
candidate: DesktopTargetCandidate,
|
||||
bridgeAvailable: boolean,
|
||||
actionButton: 'left' | 'right' | 'middle' = 'left',
|
||||
clickCount: number = 1,
|
||||
clickCount = 1,
|
||||
): BrowserActionDecision {
|
||||
const rejection = checkBrowserDomPreconditions(candidate, bridgeAvailable)
|
||||
if (rejection)
|
||||
return rejection
|
||||
|
||||
// Right-click and multi-click are not supported by the browser-dom bridge;
|
||||
// fall through to OS input so the caller's arguments are honoured.
|
||||
if (actionButton !== 'left' || clickCount !== 1) {
|
||||
return {
|
||||
route: 'os_input',
|
||||
reason: `browser-dom click only supports left single-click; got button='${actionButton}' clickCount=${clickCount}`,
|
||||
reason: `browser-dom click routing only supports left single-click, got ${actionButton} with count ${clickCount}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,10 +154,9 @@ function isTextInputCandidate(candidate: DesktopTargetCandidate): boolean {
|
||||
const inputType = candidate.inputType?.toLowerCase() || 'text'
|
||||
return TEXT_INPUT_TYPES.has(inputType)
|
||||
}
|
||||
// NOTICE: contenteditable elements are surfaced with role="textbox" but lack
|
||||
// a native .value property, so setInputValue (which uses input/textarea value
|
||||
// setters) silently fails on them. Only route actual <input>/<textarea> here;
|
||||
// contenteditable targets will fall through to OS typing via desktop_type_text.
|
||||
// contenteditable elements surfaced with role="textbox"
|
||||
if (candidate.role === 'textbox')
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface BrowserDomCapabilitySource {
|
||||
getStatus: () => { connected: boolean }
|
||||
supportsAction?: (action: string) => boolean
|
||||
}
|
||||
|
||||
export function isBrowserDomActionSupported(
|
||||
bridge: BrowserDomCapabilitySource,
|
||||
...actions: string[]
|
||||
) {
|
||||
if (!bridge.getStatus().connected)
|
||||
return false
|
||||
|
||||
return actions.every(action => bridge.supportsAction?.(action) ?? true)
|
||||
}
|
||||
|
||||
export function getUnsupportedBrowserDomActions(
|
||||
bridge: BrowserDomCapabilitySource,
|
||||
...actions: string[]
|
||||
) {
|
||||
if (!bridge.getStatus().connected)
|
||||
return [...actions]
|
||||
|
||||
return actions.filter(action => !(bridge.supportsAction?.(action) ?? true))
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { WebSocket } from 'ws'
|
||||
import { WebSocket, WebSocketServer } from 'ws'
|
||||
|
||||
import { BrowserDomExtensionBridge } from './extension-bridge'
|
||||
|
||||
describe('browserDomExtensionBridge', () => {
|
||||
let bridge: BrowserDomExtensionBridge | undefined
|
||||
let client: WebSocket | undefined
|
||||
let blocker: WebSocketServer | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
client?.close()
|
||||
client = undefined
|
||||
await new Promise<void>((resolve) => {
|
||||
blocker?.close(() => resolve())
|
||||
if (!blocker)
|
||||
resolve()
|
||||
})
|
||||
blocker = undefined
|
||||
await bridge?.close()
|
||||
bridge = undefined
|
||||
})
|
||||
@@ -64,4 +71,185 @@ describe('browserDomExtensionBridge', () => {
|
||||
expect(bridge.getStatus().connected).toBe(true)
|
||||
expect(bridge.getStatus().lastHello?.source).toBe('test-extension')
|
||||
})
|
||||
|
||||
it('rejects clickSelector on the read-only extension transport even when getClickTarget succeeds', async () => {
|
||||
bridge = new BrowserDomExtensionBridge({
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
requestTimeoutMs: 1_000,
|
||||
})
|
||||
await bridge.start()
|
||||
|
||||
const status = bridge.getStatus()
|
||||
client = new WebSocket(`ws://${status.host}:${status.port}`)
|
||||
|
||||
client.on('message', (raw) => {
|
||||
const data = JSON.parse(String(raw)) as Record<string, unknown>
|
||||
if (typeof data.id !== 'string')
|
||||
return
|
||||
|
||||
if (data.action === 'getClickTarget') {
|
||||
client!.send(JSON.stringify({
|
||||
id: data.id,
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
frameId: 5,
|
||||
result: {
|
||||
success: true,
|
||||
x: 321,
|
||||
y: 182,
|
||||
element: {
|
||||
tag: 'button',
|
||||
text: 'Submit',
|
||||
},
|
||||
center: {
|
||||
x: 321,
|
||||
y: 182,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (data.action === 'clickAt') {
|
||||
client!.send(JSON.stringify({
|
||||
id: data.id,
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
frameId: 5,
|
||||
result: {
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client!.once('open', () => {
|
||||
client!.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
source: 'test-extension',
|
||||
version: 'bridge-test',
|
||||
}))
|
||||
resolve()
|
||||
})
|
||||
client!.once('error', reject)
|
||||
})
|
||||
|
||||
await expect(bridge.clickSelector({
|
||||
selector: '#submit',
|
||||
frameIds: [5],
|
||||
})).rejects.toThrow('does not support action "clickAt"')
|
||||
})
|
||||
|
||||
it('rejects unsupported DOM-mutating actions before sending them to the extension transport', async () => {
|
||||
bridge = new BrowserDomExtensionBridge({
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
requestTimeoutMs: 1_000,
|
||||
})
|
||||
await bridge.start()
|
||||
|
||||
expect(bridge.supportsAction('readAllFramesDOM')).toBe(true)
|
||||
expect(bridge.supportsAction('setInputValue')).toBe(false)
|
||||
await expect(bridge.setInputValue({
|
||||
selector: '#email',
|
||||
value: 'hello@example.com',
|
||||
})).rejects.toThrow('does not support action "setInputValue"')
|
||||
})
|
||||
|
||||
it('can retry startup after an initial bind failure', async () => {
|
||||
blocker = new WebSocketServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker!.once('listening', () => resolve())
|
||||
blocker!.once('error', reject)
|
||||
})
|
||||
|
||||
const blockedPort = (blocker.address() as { port: number }).port
|
||||
|
||||
bridge = new BrowserDomExtensionBridge({
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: blockedPort,
|
||||
requestTimeoutMs: 1_000,
|
||||
})
|
||||
|
||||
await bridge.start()
|
||||
expect(bridge.getStatus().lastError).toBeTruthy()
|
||||
|
||||
await new Promise<void>(resolve => blocker!.close(() => resolve()))
|
||||
blocker = undefined
|
||||
|
||||
await bridge.start()
|
||||
|
||||
const status = bridge.getStatus()
|
||||
expect(status.lastError).toBeUndefined()
|
||||
|
||||
client = new WebSocket(`ws://${status.host}:${status.port}`)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client!.once('open', resolve)
|
||||
client!.once('error', reject)
|
||||
})
|
||||
|
||||
expect(bridge.getStatus().connected).toBe(true)
|
||||
|
||||
client.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
source: 'test-extension',
|
||||
version: 'bridge-test',
|
||||
}))
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(bridge.getStatus().connected).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects in-flight requests immediately when the socket disconnects', async () => {
|
||||
bridge = new BrowserDomExtensionBridge({
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
requestTimeoutMs: 10_000,
|
||||
})
|
||||
await bridge.start()
|
||||
|
||||
const status = bridge.getStatus()
|
||||
client = new WebSocket(`ws://${status.host}:${status.port}`)
|
||||
|
||||
client.on('message', (raw) => {
|
||||
const data = JSON.parse(String(raw)) as Record<string, unknown>
|
||||
if (data.action === 'getActiveTab') {
|
||||
client!.close()
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client!.once('open', () => {
|
||||
client!.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
source: 'test-extension',
|
||||
version: 'bridge-test',
|
||||
}))
|
||||
resolve()
|
||||
})
|
||||
client!.once('error', reject)
|
||||
})
|
||||
|
||||
const startedAt = Date.now()
|
||||
|
||||
await expect(bridge.getActiveTab()).rejects.toThrow('browser dom bridge disconnected before completing pending request')
|
||||
expect(Date.now() - startedAt).toBeLessThan(1_000)
|
||||
expect(bridge.getStatus().pendingRequests).toBe(0)
|
||||
expect(bridge.getStatus().connected).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,16 @@ import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { WebSocket, WebSocketServer } from 'ws'
|
||||
|
||||
const SUPPORTED_ACTIONS = new Set([
|
||||
'getActiveTab',
|
||||
'getAllFrames',
|
||||
'readAllFramesDOM',
|
||||
'findElement',
|
||||
'findElements',
|
||||
'getClickTarget',
|
||||
'getElementAttributes',
|
||||
])
|
||||
|
||||
interface PendingBridgeRequest {
|
||||
reject: (error: Error) => void
|
||||
resolve: (value: unknown) => void
|
||||
@@ -59,6 +69,16 @@ export class BrowserDomExtensionBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private rejectPendingRequests(error: Error) {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timeoutId)
|
||||
pending.reject(error)
|
||||
}
|
||||
|
||||
this.pending.clear()
|
||||
this.status.pendingRequests = 0
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (!this.config.enabled || this.started)
|
||||
return
|
||||
@@ -97,6 +117,7 @@ export class BrowserDomExtensionBridge {
|
||||
this.status.host = (address as AddressInfo).address
|
||||
this.status.port = (address as AddressInfo).port
|
||||
}
|
||||
this.status.lastError = undefined
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
if (this.socket && this.socket !== socket) {
|
||||
@@ -112,6 +133,7 @@ export class BrowserDomExtensionBridge {
|
||||
if (this.socket === socket) {
|
||||
this.socket = undefined
|
||||
this.status.connected = false
|
||||
this.rejectPendingRequests(new Error('browser dom bridge disconnected before completing pending request'))
|
||||
}
|
||||
})
|
||||
socket.on('error', (error) => {
|
||||
@@ -124,17 +146,13 @@ export class BrowserDomExtensionBridge {
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
this.started = false
|
||||
this.status.lastError = asError(error, 'failed to start browser dom bridge').message
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
for (const [requestId, pending] of this.pending.entries()) {
|
||||
clearTimeout(pending.timeoutId)
|
||||
pending.reject(new Error(`browser dom bridge closed before completing request ${requestId}`))
|
||||
}
|
||||
this.pending.clear()
|
||||
this.status.pendingRequests = 0
|
||||
this.rejectPendingRequests(new Error('browser dom bridge closed before completing pending request'))
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.close()
|
||||
@@ -163,6 +181,10 @@ export class BrowserDomExtensionBridge {
|
||||
throw new Error('browser dom bridge is disabled')
|
||||
}
|
||||
|
||||
if (!this.supportsAction(action)) {
|
||||
throw new Error(`browser dom bridge transport does not support action "${action}"`)
|
||||
}
|
||||
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error(this.status.lastError || 'browser dom bridge is not connected')
|
||||
}
|
||||
@@ -206,6 +228,10 @@ export class BrowserDomExtensionBridge {
|
||||
return result
|
||||
}
|
||||
|
||||
supportsAction(action: string) {
|
||||
return SUPPORTED_ACTIONS.has(action)
|
||||
}
|
||||
|
||||
async getActiveTab() {
|
||||
return await this.callAction<Record<string, unknown> | null>('getActiveTab')
|
||||
}
|
||||
|
||||
@@ -311,6 +311,27 @@ describe('chromeElementsToTargetCandidates', () => {
|
||||
expect(candidates[0].frameId).toBe(3)
|
||||
})
|
||||
|
||||
it('applies tagged frame offsets before converting to screen coordinates', () => {
|
||||
const taggedEl = {
|
||||
tag: 'button',
|
||||
text: 'Iframe CTA',
|
||||
rect: { x: 12, y: 24, w: 90, h: 32 },
|
||||
_frameId: 3,
|
||||
_frameOffsetX: 220,
|
||||
_frameOffsetY: 140,
|
||||
} as any
|
||||
|
||||
const candidates = chromeElementsToTargetCandidates(
|
||||
[taggedEl],
|
||||
windowBounds,
|
||||
88,
|
||||
0,
|
||||
)
|
||||
|
||||
expect(candidates[0].bounds.x).toBe(100 + 220 + 12)
|
||||
expect(candidates[0].bounds.y).toBe(50 + 88 + 140 + 24)
|
||||
})
|
||||
|
||||
it('falls back to function-level frameId when _frameId is absent', () => {
|
||||
const el = {
|
||||
tag: 'button',
|
||||
@@ -419,6 +440,17 @@ describe('captureChromeSemantics', () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
frameId: 5,
|
||||
result: {
|
||||
url: 'https://example.com/iframe',
|
||||
title: 'Iframe',
|
||||
frameOffset: { x: 320, y: 180 },
|
||||
interactiveElements: [
|
||||
{ tag: 'input', name: 'email', rect: { x: 16, y: 22, w: 140, h: 28 } },
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
}
|
||||
|
||||
@@ -426,7 +458,11 @@ describe('captureChromeSemantics', () => {
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.source).toBe('extension')
|
||||
expect(result!.pageUrl).toBe('https://example.com')
|
||||
expect(result!.interactiveElements).toHaveLength(1)
|
||||
expect(result!.interactiveElements).toHaveLength(2)
|
||||
const iframeElement = result!.interactiveElements[1] as Record<string, unknown>
|
||||
expect(iframeElement._frameId).toBe(5)
|
||||
expect(iframeElement._frameOffsetX).toBe(320)
|
||||
expect(iframeElement._frameOffsetY).toBe(180)
|
||||
})
|
||||
|
||||
it('falls back to CDP when extension is disconnected', async () => {
|
||||
|
||||
@@ -110,11 +110,13 @@ export function chromeElementsToTargetCandidates(
|
||||
// Read per-element frame ID if tagged by captureViaExtension,
|
||||
// otherwise fall back to the function parameter
|
||||
const elFrameId = (el as Record<string, unknown>)._frameId as number | undefined
|
||||
const elFrameOffsetX = (el as Record<string, unknown>)._frameOffsetX as number | undefined
|
||||
const elFrameOffsetY = (el as Record<string, unknown>)._frameOffsetY as number | undefined
|
||||
|
||||
// Convert page-relative rect to screen-absolute bounds
|
||||
const bounds: Bounds = {
|
||||
x: viewportOffsetX + el.rect.x,
|
||||
y: viewportOffsetY + el.rect.y,
|
||||
x: viewportOffsetX + (elFrameOffsetX ?? 0) + el.rect.x,
|
||||
y: viewportOffsetY + (elFrameOffsetY ?? 0) + el.rect.y,
|
||||
width: el.rect.w,
|
||||
height: el.rect.h,
|
||||
}
|
||||
@@ -183,11 +185,28 @@ async function captureViaExtension(
|
||||
|
||||
const rawElements = dom.interactiveElements
|
||||
?? (dom.data && typeof dom.data === 'object' && (dom.data as Record<string, unknown>).interactiveElements)
|
||||
const rawFrameOffset = dom.frameOffset
|
||||
?? (dom.data && typeof dom.data === 'object' && (dom.data as Record<string, unknown>).frameOffset)
|
||||
const frameOffset = (
|
||||
rawFrameOffset
|
||||
&& typeof rawFrameOffset === 'object'
|
||||
&& typeof (rawFrameOffset as Record<string, unknown>).x === 'number'
|
||||
&& typeof (rawFrameOffset as Record<string, unknown>).y === 'number'
|
||||
)
|
||||
? {
|
||||
x: (rawFrameOffset as Record<string, unknown>).x as number,
|
||||
y: (rawFrameOffset as Record<string, unknown>).y as number,
|
||||
}
|
||||
: undefined
|
||||
const elements = rawElements as BrowserDomInteractiveElement[] | undefined
|
||||
if (elements) {
|
||||
// Tag each element with its frame ID for downstream routing
|
||||
for (const el of elements) {
|
||||
allElements.push({ ...el, _frameId: frame.frameId })
|
||||
allElements.push({
|
||||
...el,
|
||||
_frameId: frame.frameId,
|
||||
...(frameOffset ? { _frameOffsetX: frameOffset.x, _frameOffsetY: frameOffset.y } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Tests for ChromeSessionManager.
|
||||
*
|
||||
* All macOS shell interactions are mocked via `runProcess` to test
|
||||
* the logic without requiring a real Chrome instance.
|
||||
*/
|
||||
|
||||
import type { ChromeSessionManager } from './chrome-session-manager'
|
||||
import type { ComputerUseConfig } from './types'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createChromeSessionManager } from './chrome-session-manager'
|
||||
import { runProcess } from './utils/process'
|
||||
|
||||
// Mock runProcess and sleep before importing the module under test
|
||||
vi.mock('./utils/process', () => ({
|
||||
runProcess: vi.fn(),
|
||||
}))
|
||||
vi.mock('./utils/sleep', () => ({
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
const mockedRunProcess = vi.mocked(runProcess)
|
||||
|
||||
function makeConfig(): ComputerUseConfig {
|
||||
return {
|
||||
executor: 'macos-local',
|
||||
sessionTag: 'test',
|
||||
sessionRoot: '/tmp/test',
|
||||
screenshotsDir: '/tmp/test/screenshots',
|
||||
timeoutMs: 5000,
|
||||
approvalMode: 'never',
|
||||
binaries: {
|
||||
swift: '/usr/bin/swift',
|
||||
screencapture: '/usr/sbin/screencapture',
|
||||
open: '/usr/bin/open',
|
||||
osascript: '/usr/bin/osascript',
|
||||
},
|
||||
browserDomBridge: { enabled: false },
|
||||
openableApps: [],
|
||||
} as ComputerUseConfig
|
||||
}
|
||||
|
||||
function ok(stdout = ''): any {
|
||||
return { stdout, stderr: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock for "chrome not running → launch" flow (first call, no existing session).
|
||||
*
|
||||
* Call sequence when `session` is null:
|
||||
* 1. osascript (getCurrentForegroundApp)
|
||||
* 2. pgrep (isChromeRunning for wasAlreadyRunning) → reject (not running)
|
||||
* 3. open (launchChromeWithCdp)
|
||||
* 4. osascript (activateChrome)
|
||||
* 5. pgrep (getChromeMainPid) → ok with pid
|
||||
*/
|
||||
function mockLaunchFlow(pid: number, userApp = '') {
|
||||
mockedRunProcess
|
||||
.mockResolvedValueOnce(ok(userApp)) // 1. getCurrentForegroundApp
|
||||
.mockRejectedValueOnce(new Error('no match')) // 2. isChromeRunning → false
|
||||
.mockResolvedValueOnce(ok()) // 3. open command
|
||||
.mockResolvedValueOnce(ok()) // 4. activateChrome
|
||||
.mockResolvedValueOnce(ok(`${pid}\n`)) // 5. getChromeMainPid
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock for "chrome already running → new window" flow (first call, no existing session).
|
||||
*
|
||||
* Call sequence when `session` is null:
|
||||
* 1. osascript (getCurrentForegroundApp)
|
||||
* 2. pgrep (isChromeRunning for wasAlreadyRunning) → ok (running)
|
||||
* 3. osascript (createNewWindow)
|
||||
* 4. osascript (activateChrome)
|
||||
* 5. pgrep (getChromeMainPid)
|
||||
*/
|
||||
function mockJoinFlow(pid: number, userApp = 'Terminal') {
|
||||
mockedRunProcess
|
||||
.mockResolvedValueOnce(ok(userApp)) // 1. getCurrentForegroundApp
|
||||
.mockResolvedValueOnce(ok(`${pid}\n`)) // 2. isChromeRunning → true
|
||||
.mockResolvedValueOnce(ok()) // 3. createNewWindow
|
||||
.mockResolvedValueOnce(ok()) // 4. activateChrome
|
||||
.mockResolvedValueOnce(ok(`${pid}\n`)) // 5. getChromeMainPid
|
||||
}
|
||||
|
||||
describe('chromeSessionManager', () => {
|
||||
let manager: ChromeSessionManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
manager = createChromeSessionManager(makeConfig())
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ensureAgentWindow
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('ensureAgentWindow', () => {
|
||||
it('should launch Chrome when not running', async () => {
|
||||
mockLaunchFlow(12345)
|
||||
|
||||
const info = await manager.ensureAgentWindow()
|
||||
|
||||
expect(info.wasAlreadyRunning).toBe(false)
|
||||
expect(info.agentOwned).toBe(true)
|
||||
expect(info.pid).toBe(12345)
|
||||
expect(info.cdpUrl).toBe('http://127.0.0.1:9222')
|
||||
expect(info.windowId).toBe('12345:0:Google Chrome')
|
||||
expect(info.createdAt).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should create new window when Chrome is already running', async () => {
|
||||
mockJoinFlow(99999, 'Terminal')
|
||||
|
||||
const info = await manager.ensureAgentWindow()
|
||||
|
||||
expect(info.wasAlreadyRunning).toBe(true)
|
||||
expect(info.agentOwned).toBe(false)
|
||||
expect(info.pid).toBe(99999)
|
||||
// No CDP URL when joining existing Chrome
|
||||
expect(info.cdpUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should reuse an existing session when the agent launched a dedicated Chrome instance', async () => {
|
||||
mockLaunchFlow(11111)
|
||||
const first = await manager.ensureAgentWindow()
|
||||
|
||||
// Second call: session exists → isChromeRunning check (1 call)
|
||||
mockedRunProcess.mockResolvedValueOnce(ok('11111\n')) // isChromeRunning → still alive
|
||||
const second = await manager.ensureAgentWindow()
|
||||
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('should create a fresh agent window on repeated calls when joining an existing Chrome instance', async () => {
|
||||
mockJoinFlow(99999, 'Terminal')
|
||||
const first = await manager.ensureAgentWindow()
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockedRunProcess
|
||||
.mockResolvedValueOnce(ok('99999\n')) // session reuse check → Chrome still running
|
||||
mockJoinFlow(99999, 'Terminal')
|
||||
|
||||
const second = await manager.ensureAgentWindow()
|
||||
|
||||
expect(second).not.toBe(first)
|
||||
expect(second.wasAlreadyRunning).toBe(true)
|
||||
expect(second.pid).toBe(99999)
|
||||
expect(mockedRunProcess.mock.calls[3]?.[0]).toBe('/usr/bin/osascript')
|
||||
expect(mockedRunProcess.mock.calls[3]?.[1]).toEqual(['-e', 'tell application "Google Chrome" to make new window'])
|
||||
})
|
||||
|
||||
it('should recreate session if Chrome crashed between calls', async () => {
|
||||
mockLaunchFlow(11111)
|
||||
const first = await manager.ensureAgentWindow()
|
||||
expect(first.pid).toBe(11111)
|
||||
|
||||
// Second call: session exists → isChromeRunning fails (Chrome crashed)
|
||||
mockedRunProcess.mockRejectedValueOnce(new Error('no match'))
|
||||
// session=null now, goes through full launch flow (5 calls)
|
||||
mockLaunchFlow(22222)
|
||||
|
||||
const second = await manager.ensureAgentWindow()
|
||||
expect(second.pid).toBe(22222)
|
||||
expect(second).not.toBe(first)
|
||||
})
|
||||
|
||||
it('should pass custom URL to Chrome', async () => {
|
||||
mockLaunchFlow(33333)
|
||||
|
||||
const info = await manager.ensureAgentWindow({ url: 'https://example.com' })
|
||||
expect(info.initialUrl).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it('should pass URL to osascript as argv instead of interpolating it into script source', async () => {
|
||||
const maliciousUrl = 'https://example.com/" & do shell script "touch /tmp/pwned" & "'
|
||||
mockJoinFlow(33333, 'Terminal')
|
||||
|
||||
await manager.ensureAgentWindow({ url: maliciousUrl })
|
||||
|
||||
const createWindowCall = mockedRunProcess.mock.calls[2]
|
||||
expect(createWindowCall?.[0]).toBe('/usr/bin/osascript')
|
||||
expect(createWindowCall?.[1]).toEqual([
|
||||
'-e',
|
||||
expect.stringContaining('item 1 of argv'),
|
||||
'--',
|
||||
maliciousUrl,
|
||||
])
|
||||
expect(createWindowCall?.[1]?.[1]).not.toContain(maliciousUrl)
|
||||
})
|
||||
|
||||
it('should use custom CDP port', async () => {
|
||||
mockLaunchFlow(44444)
|
||||
|
||||
const info = await manager.ensureAgentWindow({ cdpPort: 9333 })
|
||||
expect(info.cdpUrl).toBe('http://127.0.0.1:9333')
|
||||
})
|
||||
|
||||
it('should throw if Chrome PID cannot be obtained after launch', async () => {
|
||||
mockedRunProcess
|
||||
.mockResolvedValueOnce(ok()) // 1. foreground
|
||||
.mockRejectedValueOnce(new Error('no match')) // 2. wasAlreadyRunning → false
|
||||
.mockResolvedValueOnce(ok()) // 3. launch
|
||||
.mockResolvedValueOnce(ok()) // 4. activate
|
||||
.mockRejectedValueOnce(new Error('no match')) // 5. getChromeMainPid → fails
|
||||
|
||||
await expect(manager.ensureAgentWindow()).rejects.toThrow('Failed to get Chrome PID')
|
||||
})
|
||||
|
||||
it('should record the user\'s previous foreground app', async () => {
|
||||
mockLaunchFlow(55555, 'Finder')
|
||||
|
||||
await manager.ensureAgentWindow()
|
||||
|
||||
// First call should have been getCurrentForegroundApp
|
||||
const firstCall = mockedRunProcess.mock.calls[0]
|
||||
expect(firstCall[1]).toContainEqual(expect.stringContaining('first application process'))
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// bringToFront
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('bringToFront', () => {
|
||||
it('should activate Chrome when session exists', async () => {
|
||||
mockLaunchFlow(11111)
|
||||
await manager.ensureAgentWindow()
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockedRunProcess
|
||||
.mockResolvedValueOnce(ok('11111\n')) // isChromeRunning
|
||||
.mockResolvedValueOnce(ok()) // activateChrome
|
||||
|
||||
const result = await manager.bringToFront()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockedRunProcess).toHaveBeenCalledTimes(2)
|
||||
const activateCall = mockedRunProcess.mock.calls[1]
|
||||
expect(activateCall[1]).toEqual(['-e', 'tell application "Google Chrome" to activate'])
|
||||
})
|
||||
|
||||
it('should be no-op when no session exists', async () => {
|
||||
const result = await manager.bringToFront()
|
||||
expect(result).toBe(false)
|
||||
expect(mockedRunProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear session if Chrome crashed', async () => {
|
||||
mockLaunchFlow(11111)
|
||||
await manager.ensureAgentWindow()
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Chrome crashed
|
||||
mockedRunProcess.mockRejectedValueOnce(new Error('no match'))
|
||||
|
||||
const result = await manager.bringToFront()
|
||||
expect(result).toBe(false)
|
||||
expect(manager.getSessionInfo()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// restorePreviousForeground
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('restorePreviousForeground', () => {
|
||||
it('should activate the user\'s previous app', async () => {
|
||||
mockLaunchFlow(11111, 'Terminal')
|
||||
await manager.ensureAgentWindow()
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockedRunProcess.mockResolvedValueOnce(ok())
|
||||
|
||||
await manager.restorePreviousForeground()
|
||||
|
||||
expect(mockedRunProcess).toHaveBeenCalledWith(
|
||||
'/usr/bin/osascript',
|
||||
['-e', 'tell application "Terminal" to activate'],
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should be no-op if user was already in Chrome', async () => {
|
||||
mockLaunchFlow(11111, 'Google Chrome')
|
||||
await manager.ensureAgentWindow()
|
||||
vi.clearAllMocks()
|
||||
|
||||
await manager.restorePreviousForeground()
|
||||
expect(mockedRunProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should be no-op if no session was created', async () => {
|
||||
await manager.restorePreviousForeground()
|
||||
expect(mockedRunProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// session lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('session lifecycle', () => {
|
||||
it('should return null before any session', () => {
|
||||
expect(manager.getSessionInfo()).toBeNull()
|
||||
})
|
||||
|
||||
it('should return session info after ensureAgentWindow', async () => {
|
||||
mockLaunchFlow(55555)
|
||||
|
||||
await manager.ensureAgentWindow()
|
||||
const info = manager.getSessionInfo()
|
||||
expect(info).not.toBeNull()
|
||||
expect(info!.pid).toBe(55555)
|
||||
expect(info!.agentOwned).toBe(true)
|
||||
})
|
||||
|
||||
it('should clear session on endSession', async () => {
|
||||
mockLaunchFlow(55555)
|
||||
await manager.ensureAgentWindow()
|
||||
expect(manager.getSessionInfo()).not.toBeNull()
|
||||
|
||||
manager.endSession()
|
||||
expect(manager.getSessionInfo()).toBeNull()
|
||||
})
|
||||
|
||||
it('should restore fully after endSession and re-ensureAgentWindow', async () => {
|
||||
mockLaunchFlow(11111)
|
||||
await manager.ensureAgentWindow()
|
||||
manager.endSession()
|
||||
|
||||
mockLaunchFlow(22222)
|
||||
const info = await manager.ensureAgentWindow()
|
||||
expect(info.pid).toBe(22222)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Chrome Session Manager — agent-owned Chrome window lifecycle.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Detect whether Chrome is already running
|
||||
* - Launch Chrome with CDP if not running
|
||||
* - Create a new window in existing Chrome
|
||||
* - Track window identity via PID
|
||||
* - Bring agent window to front / restore user's previous foreground
|
||||
*
|
||||
* macOS only. Uses AppleScript and `open` CLI for Chrome lifecycle control.
|
||||
*/
|
||||
|
||||
import type { ChromeSessionInfo, ComputerUseConfig } from './types'
|
||||
|
||||
import { runProcess } from './utils/process'
|
||||
import { sleep } from './utils/sleep'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CHROME_APP_NAME = 'Google Chrome'
|
||||
const DEFAULT_CDP_PORT = 9222
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ChromeSessionManager {
|
||||
/**
|
||||
* Ensure the agent has a usable Chrome window.
|
||||
*
|
||||
* - Chrome not running → launch with CDP flag + new window
|
||||
* - Chrome running → create new window in existing instance
|
||||
*
|
||||
* Reuses the cached session only when the agent launched a dedicated Chrome
|
||||
* instance itself. When joining an existing user-owned Chrome process, a
|
||||
* fresh window must be created on every ensure because this module does not
|
||||
* track a verifiable OS window handle for the agent tab/window.
|
||||
*/
|
||||
ensureAgentWindow: (options?: { url?: string, cdpPort?: number }) => Promise<ChromeSessionInfo>
|
||||
|
||||
/**
|
||||
* Bring the agent's Chrome window to the foreground.
|
||||
* Returns false if the tracked session is missing or Chrome is no longer running.
|
||||
*/
|
||||
bringToFront: () => Promise<boolean>
|
||||
|
||||
/**
|
||||
* Restore the user's previous foreground app (recorded at session start).
|
||||
*/
|
||||
restorePreviousForeground: () => Promise<void>
|
||||
|
||||
/**
|
||||
* Get the current session info (null if no session).
|
||||
*/
|
||||
getSessionInfo: () => ChromeSessionInfo | null
|
||||
|
||||
/**
|
||||
* End the session. Does NOT close Chrome — just clears the tracked state.
|
||||
*/
|
||||
endSession: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createChromeSessionManager(
|
||||
config: ComputerUseConfig,
|
||||
options?: { onSessionLost?: () => void },
|
||||
): ChromeSessionManager {
|
||||
let session: ChromeSessionInfo | null = null
|
||||
let previousForegroundApp: string | undefined
|
||||
const onSessionLost = options?.onSessionLost
|
||||
|
||||
// -- Helpers ------------------------------------------------------------
|
||||
|
||||
async function isChromeRunning(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await runProcess('pgrep', ['-x', 'Google Chrome'], {
|
||||
timeoutMs: config.timeoutMs,
|
||||
})
|
||||
return stdout.trim().length > 0
|
||||
}
|
||||
catch {
|
||||
// pgrep exits non-zero when no match
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function getChromeMainPid(): Promise<number | undefined> {
|
||||
try {
|
||||
const { stdout } = await runProcess('pgrep', ['-x', 'Google Chrome'], {
|
||||
timeoutMs: config.timeoutMs,
|
||||
})
|
||||
const pids = stdout.trim().split('\n').map(Number).filter(n => !Number.isNaN(n))
|
||||
// The lowest PID is typically the main Chrome process
|
||||
return pids.length > 0 ? Math.min(...pids) : undefined
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentForegroundApp(): Promise<string | undefined> {
|
||||
try {
|
||||
const { stdout } = await runProcess(config.binaries.osascript, [
|
||||
'-e',
|
||||
'tell application "System Events" to get name of first application process whose frontmost is true',
|
||||
], { timeoutMs: config.timeoutMs })
|
||||
return stdout.trim() || undefined
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function launchChromeWithCdp(cdpPort: number, url?: string): Promise<void> {
|
||||
const args = [
|
||||
'-na',
|
||||
CHROME_APP_NAME,
|
||||
'--args',
|
||||
'--new-window',
|
||||
`--remote-debugging-port=${cdpPort}`,
|
||||
]
|
||||
if (url) {
|
||||
args.push(url)
|
||||
}
|
||||
|
||||
await runProcess(config.binaries.open, args, {
|
||||
timeoutMs: config.timeoutMs,
|
||||
})
|
||||
|
||||
// Wait for Chrome to finish launching
|
||||
await sleep(2000)
|
||||
}
|
||||
|
||||
async function createNewWindow(url?: string): Promise<void> {
|
||||
// NOTICE: Pass the URL as an `osascript` argv value instead of interpolating
|
||||
// it into the AppleScript source. `desktop_ensure_chrome` accepts arbitrary
|
||||
// strings, so direct interpolation creates an AppleScript injection path when
|
||||
// the URL contains quotes. The review report on PR #1649 correctly called this
|
||||
// out as a P1 command-injection issue.
|
||||
const script = url
|
||||
? `on run argv
|
||||
tell application "${CHROME_APP_NAME}" to make new window with properties {mode:"normal"}
|
||||
tell application "${CHROME_APP_NAME}" to set URL of active tab of front window to item 1 of argv
|
||||
end run`
|
||||
: `tell application "${CHROME_APP_NAME}" to make new window`
|
||||
|
||||
const args = url
|
||||
? ['-e', script, '--', url]
|
||||
: ['-e', script]
|
||||
|
||||
await runProcess(config.binaries.osascript, args, {
|
||||
timeoutMs: config.timeoutMs,
|
||||
})
|
||||
|
||||
// Brief delay for the window to appear
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
async function activateChrome(): Promise<void> {
|
||||
await runProcess(config.binaries.osascript, [
|
||||
'-e',
|
||||
`tell application "${CHROME_APP_NAME}" to activate`,
|
||||
], { timeoutMs: config.timeoutMs })
|
||||
}
|
||||
|
||||
async function activateApp(appName: string): Promise<void> {
|
||||
try {
|
||||
await runProcess(config.binaries.osascript, [
|
||||
'-e',
|
||||
`tell application "${appName}" to activate`,
|
||||
], { timeoutMs: config.timeoutMs })
|
||||
}
|
||||
catch {
|
||||
// Best-effort: the app might have been closed
|
||||
}
|
||||
}
|
||||
|
||||
// -- Public API ---------------------------------------------------------
|
||||
|
||||
return {
|
||||
async ensureAgentWindow(options) {
|
||||
// If we already have a session, only reuse it when the agent launched a
|
||||
// dedicated Chrome instance. Joined sessions only cache process metadata,
|
||||
// not a verifiable OS window handle, so blindly reusing them after the
|
||||
// user closes that window would redirect later focus/click flows onto a
|
||||
// different Chrome window.
|
||||
if (session) {
|
||||
const stillRunning = await isChromeRunning()
|
||||
if (stillRunning && !session.wasAlreadyRunning) {
|
||||
return session
|
||||
}
|
||||
if (!stillRunning) {
|
||||
// Chrome died — clear stale session.
|
||||
onSessionLost?.()
|
||||
}
|
||||
session = null
|
||||
}
|
||||
|
||||
// Record the user's current foreground app before we steal focus
|
||||
previousForegroundApp = await getCurrentForegroundApp()
|
||||
|
||||
const cdpPort = options?.cdpPort ?? DEFAULT_CDP_PORT
|
||||
const wasAlreadyRunning = await isChromeRunning()
|
||||
|
||||
if (wasAlreadyRunning) {
|
||||
// Chrome is running — create a new window in the existing instance
|
||||
await createNewWindow(options?.url)
|
||||
}
|
||||
else {
|
||||
// Chrome not running — launch with CDP
|
||||
await launchChromeWithCdp(cdpPort, options?.url)
|
||||
}
|
||||
|
||||
// Bring Chrome to front
|
||||
await activateChrome()
|
||||
// Brief wait for activation
|
||||
await sleep(300)
|
||||
|
||||
// Get the Chrome PID
|
||||
const pid = await getChromeMainPid()
|
||||
if (!pid) {
|
||||
throw new Error('Failed to get Chrome PID after launch')
|
||||
}
|
||||
|
||||
session = {
|
||||
wasAlreadyRunning,
|
||||
windowId: `${pid}:0:${CHROME_APP_NAME}`,
|
||||
cdpUrl: wasAlreadyRunning ? undefined : `http://127.0.0.1:${cdpPort}`,
|
||||
pid,
|
||||
agentOwned: !wasAlreadyRunning,
|
||||
initialUrl: options?.url,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
return session
|
||||
},
|
||||
|
||||
async bringToFront() {
|
||||
if (!session)
|
||||
return false
|
||||
const stillRunning = await isChromeRunning()
|
||||
if (!stillRunning) {
|
||||
session = null
|
||||
onSessionLost?.()
|
||||
return false
|
||||
}
|
||||
await activateChrome()
|
||||
return true
|
||||
},
|
||||
|
||||
async restorePreviousForeground() {
|
||||
if (previousForegroundApp && previousForegroundApp !== CHROME_APP_NAME) {
|
||||
await activateApp(previousForegroundApp)
|
||||
}
|
||||
},
|
||||
|
||||
getSessionInfo() {
|
||||
return session
|
||||
},
|
||||
|
||||
endSession() {
|
||||
const hadSession = session !== null
|
||||
session = null
|
||||
previousForegroundApp = undefined
|
||||
if (hadSession) {
|
||||
onSessionLost?.()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,6 @@ export const TARGET_SOURCE_PRIORITY: readonly TargetSource[] = [
|
||||
'raw',
|
||||
] as const
|
||||
|
||||
/** Maximum snapshot age tolerated before `desktop_click_target` must refresh. */
|
||||
export const DESKTOP_CLICK_SNAPSHOT_MAX_AGE_MS = 5_000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target candidate
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -144,8 +141,6 @@ export interface DesktopGroundingSnapshot {
|
||||
capturedAt: string
|
||||
/** Name of the foreground application */
|
||||
foregroundApp: string
|
||||
/** Title of the foreground window when available */
|
||||
foregroundWindowTitle?: string
|
||||
/** Current window list */
|
||||
windows: WindowInfo[]
|
||||
/** Latest screenshot artifact */
|
||||
@@ -206,4 +201,12 @@ export interface PointerIntent {
|
||||
confidence: number
|
||||
/** Pointer animation path for overlay visualization */
|
||||
path: PointerTracePoint[]
|
||||
|
||||
// ---- Ghost pointer execution phases (v3) ----
|
||||
/** Execution lifecycle phase for ghost pointer animation. */
|
||||
phase?: 'preview' | 'executing' | 'completed'
|
||||
/** Outcome of the execution (set when phase = 'completed'). */
|
||||
executionResult?: 'success' | 'fallback' | 'error'
|
||||
/** Human-readable description of the execution route taken. */
|
||||
executionRoute?: string
|
||||
}
|
||||
|
||||
@@ -320,18 +320,10 @@ function isChromeApp(appName: string): boolean {
|
||||
|
||||
function findChromeWindowBounds(
|
||||
observation: WindowObservation,
|
||||
foregroundApp: string,
|
||||
_foregroundApp: string,
|
||||
): Bounds | undefined {
|
||||
const normalizedFg = foregroundApp.trim().toLowerCase().replace(APP_SUFFIX_RE, '')
|
||||
// Prefer exact match on the foreground app name
|
||||
const exactMatch = observation.windows.find(w =>
|
||||
w.appName.trim().toLowerCase().replace(APP_SUFFIX_RE, '') === normalizedFg && w.bounds,
|
||||
)
|
||||
if (exactMatch?.bounds)
|
||||
return exactMatch.bounds
|
||||
// Fallback: any Chrome-like window
|
||||
const chromeWindow = observation.windows.find(w =>
|
||||
isChromeApp(w.appName) && w.bounds,
|
||||
w.appName.toLowerCase().includes('chrome') && w.bounds,
|
||||
)
|
||||
return chromeWindow?.bounds
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Tests for DesktopSessionController.
|
||||
*
|
||||
* Pure in-memory tests — no OS calls. The session controller delegates
|
||||
* foreground management to callbacks, which we mock here.
|
||||
*/
|
||||
|
||||
import type { ChromeSessionManager } from './chrome-session-manager'
|
||||
import type { DesktopSessionController } from './desktop-session'
|
||||
import type { ForegroundContext } from './types'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createDesktopSessionController } from './desktop-session'
|
||||
import { RunStateManager } from './state'
|
||||
|
||||
function fg(appName: string): ForegroundContext {
|
||||
return { available: true, appName, platform: 'darwin' }
|
||||
}
|
||||
function fgUnavailable(): ForegroundContext {
|
||||
return { available: false, platform: 'darwin' }
|
||||
}
|
||||
|
||||
function mockChromeSessionManager(): ChromeSessionManager {
|
||||
return {
|
||||
ensureAgentWindow: vi.fn(),
|
||||
bringToFront: vi.fn().mockResolvedValue(true),
|
||||
restorePreviousForeground: vi.fn().mockResolvedValue(undefined),
|
||||
getSessionInfo: vi.fn().mockReturnValue(null),
|
||||
endSession: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('desktopSessionController', () => {
|
||||
let stateManager: RunStateManager
|
||||
let controller: DesktopSessionController
|
||||
|
||||
beforeEach(() => {
|
||||
stateManager = new RunStateManager()
|
||||
controller = createDesktopSessionController(stateManager)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// begin / end
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('begin', () => {
|
||||
it('should create a session with the controlled app', () => {
|
||||
const session = controller.begin({
|
||||
controlledApp: 'Google Chrome',
|
||||
currentForeground: fg('Terminal'),
|
||||
})
|
||||
|
||||
expect(session.controlledApp).toBe('Google Chrome')
|
||||
expect(session.userForegroundApp).toBe('Terminal')
|
||||
expect(session.ownedWindows).toEqual([])
|
||||
expect(session.id).toMatch(/^ds_/)
|
||||
expect(session.createdAt).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should not record userForegroundApp if same as controlled app', () => {
|
||||
const session = controller.begin({
|
||||
controlledApp: 'Google Chrome',
|
||||
currentForeground: fg('Google Chrome'),
|
||||
})
|
||||
|
||||
expect(session.userForegroundApp).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should update RunState on begin', () => {
|
||||
controller.begin({
|
||||
controlledApp: 'Safari',
|
||||
currentForeground: fg('Finder'),
|
||||
})
|
||||
|
||||
const state = stateManager.getState()
|
||||
expect(state.desktopSession?.controlledApp).toBe('Safari')
|
||||
expect(state.previousUserForegroundApp).toBe('Finder')
|
||||
})
|
||||
|
||||
it('should handle undefined foreground', () => {
|
||||
const session = controller.begin({
|
||||
controlledApp: 'Chrome',
|
||||
})
|
||||
expect(session.userForegroundApp).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('end', () => {
|
||||
it('should clear the session', () => {
|
||||
controller.begin({ controlledApp: 'Chrome' })
|
||||
expect(controller.getSession()).not.toBeNull()
|
||||
|
||||
controller.end()
|
||||
expect(controller.getSession()).toBeNull()
|
||||
expect(stateManager.getState().desktopSession).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// addOwnedWindow
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('addOwnedWindow', () => {
|
||||
it('should add a window to the session', () => {
|
||||
controller.begin({ controlledApp: 'Chrome' })
|
||||
controller.addOwnedWindow({
|
||||
appName: 'Google Chrome',
|
||||
windowId: '1234:0:Google Chrome',
|
||||
pid: 1234,
|
||||
agentLaunched: true,
|
||||
})
|
||||
|
||||
expect(controller.getSession()!.ownedWindows).toHaveLength(1)
|
||||
expect(controller.getSession()!.ownedWindows[0].pid).toBe(1234)
|
||||
})
|
||||
|
||||
it('should prevent duplicate windows', () => {
|
||||
controller.begin({ controlledApp: 'Chrome' })
|
||||
const window = {
|
||||
appName: 'Google Chrome',
|
||||
windowId: '1234:0:Google Chrome',
|
||||
pid: 1234,
|
||||
agentLaunched: true,
|
||||
}
|
||||
|
||||
controller.addOwnedWindow(window)
|
||||
controller.addOwnedWindow(window)
|
||||
|
||||
expect(controller.getSession()!.ownedWindows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should no-op if no session', () => {
|
||||
controller.addOwnedWindow({
|
||||
appName: 'Chrome',
|
||||
windowId: 'fake',
|
||||
pid: 0,
|
||||
agentLaunched: false,
|
||||
})
|
||||
// No error thrown, no session created
|
||||
expect(controller.getSession()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// touch
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('touch', () => {
|
||||
it('should update lastActiveAt', () => {
|
||||
controller.begin({ controlledApp: 'Chrome' })
|
||||
const before = controller.getSession()!.lastActiveAt
|
||||
|
||||
// Introduce a tiny delay to ensure the timestamp changes
|
||||
vi.useFakeTimers()
|
||||
vi.advanceTimersByTime(100)
|
||||
controller.touch()
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(controller.getSession()!.lastActiveAt).not.toBe(before)
|
||||
})
|
||||
|
||||
it('should no-op if no session', () => {
|
||||
controller.touch() // should not throw
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// isControlledAppInForeground
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('isControlledAppInForeground', () => {
|
||||
it('should return true when controlled app is foreground', () => {
|
||||
controller.begin({ controlledApp: 'Google Chrome' })
|
||||
expect(controller.isControlledAppInForeground(fg('Google Chrome'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when different app is foreground', () => {
|
||||
controller.begin({ controlledApp: 'Google Chrome' })
|
||||
expect(controller.isControlledAppInForeground(fg('Terminal'))).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when foreground is unavailable', () => {
|
||||
controller.begin({ controlledApp: 'Chrome' })
|
||||
expect(controller.isControlledAppInForeground(fgUnavailable())).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when no session', () => {
|
||||
expect(controller.isControlledAppInForeground(fg('Chrome'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ensureControlledAppInForeground
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('ensureControlledAppInForeground', () => {
|
||||
it('should return true if controlled app is already in foreground', async () => {
|
||||
controller.begin({ controlledApp: 'Google Chrome' })
|
||||
const chromeManager = mockChromeSessionManager()
|
||||
|
||||
const result = await controller.ensureControlledAppInForeground({
|
||||
currentForeground: fg('Google Chrome'),
|
||||
chromeSessionManager: chromeManager,
|
||||
activateApp: vi.fn(),
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(chromeManager.bringToFront).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use chromeSessionManager.bringToFront for Chrome', async () => {
|
||||
controller.begin({ controlledApp: 'Google Chrome', currentForeground: fg('Finder') })
|
||||
const chromeManager = mockChromeSessionManager()
|
||||
|
||||
const result = await controller.ensureControlledAppInForeground({
|
||||
currentForeground: fg('Finder'),
|
||||
chromeSessionManager: chromeManager,
|
||||
activateApp: vi.fn(),
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(chromeManager.bringToFront).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fail when controlled Chrome session cannot be foregrounded', async () => {
|
||||
controller.begin({ controlledApp: 'Google Chrome', currentForeground: fg('Finder') })
|
||||
const chromeManager = mockChromeSessionManager()
|
||||
vi.mocked(chromeManager.bringToFront).mockResolvedValue(false)
|
||||
|
||||
await expect(controller.ensureControlledAppInForeground({
|
||||
currentForeground: fg('Finder'),
|
||||
chromeSessionManager: chromeManager,
|
||||
activateApp: vi.fn(),
|
||||
})).rejects.toThrow('Controlled Chrome session is unavailable')
|
||||
})
|
||||
|
||||
it('should use activateApp for non-Chrome apps', async () => {
|
||||
controller.begin({ controlledApp: 'Safari', currentForeground: fg('Finder') })
|
||||
const chromeManager = mockChromeSessionManager()
|
||||
const activateApp = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await controller.ensureControlledAppInForeground({
|
||||
currentForeground: fg('Terminal'),
|
||||
chromeSessionManager: chromeManager,
|
||||
activateApp,
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(activateApp).toHaveBeenCalledWith('Safari')
|
||||
expect(chromeManager.bringToFront).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update userForegroundApp when switching', async () => {
|
||||
controller.begin({ controlledApp: 'Google Chrome', currentForeground: fg('Finder') })
|
||||
const chromeManager = mockChromeSessionManager()
|
||||
|
||||
await controller.ensureControlledAppInForeground({
|
||||
currentForeground: fg('Terminal'),
|
||||
chromeSessionManager: chromeManager,
|
||||
activateApp: vi.fn(),
|
||||
})
|
||||
|
||||
expect(controller.getSession()!.userForegroundApp).toBe('Terminal')
|
||||
expect(stateManager.getState().previousUserForegroundApp).toBe('Terminal')
|
||||
})
|
||||
|
||||
it('should return true when no session exists', async () => {
|
||||
const result = await controller.ensureControlledAppInForeground({
|
||||
currentForeground: fg('Chrome'),
|
||||
chromeSessionManager: mockChromeSessionManager(),
|
||||
activateApp: vi.fn(),
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Desktop Session — agent execution ownership model.
|
||||
*
|
||||
* Tracks what the agent is controlling, which windows it owns, and
|
||||
* the user's previous foreground context so it can be restored.
|
||||
*
|
||||
* The session is a lightweight state object managed by the RunStateManager.
|
||||
* It does not perform any OS actions itself — that's the job of
|
||||
* ChromeSessionManager and the executor.
|
||||
*/
|
||||
|
||||
import type { ChromeSessionManager } from './chrome-session-manager'
|
||||
import type { RunStateManager } from './state'
|
||||
import type { ForegroundContext } from './types'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OwnedWindow {
|
||||
/** Application name (e.g. "Google Chrome"). */
|
||||
appName: string
|
||||
/** Window identity string from observe-windows (ownerPid:layer:title). */
|
||||
windowId: string
|
||||
/** Process ID. */
|
||||
pid: number
|
||||
/** Whether the agent launched this app (vs. just taking a window in it). */
|
||||
agentLaunched: boolean
|
||||
}
|
||||
|
||||
export interface DesktopSession {
|
||||
/** Session unique ID. */
|
||||
id: string
|
||||
/** App the agent is currently controlling. */
|
||||
controlledApp?: string
|
||||
/** Windows the agent owns or manages. */
|
||||
ownedWindows: OwnedWindow[]
|
||||
/** The user's foreground app before the agent took over. */
|
||||
userForegroundApp?: string
|
||||
/** ISO timestamp of session creation. */
|
||||
createdAt: string
|
||||
/** ISO timestamp of last activity. */
|
||||
lastActiveAt: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DesktopSessionController {
|
||||
/**
|
||||
* Begin a new session targeting a specific app.
|
||||
* Records the user's current foreground and sets the agent's controlled app.
|
||||
*/
|
||||
begin: (params: {
|
||||
controlledApp: string
|
||||
currentForeground?: ForegroundContext
|
||||
}) => DesktopSession
|
||||
|
||||
/**
|
||||
* End the current session and clear session state.
|
||||
*
|
||||
* Foreground restoration is handled by the Chrome/session manager; this
|
||||
* controller only owns in-memory session bookkeeping.
|
||||
*/
|
||||
end: () => void
|
||||
|
||||
/**
|
||||
* Add an owned window to the session.
|
||||
*/
|
||||
addOwnedWindow: (window: OwnedWindow) => void
|
||||
|
||||
/**
|
||||
* Touch the session (update lastActiveAt).
|
||||
*/
|
||||
touch: () => void
|
||||
|
||||
/**
|
||||
* Get the current session (null if no session).
|
||||
*/
|
||||
getSession: () => DesktopSession | null
|
||||
|
||||
/**
|
||||
* Check if the session's controlled app is still in the foreground.
|
||||
*/
|
||||
isControlledAppInForeground: (currentForeground: ForegroundContext) => boolean
|
||||
|
||||
/**
|
||||
* Ensure the controlled app is in the foreground.
|
||||
* Delegates to ChromeSessionManager.bringToFront() for Chrome,
|
||||
* or to executor.focusApp() for other apps.
|
||||
*
|
||||
* Returns true if the app was already in front, false if it needed switching.
|
||||
*/
|
||||
ensureControlledAppInForeground: (params: {
|
||||
currentForeground: ForegroundContext
|
||||
chromeSessionManager: ChromeSessionManager
|
||||
activateApp: (appName: string) => Promise<void>
|
||||
}) => Promise<boolean>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let sessionCounter = 0
|
||||
|
||||
export function createDesktopSessionController(
|
||||
stateManager: RunStateManager,
|
||||
): DesktopSessionController {
|
||||
let session: DesktopSession | null = null
|
||||
|
||||
return {
|
||||
begin({ controlledApp, currentForeground }) {
|
||||
sessionCounter++
|
||||
session = {
|
||||
id: `ds_${sessionCounter}`,
|
||||
controlledApp,
|
||||
ownedWindows: [],
|
||||
userForegroundApp: currentForeground?.appName !== controlledApp
|
||||
? currentForeground?.appName
|
||||
: undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
stateManager.updateDesktopSession(session)
|
||||
|
||||
// Also save the user's foreground for restore
|
||||
if (session.userForegroundApp) {
|
||||
stateManager.savePreviousUserForeground(session.userForegroundApp)
|
||||
}
|
||||
|
||||
return session
|
||||
},
|
||||
|
||||
end() {
|
||||
session = null
|
||||
stateManager.clearDesktopSession()
|
||||
},
|
||||
|
||||
addOwnedWindow(window) {
|
||||
if (!session)
|
||||
return
|
||||
// Prevent duplicate entries
|
||||
if (session.ownedWindows.some(w => w.windowId === window.windowId))
|
||||
return
|
||||
session.ownedWindows.push(window)
|
||||
session.lastActiveAt = new Date().toISOString()
|
||||
stateManager.updateDesktopSession(session)
|
||||
},
|
||||
|
||||
touch() {
|
||||
if (!session)
|
||||
return
|
||||
session.lastActiveAt = new Date().toISOString()
|
||||
stateManager.updateDesktopSession(session)
|
||||
},
|
||||
|
||||
getSession() {
|
||||
return session
|
||||
},
|
||||
|
||||
isControlledAppInForeground(currentForeground) {
|
||||
if (!session?.controlledApp)
|
||||
return false
|
||||
if (!currentForeground.available || !currentForeground.appName)
|
||||
return false
|
||||
return currentForeground.appName === session.controlledApp
|
||||
},
|
||||
|
||||
async ensureControlledAppInForeground({ currentForeground, chromeSessionManager, activateApp }) {
|
||||
if (!session?.controlledApp)
|
||||
return true
|
||||
|
||||
if (this.isControlledAppInForeground(currentForeground)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Save user's current foreground before switching
|
||||
if (currentForeground.appName && currentForeground.appName !== session.controlledApp) {
|
||||
session.userForegroundApp = currentForeground.appName
|
||||
stateManager.savePreviousUserForeground(currentForeground.appName)
|
||||
}
|
||||
|
||||
// Switch to the controlled app
|
||||
if (session.controlledApp === 'Google Chrome') {
|
||||
const activated = await chromeSessionManager.bringToFront()
|
||||
if (!activated) {
|
||||
throw new Error('Controlled Chrome session is unavailable; call desktop_ensure_chrome before continuing.')
|
||||
}
|
||||
}
|
||||
else {
|
||||
await activateApp(session.controlledApp)
|
||||
}
|
||||
|
||||
this.touch()
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { resolveComputerUseConfig } from './config'
|
||||
import { createExecuteAction } from './server/action-executor'
|
||||
import { registerAccessibilityTools } from './server/register-accessibility'
|
||||
import { registerCdpTools } from './server/register-cdp'
|
||||
import { registerChromeSessionTools } from './server/register-chrome-session'
|
||||
import { registerDesktopGroundingTools } from './server/register-desktop-grounding'
|
||||
import { registerDisplayTools } from './server/register-display'
|
||||
import { destroyAllPtySessions, registerPtyTools } from './server/register-pty'
|
||||
@@ -50,7 +51,8 @@ export async function createComputerUseMcpServer(config = resolveComputerUseConf
|
||||
},
|
||||
})
|
||||
const cdpCleanup = registerCdpTools({ server, runtime })
|
||||
registerDesktopGroundingTools({ server, runtime, executeAction })
|
||||
registerDesktopGroundingTools({ server, runtime })
|
||||
registerChromeSessionTools({ server, runtime })
|
||||
|
||||
return {
|
||||
server,
|
||||
|
||||
@@ -120,116 +120,281 @@ describe('createExecuteAction', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('updates pointer state only after desktop_click_target executes successfully', async () => {
|
||||
const { runtime, executor, session, stateManager } = createRuntimeForActionTest()
|
||||
it('does not reuse stale browser-dom typing route when explicit coordinates are provided', async () => {
|
||||
const stateManager = new RunStateManager()
|
||||
stateManager.updateGroundingSnapshot({
|
||||
snapshotId: 'dg_1',
|
||||
capturedAt: new Date().toISOString(),
|
||||
foregroundApp: 'Google Chrome',
|
||||
windows: [],
|
||||
screenshot: { dataBase64: '', mimeType: 'image/png', path: '', capturedAt: new Date().toISOString() },
|
||||
targetCandidates: [{
|
||||
id: 't_0',
|
||||
source: 'chrome_dom',
|
||||
appName: 'Google Chrome',
|
||||
role: 'button',
|
||||
label: 'Submit',
|
||||
bounds: { x: 100, y: 200, width: 40, height: 20 },
|
||||
confidence: 0.95,
|
||||
interactable: true,
|
||||
}],
|
||||
targetCandidates: [
|
||||
{
|
||||
id: 't_0',
|
||||
source: 'chrome_dom',
|
||||
appName: 'Google Chrome',
|
||||
role: 'textbox',
|
||||
label: 'Email',
|
||||
bounds: { x: 100, y: 200, width: 140, height: 28 },
|
||||
confidence: 0.98,
|
||||
interactable: true,
|
||||
tag: 'input',
|
||||
inputType: 'text',
|
||||
selector: '#email',
|
||||
frameId: 0,
|
||||
isPageContent: true,
|
||||
},
|
||||
],
|
||||
staleFlags: { screenshot: false, ax: false, chromeSemantic: false },
|
||||
} as any)
|
||||
stateManager.updatePointerIntent({
|
||||
mode: 'execute',
|
||||
candidateId: 't_0',
|
||||
rawPoint: { x: 120, y: 214 },
|
||||
snappedPoint: { x: 120, y: 214 },
|
||||
source: 'chrome_dom',
|
||||
confidence: 0.98,
|
||||
path: [{ x: 120, y: 214, delayMs: 0 }],
|
||||
phase: 'completed',
|
||||
executionResult: 'success',
|
||||
}, 't_0')
|
||||
|
||||
const session = {
|
||||
listPendingActions: vi.fn().mockReturnValue([]),
|
||||
getBudgetState: vi.fn().mockReturnValue({
|
||||
operationsExecuted: 0,
|
||||
operationUnitsConsumed: 0,
|
||||
}),
|
||||
record: vi.fn().mockResolvedValue(undefined),
|
||||
createPendingAction: vi.fn(),
|
||||
consumeOperation: vi.fn(),
|
||||
getLastScreenshot: vi.fn().mockReturnValue(undefined),
|
||||
setLastScreenshot: vi.fn(),
|
||||
getTerminalState: vi.fn().mockReturnValue(createTerminalState()),
|
||||
setTerminalState: vi.fn(),
|
||||
getPointerPosition: vi.fn().mockReturnValue({ x: 0, y: 0 }),
|
||||
setPointerPosition: vi.fn(),
|
||||
}
|
||||
const executor = {
|
||||
kind: 'dry-run' as const,
|
||||
describe: () => ({ kind: 'dry-run' as const, notes: [] }),
|
||||
getExecutionTarget: vi.fn().mockResolvedValue(createLocalExecutionTarget()),
|
||||
getForegroundContext: vi.fn().mockResolvedValue({
|
||||
available: true,
|
||||
appName: 'Google Chrome',
|
||||
platform: 'darwin',
|
||||
}),
|
||||
getDisplayInfo: vi.fn().mockResolvedValue(createDisplayInfo({
|
||||
platform: 'darwin',
|
||||
})),
|
||||
getPermissionInfo: vi.fn(),
|
||||
observeWindows: vi.fn(),
|
||||
takeScreenshot: vi.fn(),
|
||||
openApp: vi.fn(),
|
||||
focusApp: vi.fn(),
|
||||
click: vi.fn().mockResolvedValue({
|
||||
performed: true,
|
||||
backend: 'dry-run' as const,
|
||||
notes: [],
|
||||
}),
|
||||
typeText: vi.fn().mockResolvedValue({
|
||||
performed: true,
|
||||
backend: 'dry-run' as const,
|
||||
notes: [],
|
||||
}),
|
||||
pressKeys: vi.fn(),
|
||||
scroll: vi.fn(),
|
||||
wait: vi.fn(),
|
||||
}
|
||||
const terminalRunner = {
|
||||
describe: () => ({ kind: 'local-shell-runner' as const, notes: [] }),
|
||||
execute: vi.fn(),
|
||||
getState: vi.fn().mockReturnValue(createTerminalState()),
|
||||
resetState: vi.fn(),
|
||||
}
|
||||
const browserDomBridge = {
|
||||
getStatus: vi.fn().mockReturnValue({
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8765,
|
||||
connected: true,
|
||||
pendingRequests: 0,
|
||||
}),
|
||||
setInputValue: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
const cdpBridgeManager = {
|
||||
probeAvailability: vi.fn().mockResolvedValue({
|
||||
endpoint: 'http://localhost:9222',
|
||||
connected: false,
|
||||
connectable: true,
|
||||
}),
|
||||
}
|
||||
|
||||
const runtime = {
|
||||
config: createTestConfig({
|
||||
executor: 'dry-run',
|
||||
approvalMode: 'never',
|
||||
defaultCaptureAfter: false,
|
||||
}),
|
||||
session,
|
||||
executor,
|
||||
terminalRunner,
|
||||
browserDomBridge,
|
||||
cdpBridgeManager,
|
||||
stateManager,
|
||||
taskMemory: {},
|
||||
} as unknown as ComputerUseServerRuntime
|
||||
|
||||
const executeAction = createExecuteAction(runtime)
|
||||
const result = await executeAction({
|
||||
kind: 'desktop_click_target',
|
||||
input: { candidateId: 't_0' },
|
||||
}, 'desktop_click_target')
|
||||
kind: 'type_text',
|
||||
input: {
|
||||
x: 300,
|
||||
y: 400,
|
||||
text: 'hello',
|
||||
captureAfter: false,
|
||||
},
|
||||
}, 'desktop_type_text')
|
||||
|
||||
expect(result.isError).not.toBe(true)
|
||||
expect(executor.click).toHaveBeenCalledWith(expect.objectContaining({
|
||||
x: 120,
|
||||
y: 210,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}))
|
||||
expect(session.setPointerPosition).toHaveBeenCalledWith({ x: 120, y: 210 })
|
||||
expect(stateManager.getState().lastClickedCandidateId).toBe('t_0')
|
||||
expect(stateManager.getState().lastPointerIntent).toMatchObject({
|
||||
expect(executor.click).toHaveBeenCalledOnce()
|
||||
expect(executor.typeText).toHaveBeenCalledOnce()
|
||||
expect(browserDomBridge.setInputValue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to OS typing when the connected extension transport does not support setInputValue', async () => {
|
||||
const stateManager = new RunStateManager()
|
||||
stateManager.updateGroundingSnapshot({
|
||||
snapshotId: 'dg_1',
|
||||
capturedAt: new Date().toISOString(),
|
||||
foregroundApp: 'Google Chrome',
|
||||
windows: [],
|
||||
screenshot: { dataBase64: '', mimeType: 'image/png', path: '', capturedAt: new Date().toISOString() },
|
||||
targetCandidates: [
|
||||
{
|
||||
id: 't_0',
|
||||
source: 'chrome_dom',
|
||||
appName: 'Google Chrome',
|
||||
role: 'textbox',
|
||||
label: 'Email',
|
||||
bounds: { x: 100, y: 200, width: 140, height: 28 },
|
||||
confidence: 0.98,
|
||||
interactable: true,
|
||||
tag: 'input',
|
||||
inputType: 'text',
|
||||
selector: '#email',
|
||||
frameId: 0,
|
||||
isPageContent: true,
|
||||
},
|
||||
],
|
||||
staleFlags: { screenshot: false, ax: false, chromeSemantic: false },
|
||||
} as any)
|
||||
stateManager.updatePointerIntent({
|
||||
mode: 'execute',
|
||||
candidateId: 't_0',
|
||||
snappedPoint: { x: 120, y: 210 },
|
||||
rawPoint: { x: 120, y: 214 },
|
||||
snappedPoint: { x: 120, y: 214 },
|
||||
source: 'chrome_dom',
|
||||
})
|
||||
})
|
||||
confidence: 0.98,
|
||||
path: [{ x: 120, y: 214, delayMs: 0 }],
|
||||
phase: 'completed',
|
||||
executionResult: 'success',
|
||||
}, 't_0')
|
||||
|
||||
it('does not mark a candidate as clicked when desktop_click_target execution fails', async () => {
|
||||
const { runtime, executor, stateManager } = createRuntimeForActionTest()
|
||||
stateManager.updateGroundingSnapshot({
|
||||
snapshotId: 'dg_1',
|
||||
capturedAt: new Date().toISOString(),
|
||||
foregroundApp: 'Google Chrome',
|
||||
windows: [],
|
||||
screenshot: { dataBase64: '', mimeType: 'image/png', path: '', capturedAt: new Date().toISOString() },
|
||||
targetCandidates: [{
|
||||
id: 't_0',
|
||||
source: 'chrome_dom',
|
||||
const session = {
|
||||
listPendingActions: vi.fn().mockReturnValue([]),
|
||||
getBudgetState: vi.fn().mockReturnValue({
|
||||
operationsExecuted: 0,
|
||||
operationUnitsConsumed: 0,
|
||||
}),
|
||||
record: vi.fn().mockResolvedValue(undefined),
|
||||
createPendingAction: vi.fn(),
|
||||
consumeOperation: vi.fn(),
|
||||
getLastScreenshot: vi.fn().mockReturnValue(undefined),
|
||||
setLastScreenshot: vi.fn(),
|
||||
getTerminalState: vi.fn().mockReturnValue(createTerminalState()),
|
||||
setTerminalState: vi.fn(),
|
||||
getPointerPosition: vi.fn().mockReturnValue({ x: 0, y: 0 }),
|
||||
setPointerPosition: vi.fn(),
|
||||
}
|
||||
const executor = {
|
||||
kind: 'dry-run' as const,
|
||||
describe: () => ({ kind: 'dry-run' as const, notes: [] }),
|
||||
getExecutionTarget: vi.fn().mockResolvedValue(createLocalExecutionTarget()),
|
||||
getForegroundContext: vi.fn().mockResolvedValue({
|
||||
available: true,
|
||||
appName: 'Google Chrome',
|
||||
role: 'button',
|
||||
label: 'Submit',
|
||||
bounds: { x: 100, y: 200, width: 40, height: 20 },
|
||||
confidence: 0.95,
|
||||
interactable: true,
|
||||
}],
|
||||
staleFlags: { screenshot: false, ax: false, chromeSemantic: false },
|
||||
} as any)
|
||||
;(executor.click as any).mockRejectedValueOnce(new Error('backend failed'))
|
||||
platform: 'darwin',
|
||||
}),
|
||||
getDisplayInfo: vi.fn().mockResolvedValue(createDisplayInfo({
|
||||
platform: 'darwin',
|
||||
})),
|
||||
getPermissionInfo: vi.fn(),
|
||||
observeWindows: vi.fn(),
|
||||
takeScreenshot: vi.fn(),
|
||||
openApp: vi.fn(),
|
||||
focusApp: vi.fn(),
|
||||
click: vi.fn(),
|
||||
typeText: vi.fn().mockResolvedValue({
|
||||
performed: true,
|
||||
backend: 'dry-run' as const,
|
||||
notes: [],
|
||||
}),
|
||||
pressKeys: vi.fn(),
|
||||
scroll: vi.fn(),
|
||||
wait: vi.fn(),
|
||||
}
|
||||
const terminalRunner = {
|
||||
describe: () => ({ kind: 'local-shell-runner' as const, notes: [] }),
|
||||
execute: vi.fn(),
|
||||
getState: vi.fn().mockReturnValue(createTerminalState()),
|
||||
resetState: vi.fn(),
|
||||
}
|
||||
const browserDomBridge = {
|
||||
getStatus: vi.fn().mockReturnValue({
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8765,
|
||||
connected: true,
|
||||
pendingRequests: 0,
|
||||
}),
|
||||
supportsAction: vi.fn().mockImplementation((action: string) => action !== 'setInputValue'),
|
||||
setInputValue: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
const cdpBridgeManager = {
|
||||
probeAvailability: vi.fn().mockResolvedValue({
|
||||
endpoint: 'http://localhost:9222',
|
||||
connected: false,
|
||||
connectable: true,
|
||||
}),
|
||||
}
|
||||
|
||||
const runtime = {
|
||||
config: createTestConfig({
|
||||
executor: 'dry-run',
|
||||
approvalMode: 'never',
|
||||
defaultCaptureAfter: false,
|
||||
}),
|
||||
session,
|
||||
executor,
|
||||
terminalRunner,
|
||||
browserDomBridge,
|
||||
cdpBridgeManager,
|
||||
stateManager,
|
||||
taskMemory: {},
|
||||
} as unknown as ComputerUseServerRuntime
|
||||
|
||||
const executeAction = createExecuteAction(runtime)
|
||||
const result = await executeAction({
|
||||
kind: 'desktop_click_target',
|
||||
input: { candidateId: 't_0' },
|
||||
}, 'desktop_click_target')
|
||||
kind: 'type_text',
|
||||
input: {
|
||||
text: 'hello',
|
||||
captureAfter: false,
|
||||
},
|
||||
}, 'desktop_type_text')
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(stateManager.getState().lastClickedCandidateId).toBeUndefined()
|
||||
expect(stateManager.getState().lastPointerIntent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects desktop_click_target when the foreground app changed after desktop_observe', async () => {
|
||||
const { runtime, executor, stateManager } = createRuntimeForActionTest()
|
||||
stateManager.updateGroundingSnapshot({
|
||||
snapshotId: 'dg_1',
|
||||
capturedAt: new Date().toISOString(),
|
||||
foregroundApp: 'Google Chrome',
|
||||
windows: [],
|
||||
screenshot: { dataBase64: '', mimeType: 'image/png', path: '', capturedAt: new Date().toISOString() },
|
||||
targetCandidates: [{
|
||||
id: 't_0',
|
||||
source: 'chrome_dom',
|
||||
appName: 'Google Chrome',
|
||||
role: 'button',
|
||||
label: 'Submit',
|
||||
bounds: { x: 100, y: 200, width: 40, height: 20 },
|
||||
confidence: 0.95,
|
||||
interactable: true,
|
||||
}],
|
||||
staleFlags: { screenshot: false, ax: false, chromeSemantic: false },
|
||||
} as any)
|
||||
;(executor.getForegroundContext as any).mockResolvedValue({
|
||||
available: true,
|
||||
appName: 'Terminal',
|
||||
platform: 'darwin',
|
||||
})
|
||||
|
||||
const executeAction = createExecuteAction(runtime)
|
||||
const result = await executeAction({
|
||||
kind: 'desktop_click_target',
|
||||
input: { candidateId: 't_0' },
|
||||
}, 'desktop_click_target')
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(executor.click).not.toHaveBeenCalled()
|
||||
expect(result.content.find(item => item.type === 'text')?.text ?? '').toContain('current foreground app is "Terminal"')
|
||||
expect(result.isError).not.toBe(true)
|
||||
expect(executor.typeText).toHaveBeenCalledOnce()
|
||||
expect(browserDomBridge.setInputValue).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
import type { PointerIntent } from '../desktop-grounding-types'
|
||||
import type {
|
||||
ActionInvocation,
|
||||
ComputerUseConfig,
|
||||
@@ -12,13 +11,12 @@ import type {
|
||||
} from '../types'
|
||||
import type { ComputerUseServerRuntime } from './runtime'
|
||||
|
||||
import { appNamesMatch, normalizeConfiguredAppAction } from '../app-aliases'
|
||||
import { normalizeConfiguredAppAction } from '../app-aliases'
|
||||
import { decideBrowserTypeAction } from '../browser-action-router'
|
||||
import { DESKTOP_CLICK_SNAPSHOT_MAX_AGE_MS } from '../desktop-grounding-types'
|
||||
import { isBrowserDomActionSupported } from '../browser-dom/capabilities'
|
||||
import { evaluateActionPolicy } from '../policy'
|
||||
import { getRuntimePreflight } from '../preflight'
|
||||
import { buildCoordinateSpaceInfo } from '../runtime-probes'
|
||||
import { resolveSnapByCandidate } from '../snap-resolver'
|
||||
import { evaluateStrategy, summarizeAdvisories } from '../strategy'
|
||||
import { buildPointerTrace } from '../trace'
|
||||
import {
|
||||
@@ -119,21 +117,6 @@ function toTerminalStateContent(state: TerminalState) {
|
||||
}
|
||||
}
|
||||
|
||||
function isPointWithinAllowedBounds(params: {
|
||||
x: number
|
||||
y: number
|
||||
bounds: ComputerUseConfig['allowedBounds']
|
||||
}) {
|
||||
const { bounds, x, y } = params
|
||||
if (!bounds)
|
||||
return true
|
||||
|
||||
return x >= bounds.x
|
||||
&& y >= bounds.y
|
||||
&& x <= (bounds.x + bounds.width)
|
||||
&& y <= (bounds.y + bounds.height)
|
||||
}
|
||||
|
||||
export function createExecuteAction(runtime: ComputerUseServerRuntime): ExecuteAction {
|
||||
return async (action, toolName, options = {}) => {
|
||||
const normalizedAction = normalizeConfiguredAppAction(action, runtime.config.openableApps)
|
||||
@@ -365,85 +348,12 @@ export function createExecuteAction(runtime: ComputerUseServerRuntime): ExecuteA
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'desktop_click_target': {
|
||||
const state = runtime.stateManager.getState()
|
||||
const snapshot = state.lastGroundingSnapshot
|
||||
if (!snapshot) {
|
||||
throw new Error('No desktop_observe snapshot available. Call desktop_observe first to get a list of target candidates.')
|
||||
}
|
||||
|
||||
if (state.lastClickedCandidateId === normalizedAction.input.candidateId) {
|
||||
throw new Error(`Candidate "${normalizedAction.input.candidateId}" was already clicked. Call desktop_observe again before clicking the same target.`)
|
||||
}
|
||||
|
||||
const snapshotAge = Date.now() - new Date(snapshot.capturedAt).getTime()
|
||||
if (snapshotAge > DESKTOP_CLICK_SNAPSHOT_MAX_AGE_MS) {
|
||||
throw new Error(`Grounding snapshot "${snapshot.snapshotId}" is ${Math.round(snapshotAge / 1000)}s old. Call desktop_observe to get a fresh snapshot before clicking.`)
|
||||
}
|
||||
|
||||
const currentForeground = await runtime.executor.getForegroundContext()
|
||||
if (currentForeground.available && currentForeground.appName && !appNamesMatch(currentForeground.appName, snapshot.foregroundApp)) {
|
||||
throw new Error(`Grounding snapshot "${snapshot.snapshotId}" was captured for "${snapshot.foregroundApp}", but the current foreground app is "${currentForeground.appName}". Call desktop_observe again before clicking.`)
|
||||
}
|
||||
|
||||
if (
|
||||
currentForeground.available
|
||||
&& currentForeground.windowTitle
|
||||
&& snapshot.foregroundWindowTitle
|
||||
&& currentForeground.windowTitle !== snapshot.foregroundWindowTitle
|
||||
) {
|
||||
throw new Error(`Grounding snapshot "${snapshot.snapshotId}" was captured for window "${snapshot.foregroundWindowTitle}", but the current foreground window is "${currentForeground.windowTitle}". Call desktop_observe again before clicking.`)
|
||||
}
|
||||
|
||||
const snap = resolveSnapByCandidate(normalizedAction.input.candidateId, snapshot)
|
||||
if (snap.source === 'none' && !snap.candidateId) {
|
||||
throw new Error(`Candidate "${normalizedAction.input.candidateId}" not found in snapshot "${snapshot.snapshotId}". Available candidates: ${snapshot.targetCandidates.map(c => c.id).join(', ')}`)
|
||||
}
|
||||
|
||||
if (!isPointWithinAllowedBounds({
|
||||
x: snap.snappedPoint.x,
|
||||
y: snap.snappedPoint.y,
|
||||
bounds: runtime.config.allowedBounds,
|
||||
})) {
|
||||
throw new Error(`Snap-resolved point (${snap.snappedPoint.x}, ${snap.snappedPoint.y}) is outside the allowed bounds.`)
|
||||
}
|
||||
|
||||
const pointerTrace = buildPointerTrace({
|
||||
from: runtime.session.getPointerPosition(),
|
||||
to: { x: snap.snappedPoint.x, y: snap.snappedPoint.y },
|
||||
bounds: runtime.config.allowedBounds,
|
||||
})
|
||||
const result = await runtime.executor.click({
|
||||
x: snap.snappedPoint.x,
|
||||
y: snap.snappedPoint.y,
|
||||
button: normalizedAction.input.button ?? 'left',
|
||||
clickCount: normalizedAction.input.clickCount ?? 1,
|
||||
pointerTrace,
|
||||
})
|
||||
runtime.session.setPointerPosition({ x: snap.snappedPoint.x, y: snap.snappedPoint.y })
|
||||
|
||||
const candidate = snapshot.targetCandidates.find(c => c.id === normalizedAction.input.candidateId)
|
||||
const intent: PointerIntent = {
|
||||
mode: 'execute',
|
||||
candidateId: normalizedAction.input.candidateId,
|
||||
rawPoint: snap.rawPoint,
|
||||
snappedPoint: snap.snappedPoint,
|
||||
source: snap.source,
|
||||
confidence: candidate?.confidence ?? 0,
|
||||
path: pointerTrace,
|
||||
}
|
||||
runtime.stateManager.updatePointerIntent(intent, normalizedAction.input.candidateId)
|
||||
|
||||
backendResult = {
|
||||
...result,
|
||||
candidateId: normalizedAction.input.candidateId,
|
||||
snap,
|
||||
pointerTrace,
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'type_text': {
|
||||
if (typeof normalizedAction.input.x === 'number' && typeof normalizedAction.input.y === 'number') {
|
||||
const hasExplicitCoordinates
|
||||
= typeof normalizedAction.input.x === 'number'
|
||||
&& typeof normalizedAction.input.y === 'number'
|
||||
|
||||
if (hasExplicitCoordinates) {
|
||||
const pointerTrace = buildPointerTrace({
|
||||
from: runtime.session.getPointerPosition(),
|
||||
to: { x: normalizedAction.input.x, y: normalizedAction.input.y },
|
||||
@@ -469,26 +379,25 @@ export function createExecuteAction(runtime: ComputerUseServerRuntime): ExecuteA
|
||||
}
|
||||
|
||||
// Browser-dom type routing: if the last clicked grounding candidate
|
||||
// is a chrome_dom text input, use setInputValue for DOM precision.
|
||||
// NOTICE: skip this path when explicit coordinates are provided.
|
||||
// Coordinates mean the caller has targeted a specific screen position
|
||||
// (possibly in a different app/window), so using lastClickedCandidateId
|
||||
// would write into a stale Chrome selector instead of the current target.
|
||||
const hasExplicitCoords = typeof normalizedAction.input.x === 'number' && typeof normalizedAction.input.y === 'number'
|
||||
// is a chrome_dom text input, use setInputValue for DOM precision
|
||||
let usedBrowserDom = false
|
||||
const runState = runtime.stateManager.getState()
|
||||
const lastSnapshot = runState.lastGroundingSnapshot
|
||||
const lastClickedId = runState.lastClickedCandidateId
|
||||
if (!hasExplicitCoords && lastClickedId && lastSnapshot) {
|
||||
if (!hasExplicitCoordinates && lastClickedId && lastSnapshot) {
|
||||
const lastCandidate = lastSnapshot.targetCandidates.find(
|
||||
c => c.id === lastClickedId,
|
||||
)
|
||||
if (lastCandidate) {
|
||||
const bridgeConnected = runtime.browserDomBridge?.getStatus().connected ?? false
|
||||
const typeDecision = decideBrowserTypeAction(lastCandidate, bridgeConnected)
|
||||
if (typeDecision.route === 'browser_dom' && typeDecision.selector) {
|
||||
if (
|
||||
typeDecision.route === 'browser_dom'
|
||||
&& typeDecision.selector
|
||||
&& isBrowserDomActionSupported(runtime.browserDomBridge, 'setInputValue')
|
||||
) {
|
||||
try {
|
||||
const frameResults = await runtime.browserDomBridge!.setInputValue({
|
||||
await runtime.browserDomBridge!.setInputValue({
|
||||
selector: typeDecision.selector,
|
||||
value: normalizedAction.input.text,
|
||||
simulateKeystrokes: false,
|
||||
@@ -497,15 +406,6 @@ export function createExecuteAction(runtime: ComputerUseServerRuntime): ExecuteA
|
||||
? [typeDecision.frameId]
|
||||
: undefined,
|
||||
})
|
||||
// NOTICE: bridge resolve ≠ DOM success. Frame results carry
|
||||
// per-frame { success, error } — if none succeeded the
|
||||
// selector/frame was stale and we must fall back to OS typeText.
|
||||
const anySucceeded = Array.isArray(frameResults) && frameResults.some(
|
||||
fr => (fr.result as Record<string, unknown>)?.success === true,
|
||||
)
|
||||
if (!anySucceeded) {
|
||||
throw new Error('setInputValue: no frame reported success')
|
||||
}
|
||||
usedBrowserDom = true
|
||||
backendResult.browserDomRoute = {
|
||||
method: 'setInputValue',
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
import type { ComputerUseServerRuntime } from './runtime'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RunStateManager } from '../state'
|
||||
import {
|
||||
createDisplayInfo,
|
||||
createLocalExecutionTarget,
|
||||
createTerminalState,
|
||||
createTestConfig,
|
||||
} from '../test-fixtures'
|
||||
import { registerChromeSessionTools } from './register-chrome-session'
|
||||
|
||||
type ToolHandler = (args: Record<string, unknown>) => Promise<CallToolResult>
|
||||
|
||||
function createMockServer() {
|
||||
const handlers = new Map<string, ToolHandler>()
|
||||
|
||||
return {
|
||||
server: {
|
||||
tool(name: string, _summaryOrSchema: unknown, schemaOrHandler: unknown, maybeHandler?: ToolHandler) {
|
||||
const handler = (maybeHandler ?? schemaOrHandler) as ToolHandler
|
||||
handlers.set(name, handler)
|
||||
},
|
||||
} as unknown as McpServer,
|
||||
async invoke(name: string, args: Record<string, unknown> = {}) {
|
||||
const handler = handlers.get(name)
|
||||
if (!handler) {
|
||||
throw new Error(`Missing registered tool: ${name}`)
|
||||
}
|
||||
|
||||
return await handler(args)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('registerChromeSessionTools', () => {
|
||||
let runtime: ComputerUseServerRuntime
|
||||
let pendingActions: Array<Record<string, unknown>>
|
||||
|
||||
beforeEach(() => {
|
||||
pendingActions = []
|
||||
|
||||
runtime = {
|
||||
config: createTestConfig({
|
||||
executor: 'macos-local',
|
||||
approvalMode: 'never',
|
||||
}),
|
||||
stateManager: new RunStateManager(),
|
||||
session: {
|
||||
getBudgetState: vi.fn(() => ({ operationsExecuted: 0, operationUnitsConsumed: 0 })),
|
||||
getLastScreenshot: vi.fn(() => undefined),
|
||||
listPendingActions: vi.fn(() => pendingActions),
|
||||
createPendingAction: vi.fn((record: Record<string, unknown>) => {
|
||||
const pending = {
|
||||
...record,
|
||||
id: `pending-${pendingActions.length + 1}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
pendingActions.push(pending)
|
||||
return pending
|
||||
}),
|
||||
record: vi.fn().mockResolvedValue(undefined),
|
||||
consumeOperation: vi.fn(),
|
||||
},
|
||||
executor: {
|
||||
getExecutionTarget: vi.fn().mockResolvedValue(createLocalExecutionTarget({
|
||||
hostName: 'macbook-pro',
|
||||
sessionTag: 'local-session',
|
||||
})),
|
||||
getForegroundContext: vi.fn().mockResolvedValue({
|
||||
available: true,
|
||||
appName: 'Finder',
|
||||
windowTitle: 'Desktop',
|
||||
platform: 'darwin',
|
||||
}),
|
||||
getDisplayInfo: vi.fn().mockResolvedValue(createDisplayInfo({
|
||||
platform: 'darwin',
|
||||
note: 'macOS local display',
|
||||
})),
|
||||
},
|
||||
terminalRunner: {
|
||||
getState: vi.fn(() => createTerminalState({
|
||||
effectiveCwd: '/tmp',
|
||||
})),
|
||||
},
|
||||
browserDomBridge: {
|
||||
getStatus: vi.fn(() => ({
|
||||
enabled: false,
|
||||
connected: false,
|
||||
})),
|
||||
},
|
||||
cdpBridgeManager: {
|
||||
probeAvailability: vi.fn().mockResolvedValue({
|
||||
endpoint: undefined,
|
||||
connected: false,
|
||||
connectable: false,
|
||||
lastError: 'CDP unavailable',
|
||||
}),
|
||||
ensureBridge: vi.fn(),
|
||||
},
|
||||
chromeSessionManager: {
|
||||
getSessionInfo: vi.fn(() => null),
|
||||
ensureAgentWindow: vi.fn(),
|
||||
},
|
||||
desktopSessionController: {
|
||||
getSession: vi.fn(() => null),
|
||||
begin: vi.fn(() => ({ id: 'desktop-session-1' })),
|
||||
addOwnedWindow: vi.fn(),
|
||||
},
|
||||
} as unknown as ComputerUseServerRuntime
|
||||
})
|
||||
|
||||
it('returns approval_required instead of launching Chrome when approvals are enabled', async () => {
|
||||
runtime.config = createTestConfig({
|
||||
executor: 'macos-local',
|
||||
approvalMode: 'all',
|
||||
})
|
||||
|
||||
const { server, invoke } = createMockServer()
|
||||
registerChromeSessionTools({ server, runtime })
|
||||
|
||||
const result = await invoke('desktop_ensure_chrome', {
|
||||
url: 'https://example.com',
|
||||
})
|
||||
|
||||
const structured = result.structuredContent as Record<string, any>
|
||||
expect(structured.status).toBe('approval_required')
|
||||
expect(structured.action).toEqual({
|
||||
kind: 'desktop_ensure_chrome',
|
||||
input: {
|
||||
url: 'https://example.com',
|
||||
},
|
||||
})
|
||||
expect(structured.transparency.intent).toBe('Open an agent Chrome window with CDP support')
|
||||
expect(runtime.chromeSessionManager.ensureAgentWindow).not.toHaveBeenCalled()
|
||||
expect(runtime.session.createPendingAction).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.session.createPendingAction).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: {
|
||||
kind: 'desktop_ensure_chrome',
|
||||
input: {
|
||||
url: 'https://example.com',
|
||||
},
|
||||
},
|
||||
}))
|
||||
expect(runtime.session.consumeOperation).not.toHaveBeenCalled()
|
||||
expect(runtime.stateManager.getState().pendingApprovalCount).toBe(1)
|
||||
})
|
||||
|
||||
it('audits joined Chrome sessions as open_app because ensure can create a new window', async () => {
|
||||
runtime.config = createTestConfig({
|
||||
executor: 'macos-local',
|
||||
approvalMode: 'all',
|
||||
})
|
||||
vi.mocked(runtime.chromeSessionManager.getSessionInfo).mockReturnValue({
|
||||
wasAlreadyRunning: true,
|
||||
windowId: 'chrome-window-existing',
|
||||
pid: 9999,
|
||||
agentOwned: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
const { server, invoke } = createMockServer()
|
||||
registerChromeSessionTools({ server, runtime })
|
||||
|
||||
const result = await invoke('desktop_ensure_chrome')
|
||||
|
||||
const structured = result.structuredContent as Record<string, any>
|
||||
expect(structured.status).toBe('approval_required')
|
||||
expect(structured.action).toEqual({
|
||||
kind: 'desktop_ensure_chrome',
|
||||
input: {},
|
||||
})
|
||||
expect(structured.transparency.intent).toBe('Open an agent Chrome window with CDP support')
|
||||
expect(runtime.chromeSessionManager.ensureAgentWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('consumes operation budget and persists chrome session when approvals are disabled', async () => {
|
||||
vi.mocked(runtime.chromeSessionManager.ensureAgentWindow).mockResolvedValue({
|
||||
wasAlreadyRunning: false,
|
||||
windowId: 'chrome-window-1',
|
||||
pid: 4242,
|
||||
agentOwned: true,
|
||||
initialUrl: 'https://example.com',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
const { server, invoke } = createMockServer()
|
||||
registerChromeSessionTools({ server, runtime })
|
||||
|
||||
const result = await invoke('desktop_ensure_chrome', {
|
||||
url: 'https://example.com',
|
||||
})
|
||||
|
||||
expect(result.isError).not.toBe(true)
|
||||
expect((result.content?.[0] as Record<string, unknown>)?.text).toContain('Chrome session launched')
|
||||
expect(runtime.session.consumeOperation).toHaveBeenCalledWith(2)
|
||||
expect(runtime.stateManager.getState().chromeSession).toMatchObject({
|
||||
windowId: 'chrome-window-1',
|
||||
pid: 4242,
|
||||
})
|
||||
expect(runtime.desktopSessionController.begin).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.session.record).toHaveBeenCalledTimes(2)
|
||||
expect((runtime.session.record as any).mock.calls[0][0].event).toBe('requested')
|
||||
expect((runtime.session.record as any).mock.calls[1][0].event).toBe('executed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* MCP tool registration for `desktop_ensure_chrome`.
|
||||
*
|
||||
* Ensures the agent has a dedicated Chrome window with CDP support.
|
||||
* Idempotent — calling repeatedly returns the existing session.
|
||||
*/
|
||||
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
import type { ActionInvocation, DesktopEnsureChromeApprovalInput, ForegroundContext, PolicyDecision } from '../types'
|
||||
import type { ComputerUseServerRuntime } from './runtime'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { evaluateActionPolicy } from '../policy'
|
||||
import { textContent } from './content'
|
||||
import { refreshRuntimeRunState } from './refresh-run-state'
|
||||
import {
|
||||
buildApprovalResponse,
|
||||
buildDeniedResponse,
|
||||
buildExecutionErrorResponse,
|
||||
} from './responses'
|
||||
import { registerToolWithDescriptor, requireDescriptor } from './tool-descriptors/register-helper'
|
||||
|
||||
const TOOL_NAME = 'desktop_ensure_chrome'
|
||||
const CHROME_APP_NAME = 'Google Chrome'
|
||||
|
||||
function getChromeSessionAction(runtime: ComputerUseServerRuntime): ActionInvocation {
|
||||
const sessionInfo = runtime.chromeSessionManager.getSessionInfo()
|
||||
return {
|
||||
kind: sessionInfo && !sessionInfo.wasAlreadyRunning ? 'focus_app' : 'open_app',
|
||||
input: {
|
||||
app: CHROME_APP_NAME,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeChromeEnsure(
|
||||
runtime: ComputerUseServerRuntime,
|
||||
input: DesktopEnsureChromeApprovalInput,
|
||||
operationUnits?: number,
|
||||
): Promise<CallToolResult> {
|
||||
const sessionInfo = await runtime.chromeSessionManager.ensureAgentWindow({
|
||||
url: input.url,
|
||||
cdpPort: input.cdpPort,
|
||||
})
|
||||
|
||||
// Persist in state
|
||||
runtime.stateManager.updateChromeSession(sessionInfo)
|
||||
|
||||
// Auto-begin a desktop session targeting Chrome
|
||||
// This enables observe/click handlers to use session-based foreground enforcement
|
||||
const sessionCtrl = runtime.desktopSessionController
|
||||
if (!sessionCtrl.getSession()) {
|
||||
const currentForeground = runtime.stateManager.getState().foregroundContext
|
||||
sessionCtrl.begin({
|
||||
controlledApp: 'Google Chrome',
|
||||
currentForeground,
|
||||
})
|
||||
sessionCtrl.addOwnedWindow({
|
||||
appName: 'Google Chrome',
|
||||
windowId: sessionInfo.windowId,
|
||||
pid: sessionInfo.pid,
|
||||
agentLaunched: !sessionInfo.wasAlreadyRunning,
|
||||
})
|
||||
}
|
||||
|
||||
// Record the user's previous foreground app if we just took over
|
||||
const state = runtime.stateManager.getState()
|
||||
if (!state.previousUserForegroundApp && state.foregroundContext?.appName) {
|
||||
const prevApp = state.foregroundContext.appName
|
||||
if (prevApp !== 'Google Chrome') {
|
||||
runtime.stateManager.savePreviousUserForeground(prevApp)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-connect CDP bridge when the agent launched Chrome with CDP.
|
||||
// Best-effort only: Chrome may need a moment before the DevTools server answers.
|
||||
let cdpStatus = 'not applicable'
|
||||
if (sessionInfo.cdpUrl) {
|
||||
try {
|
||||
const probe = await runtime.cdpBridgeManager.probeAvailability(sessionInfo.cdpUrl)
|
||||
if (probe.connectable) {
|
||||
await runtime.cdpBridgeManager.ensureBridge(sessionInfo.cdpUrl)
|
||||
cdpStatus = 'connected'
|
||||
}
|
||||
else {
|
||||
cdpStatus = `probe failed: ${probe.lastError ?? 'no connectable target'}`
|
||||
}
|
||||
}
|
||||
catch (cdpError) {
|
||||
// Non-fatal: agent can still work via os_input / extension bridge
|
||||
cdpStatus = `connect failed: ${cdpError instanceof Error ? cdpError.message : String(cdpError)}`
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`Chrome session ${sessionInfo.wasAlreadyRunning ? 'joined' : 'launched'}:`,
|
||||
` PID: ${sessionInfo.pid}`,
|
||||
` Window: ${sessionInfo.windowId}`,
|
||||
` Agent-owned: ${sessionInfo.agentOwned}`,
|
||||
` Was already running: ${sessionInfo.wasAlreadyRunning}`,
|
||||
]
|
||||
|
||||
if (sessionInfo.cdpUrl) {
|
||||
lines.push(` CDP URL: ${sessionInfo.cdpUrl}`)
|
||||
lines.push(` CDP bridge: ${cdpStatus}`)
|
||||
}
|
||||
|
||||
if (sessionInfo.initialUrl) {
|
||||
lines.push(` Navigated to: ${sessionInfo.initialUrl}`)
|
||||
}
|
||||
|
||||
if (operationUnits !== undefined) {
|
||||
runtime.session.consumeOperation(operationUnits)
|
||||
}
|
||||
|
||||
return {
|
||||
content: [textContent(lines.join('\n'))],
|
||||
structuredContent: {
|
||||
status: 'ok',
|
||||
pid: sessionInfo.pid,
|
||||
windowId: sessionInfo.windowId,
|
||||
agentOwned: sessionInfo.agentOwned,
|
||||
wasAlreadyRunning: sessionInfo.wasAlreadyRunning,
|
||||
cdpUrl: sessionInfo.cdpUrl,
|
||||
cdpStatus,
|
||||
initialUrl: sessionInfo.initialUrl,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function registerChromeSessionTools(params: {
|
||||
server: McpServer
|
||||
runtime: ComputerUseServerRuntime
|
||||
}) {
|
||||
const { server, runtime } = params
|
||||
|
||||
registerToolWithDescriptor(server, {
|
||||
descriptor: requireDescriptor('desktop_ensure_chrome'),
|
||||
|
||||
schema: {
|
||||
url: z.string().optional().describe('Optional URL to navigate to in the new Chrome window.'),
|
||||
cdpPort: z.number().int().min(1024).max(65535).optional().describe('CDP debugging port (default: 9222).'),
|
||||
},
|
||||
|
||||
handler: async ({ url, cdpPort }) => {
|
||||
const policyAction = getChromeSessionAction(runtime)
|
||||
const ensureAction = {
|
||||
kind: TOOL_NAME,
|
||||
input: {
|
||||
...(url !== undefined ? { url } : {}),
|
||||
...(cdpPort !== undefined ? { cdpPort } : {}),
|
||||
},
|
||||
} satisfies { kind: 'desktop_ensure_chrome', input: DesktopEnsureChromeApprovalInput }
|
||||
let decision: PolicyDecision | undefined
|
||||
let context: ForegroundContext | undefined
|
||||
let executionTarget: Awaited<ReturnType<typeof refreshRuntimeRunState>>['executionTarget'] | undefined
|
||||
|
||||
try {
|
||||
const refreshed = await refreshRuntimeRunState(runtime)
|
||||
context = refreshed.context
|
||||
executionTarget = refreshed.executionTarget
|
||||
|
||||
const budget = runtime.session.getBudgetState()
|
||||
decision = evaluateActionPolicy({
|
||||
action: policyAction,
|
||||
config: runtime.config,
|
||||
context,
|
||||
operationsExecuted: budget.operationsExecuted,
|
||||
operationUnitsConsumed: budget.operationUnitsConsumed,
|
||||
})
|
||||
runtime.stateManager.updatePolicyDecision(decision)
|
||||
|
||||
await runtime.session.record({
|
||||
event: 'requested',
|
||||
toolName: TOOL_NAME,
|
||||
action: ensureAction,
|
||||
context,
|
||||
policy: decision,
|
||||
result: {
|
||||
approvalAction: policyAction,
|
||||
executionTarget,
|
||||
},
|
||||
})
|
||||
|
||||
if (!decision.allowed) {
|
||||
await runtime.session.record({
|
||||
event: 'denied',
|
||||
toolName: TOOL_NAME,
|
||||
action: ensureAction,
|
||||
context,
|
||||
policy: decision,
|
||||
result: {
|
||||
approvalAction: policyAction,
|
||||
executionTarget,
|
||||
},
|
||||
})
|
||||
|
||||
return buildDeniedResponse(decision, context, executionTarget)
|
||||
}
|
||||
|
||||
if (decision.requiresApproval) {
|
||||
const pending = runtime.session.createPendingAction({
|
||||
toolName: TOOL_NAME,
|
||||
action: ensureAction,
|
||||
context,
|
||||
policy: decision,
|
||||
})
|
||||
runtime.stateManager.setPendingApprovalCount(runtime.session.listPendingActions().length)
|
||||
|
||||
await runtime.session.record({
|
||||
event: 'approval_required',
|
||||
toolName: TOOL_NAME,
|
||||
action: ensureAction,
|
||||
context,
|
||||
policy: decision,
|
||||
result: {
|
||||
approvalAction: policyAction,
|
||||
executionTarget,
|
||||
pendingActionId: pending.id,
|
||||
},
|
||||
})
|
||||
|
||||
return buildApprovalResponse(pending, decision, context, {
|
||||
intent: policyAction.kind === 'open_app'
|
||||
? 'Open an agent Chrome window with CDP support'
|
||||
: 'Bring the agent Chrome window to the foreground',
|
||||
approvalReason: 'Starting or foregrounding Chrome is a mutating desktop action and follows the same approval and audit pipeline as other app-control tools.',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await executeChromeEnsure(runtime, ensureAction.input, decision.estimatedOperationUnits)
|
||||
await runtime.session.record({
|
||||
event: 'executed',
|
||||
toolName: TOOL_NAME,
|
||||
action: ensureAction,
|
||||
context,
|
||||
policy: decision,
|
||||
result: {
|
||||
approvalAction: policyAction,
|
||||
executionTarget,
|
||||
...(typeof result.structuredContent === 'object' && result.structuredContent !== null
|
||||
? result.structuredContent as Record<string, unknown>
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
catch (error) {
|
||||
const message = errorMessageFrom(error) ?? 'Unknown desktop_ensure_chrome failure'
|
||||
|
||||
if (decision && context && executionTarget) {
|
||||
await runtime.session.record({
|
||||
event: 'failed',
|
||||
toolName: TOOL_NAME,
|
||||
action: ensureAction,
|
||||
context,
|
||||
policy: decision,
|
||||
result: {
|
||||
executionTarget,
|
||||
error: message,
|
||||
},
|
||||
})
|
||||
|
||||
return buildExecutionErrorResponse({
|
||||
errorMessage: message,
|
||||
action: policyAction,
|
||||
context,
|
||||
executionTarget,
|
||||
policy: decision,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
content: [textContent(`desktop_ensure_chrome failed: ${message}`)],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -52,8 +52,17 @@ function createRuntime() {
|
||||
getStatus: vi.fn().mockReturnValue({ connected: false }),
|
||||
ensureBridge: vi.fn(),
|
||||
},
|
||||
chromeSessionManager: {
|
||||
getSessionInfo: vi.fn().mockReturnValue(undefined),
|
||||
},
|
||||
browserDomBridge: {},
|
||||
executor: {},
|
||||
desktopSessionController: {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
getSessionInfo: vi.fn().mockReturnValue(undefined),
|
||||
touch: vi.fn(),
|
||||
ensureControlledAppInForeground: vi.fn(),
|
||||
},
|
||||
} as unknown as ComputerUseServerRuntime
|
||||
}
|
||||
|
||||
@@ -62,39 +71,15 @@ describe('registerDesktopGroundingTools', () => {
|
||||
captureDesktopGroundingMock.mockReset()
|
||||
})
|
||||
|
||||
it('routes desktop_click_target through executeAction instead of calling the executor directly', async () => {
|
||||
it('registers desktop_click_target and handles missing candidate gracefully', async () => {
|
||||
const runtime = createRuntime()
|
||||
const executeAction = vi.fn().mockResolvedValue({
|
||||
structuredContent: { status: 'approval_required' },
|
||||
content: [{ type: 'text', text: 'approval required' }],
|
||||
})
|
||||
|
||||
const { server, invoke } = createMockServer()
|
||||
|
||||
registerDesktopGroundingTools({ server, runtime, executeAction })
|
||||
registerDesktopGroundingTools({ server, runtime })
|
||||
|
||||
const result = await invoke('desktop_click_target', {
|
||||
candidateId: 't_0',
|
||||
clickCount: 2,
|
||||
button: 'right',
|
||||
})
|
||||
|
||||
expect(executeAction).toHaveBeenCalledWith({
|
||||
kind: 'desktop_click_target',
|
||||
input: {
|
||||
candidateId: 't_0',
|
||||
clickCount: 2,
|
||||
button: 'right',
|
||||
},
|
||||
}, 'desktop_click_target')
|
||||
expect(result).toMatchObject({
|
||||
structuredContent: { status: 'approval_required' },
|
||||
})
|
||||
})
|
||||
|
||||
it('clears stale grounding state when desktop_observe fails', async () => {
|
||||
const runtime = createRuntime()
|
||||
runtime.stateManager.updateGroundingSnapshot({
|
||||
snapshotId: 'dg_old',
|
||||
snapshotId: 'dg_1',
|
||||
capturedAt: new Date().toISOString(),
|
||||
foregroundApp: 'Google Chrome',
|
||||
windows: [],
|
||||
@@ -102,25 +87,33 @@ describe('registerDesktopGroundingTools', () => {
|
||||
targetCandidates: [],
|
||||
staleFlags: { screenshot: false, ax: false, chromeSemantic: false },
|
||||
} as any)
|
||||
|
||||
const result = await invoke('desktop_click_target', {
|
||||
candidateId: 't_missing',
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([
|
||||
expect.objectContaining({ text: expect.stringContaining('Candidate "t_missing" not found in snapshot') }),
|
||||
])
|
||||
})
|
||||
|
||||
it('returns observe error content when captureDesktopGrounding fails', async () => {
|
||||
const runtime = createRuntime()
|
||||
captureDesktopGroundingMock.mockRejectedValueOnce(new Error('observe boom'))
|
||||
|
||||
const { server, invoke } = createMockServer()
|
||||
|
||||
registerDesktopGroundingTools({
|
||||
server,
|
||||
runtime,
|
||||
executeAction: vi.fn(),
|
||||
})
|
||||
registerDesktopGroundingTools({ server, runtime })
|
||||
|
||||
const result = await invoke('desktop_observe', {})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(runtime.stateManager.getState().lastGroundingSnapshot).toBeUndefined()
|
||||
expect(runtime.stateManager.getState().lastPointerIntent).toBeUndefined()
|
||||
expect(runtime.stateManager.getState().lastClickedCandidateId).toBeUndefined()
|
||||
expect(result.content).toEqual([
|
||||
expect.objectContaining({ text: expect.stringContaining('observe boom') }),
|
||||
])
|
||||
})
|
||||
|
||||
it('stores grounding snapshot without screenshot bytes but still returns image content', async () => {
|
||||
it('stores grounding snapshot and returns image content', async () => {
|
||||
const runtime = createRuntime()
|
||||
captureDesktopGroundingMock.mockResolvedValueOnce({
|
||||
snapshotId: 'dg_new',
|
||||
@@ -140,17 +133,12 @@ describe('registerDesktopGroundingTools', () => {
|
||||
} as any)
|
||||
|
||||
const { server, invoke } = createMockServer()
|
||||
|
||||
registerDesktopGroundingTools({
|
||||
server,
|
||||
runtime,
|
||||
executeAction: vi.fn(),
|
||||
})
|
||||
registerDesktopGroundingTools({ server, runtime })
|
||||
|
||||
const result = await invoke('desktop_observe', {})
|
||||
const state = runtime.stateManager.getState()
|
||||
|
||||
expect(state.lastGroundingSnapshot?.screenshot.dataBase64).toBe('')
|
||||
expect(state.lastGroundingSnapshot?.screenshot.dataBase64).toBe('ZmFrZS1wbmc=')
|
||||
expect(result.content).toEqual([
|
||||
expect.objectContaining({ type: 'text' }),
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type {
|
||||
DesktopGroundingSnapshot,
|
||||
DesktopTargetCandidate,
|
||||
PointerIntent,
|
||||
TargetSource,
|
||||
} from '../desktop-grounding-types'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getUnsupportedBrowserDomActions, isBrowserDomActionSupported } from '../browser-dom/capabilities'
|
||||
import { RunStateManager } from '../state'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -68,8 +70,7 @@ describe('runStateManager grounding state', () => {
|
||||
source: 'chrome_dom' as TargetSource,
|
||||
confidence: 0.95,
|
||||
path: [{ x: 140, y: 215, delayMs: 0 }],
|
||||
}, 't_test')
|
||||
sm.recordClickedCandidate('t_0')
|
||||
}, 't_0')
|
||||
|
||||
expect(sm.getState().lastClickedCandidateId).toBe('t_0')
|
||||
|
||||
@@ -89,8 +90,7 @@ describe('runStateManager grounding state', () => {
|
||||
confidence: 0.9,
|
||||
path: [{ x: 330, y: 213, delayMs: 0 }],
|
||||
}
|
||||
sm.updatePointerIntent(intent, 't_test')
|
||||
sm.recordClickedCandidate('t_1')
|
||||
sm.updatePointerIntent(intent, 't_1')
|
||||
|
||||
const state = sm.getState()
|
||||
expect(state.lastPointerIntent).toBe(intent)
|
||||
@@ -108,8 +108,7 @@ describe('runStateManager grounding state', () => {
|
||||
source: 'chrome_dom' as TargetSource,
|
||||
confidence: 0.95,
|
||||
path: [{ x: 140, y: 215, delayMs: 0 }],
|
||||
}, 't_test')
|
||||
sm.recordClickedCandidate('t_0')
|
||||
}, 't_0')
|
||||
|
||||
sm.clearGroundingState()
|
||||
|
||||
@@ -138,8 +137,7 @@ describe('desktop_click_target preconditions via RunStateManager', () => {
|
||||
source: 'chrome_dom' as TargetSource,
|
||||
confidence: 0.95,
|
||||
path: [{ x: 140, y: 215, delayMs: 0 }],
|
||||
}, 't_test')
|
||||
sm.recordClickedCandidate('t_0')
|
||||
}, 't_0')
|
||||
|
||||
expect(sm.getState().lastClickedCandidateId === 't_0').toBe(true)
|
||||
})
|
||||
@@ -158,8 +156,7 @@ describe('desktop_click_target preconditions via RunStateManager', () => {
|
||||
source: 'chrome_dom' as TargetSource,
|
||||
confidence: 0.95,
|
||||
path: [{ x: 140, y: 215, delayMs: 0 }],
|
||||
}, 't_test')
|
||||
sm.recordClickedCandidate('t_0')
|
||||
}, 't_0')
|
||||
|
||||
expect(sm.getState().lastClickedCandidateId === 't_1').toBe(false)
|
||||
})
|
||||
@@ -175,8 +172,7 @@ describe('desktop_click_target preconditions via RunStateManager', () => {
|
||||
source: 'chrome_dom' as TargetSource,
|
||||
confidence: 0.95,
|
||||
path: [{ x: 140, y: 215, delayMs: 0 }],
|
||||
}, 't_test')
|
||||
sm.recordClickedCandidate('t_0')
|
||||
}, 't_0')
|
||||
|
||||
// Re-observe resets clicked candidate
|
||||
sm.updateGroundingSnapshot(makeSnapshot())
|
||||
@@ -241,8 +237,7 @@ describe('overlay polling contract: desktop_get_state exposes grounding data', (
|
||||
source: 'chrome_dom' as TargetSource,
|
||||
confidence: 0.95,
|
||||
path: [{ x: 140, y: 215, delayMs: 0 }],
|
||||
}, 't_test')
|
||||
sm.recordClickedCandidate('t_0')
|
||||
}, 't_0')
|
||||
|
||||
const state = sm.getState()
|
||||
expect(state.lastPointerIntent).toBeDefined()
|
||||
@@ -280,6 +275,7 @@ describe('desktop_click_target handler integration', () => {
|
||||
clickCount?: number
|
||||
browserDomBridge: {
|
||||
getStatus: () => { connected: boolean }
|
||||
supportsAction?: (action: string) => boolean
|
||||
clickSelector: (args: { selector: string, frameIds?: number[] }) => Promise<void>
|
||||
checkCheckbox: (args: { selector: string, frameIds?: number[] }) => Promise<void>
|
||||
}
|
||||
@@ -308,45 +304,72 @@ describe('desktop_click_target handler integration', () => {
|
||||
return { isError: true, text: `Stale snapshot (${Math.round(snapshotAge / 1000)}s)` }
|
||||
}
|
||||
|
||||
const snap = resolveSnapByCandidate(candidateId, snapshot)
|
||||
if (snap.source === 'none' && !snap.candidateId) {
|
||||
return { isError: true, text: `Not found: ${candidateId}` }
|
||||
}
|
||||
try {
|
||||
const snap = resolveSnapByCandidate(candidateId, snapshot)
|
||||
if (snap.source === 'none' && !snap.candidateId) {
|
||||
return { isError: true, text: `Not found: ${candidateId}` }
|
||||
}
|
||||
|
||||
const intent = {
|
||||
mode: 'execute' as const,
|
||||
candidateId,
|
||||
rawPoint: snap.rawPoint,
|
||||
snappedPoint: snap.snappedPoint,
|
||||
source: snap.source,
|
||||
confidence: snapshot.targetCandidates.find(c => c.id === candidateId)?.confidence ?? 0,
|
||||
path: [{ x: snap.snappedPoint.x, y: snap.snappedPoint.y, delayMs: 0 }],
|
||||
}
|
||||
stateManager.updatePointerIntent(intent, 't_test')
|
||||
stateManager.recordClickedCandidate(candidateId)
|
||||
const intent: PointerIntent = {
|
||||
mode: 'execute' as const,
|
||||
candidateId,
|
||||
rawPoint: snap.rawPoint,
|
||||
snappedPoint: snap.snappedPoint,
|
||||
source: snap.source,
|
||||
confidence: snapshot.targetCandidates.find(c => c.id === candidateId)?.confidence ?? 0,
|
||||
path: [{ x: snap.snappedPoint.x, y: snap.snappedPoint.y, delayMs: 0 }],
|
||||
}
|
||||
stateManager.updatePointerIntent(intent)
|
||||
|
||||
const candidate = snapshot.targetCandidates.find(c => c.id === candidateId)
|
||||
const bridgeConnected = browserDomBridge.getStatus().connected
|
||||
const routeDecision = candidate
|
||||
? decideBrowserAction(candidate, bridgeConnected)
|
||||
: { route: 'os_input' as const, reason: 'candidate not found' }
|
||||
const candidate = snapshot.targetCandidates.find(c => c.id === candidateId)
|
||||
const bridgeConnected = browserDomBridge.getStatus().connected
|
||||
const routeDecision = candidate
|
||||
? decideBrowserAction(candidate, bridgeConnected)
|
||||
: { route: 'os_input' as const, reason: 'candidate not found' }
|
||||
|
||||
let executionRoute = routeDecision.route
|
||||
let routeNote = ''
|
||||
let executionRoute = routeDecision.route
|
||||
let routeNote = ''
|
||||
let routeReason = routeDecision.reason
|
||||
|
||||
if (routeDecision.route === 'browser_dom' && routeDecision.selector) {
|
||||
try {
|
||||
const frameIds = routeDecision.frameId !== undefined ? [routeDecision.frameId] : undefined
|
||||
if (routeDecision.bridgeMethod === 'checkCheckbox') {
|
||||
await browserDomBridge.checkCheckbox({ selector: routeDecision.selector, frameIds })
|
||||
if (routeDecision.route === 'browser_dom' && routeDecision.selector) {
|
||||
const requiredActions = routeDecision.bridgeMethod === 'checkCheckbox'
|
||||
? ['checkCheckbox']
|
||||
: ['getClickTarget', 'clickAt']
|
||||
|
||||
if (!isBrowserDomActionSupported(browserDomBridge, ...requiredActions)) {
|
||||
executionRoute = 'os_input'
|
||||
routeReason = `browser-dom extension transport does not support ${requiredActions.join(' + ')}`
|
||||
routeNote = `browser-dom ${routeDecision.bridgeMethod ?? 'click'} is unavailable on the connected extension transport (${getUnsupportedBrowserDomActions(browserDomBridge, ...requiredActions).join(', ')} unsupported), fell back to OS input`
|
||||
await executor.click({
|
||||
x: snap.snappedPoint.x,
|
||||
y: snap.snappedPoint.y,
|
||||
button: button || 'left',
|
||||
clickCount: clickCount ?? 1,
|
||||
})
|
||||
}
|
||||
else {
|
||||
await browserDomBridge.clickSelector({ selector: routeDecision.selector, frameIds })
|
||||
try {
|
||||
const frameIds = routeDecision.frameId !== undefined ? [routeDecision.frameId] : undefined
|
||||
if (routeDecision.bridgeMethod === 'checkCheckbox') {
|
||||
await browserDomBridge.checkCheckbox({ selector: routeDecision.selector, frameIds })
|
||||
}
|
||||
else {
|
||||
await browserDomBridge.clickSelector({ selector: routeDecision.selector, frameIds })
|
||||
}
|
||||
}
|
||||
catch (browserError) {
|
||||
executionRoute = 'os_input'
|
||||
routeNote = `browser-dom failed: ${browserError instanceof Error ? browserError.message : String(browserError)}`
|
||||
await executor.click({
|
||||
x: snap.snappedPoint.x,
|
||||
y: snap.snappedPoint.y,
|
||||
button: button || 'left',
|
||||
clickCount: clickCount ?? 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (browserError) {
|
||||
executionRoute = 'os_input'
|
||||
routeNote = `browser-dom failed: ${browserError instanceof Error ? browserError.message : String(browserError)}`
|
||||
else {
|
||||
await executor.click({
|
||||
x: snap.snappedPoint.x,
|
||||
y: snap.snappedPoint.y,
|
||||
@@ -354,31 +377,32 @@ describe('desktop_click_target handler integration', () => {
|
||||
clickCount: clickCount ?? 1,
|
||||
})
|
||||
}
|
||||
|
||||
intent.phase = 'completed'
|
||||
intent.executionResult = routeNote ? 'fallback' : 'success'
|
||||
intent.executionRoute = `${executionRoute} (${routeReason})`
|
||||
stateManager.updatePointerIntent(intent, candidateId)
|
||||
|
||||
const candidateDesc = candidate
|
||||
? `${candidate.source} ${candidate.role} "${candidate.label}"`
|
||||
: candidateId
|
||||
|
||||
const lines = [
|
||||
`Clicked: ${candidateDesc}`,
|
||||
` Snap: ${snap.reason}`,
|
||||
` Point: (${snap.snappedPoint.x}, ${snap.snappedPoint.y})`,
|
||||
` Route: ${executionRoute} (${routeReason})`,
|
||||
` Button: ${button || 'left'}, clicks: ${clickCount ?? 1}`,
|
||||
]
|
||||
if (routeNote)
|
||||
lines.push(` ⚠ ${routeNote}`)
|
||||
|
||||
return { isError: false, text: lines.join('\n'), executionRoute, routeNote, routeReason }
|
||||
}
|
||||
else {
|
||||
await executor.click({
|
||||
x: snap.snappedPoint.x,
|
||||
y: snap.snappedPoint.y,
|
||||
button: button || 'left',
|
||||
clickCount: clickCount ?? 1,
|
||||
})
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { isError: true, text: `desktop_click_target failed: ${message}` }
|
||||
}
|
||||
|
||||
const candidateDesc = candidate
|
||||
? `${candidate.source} ${candidate.role} "${candidate.label}"`
|
||||
: candidateId
|
||||
|
||||
const lines = [
|
||||
`Clicked: ${candidateDesc}`,
|
||||
` Snap: ${snap.reason}`,
|
||||
` Point: (${snap.snappedPoint.x}, ${snap.snappedPoint.y})`,
|
||||
` Route: ${executionRoute} (${routeDecision.reason})`,
|
||||
` Button: ${button || 'left'}, clicks: ${clickCount ?? 1}`,
|
||||
]
|
||||
if (routeNote)
|
||||
lines.push(` ⚠ ${routeNote}`)
|
||||
|
||||
return { isError: false, text: lines.join('\n'), executionRoute, routeNote }
|
||||
}
|
||||
|
||||
function freshSnapshot(candidates: DesktopTargetCandidate[]): DesktopGroundingSnapshot {
|
||||
@@ -396,6 +420,7 @@ describe('desktop_click_target handler integration', () => {
|
||||
function makeMockBridge(connected: boolean) {
|
||||
return {
|
||||
getStatus: () => ({ connected }),
|
||||
supportsAction: vi.fn().mockReturnValue(true),
|
||||
clickSelector: vi.fn().mockResolvedValue(undefined),
|
||||
checkCheckbox: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
@@ -443,6 +468,35 @@ describe('desktop_click_target handler integration', () => {
|
||||
expect(result.text).toContain('Route: browser_dom')
|
||||
})
|
||||
|
||||
it('falls back to OS click when the connected extension transport is read-only', async () => {
|
||||
const sm = new RunStateManager()
|
||||
const candidate = makeCandidate({
|
||||
id: 't_0',
|
||||
source: 'chrome_dom',
|
||||
selector: '#login-btn',
|
||||
frameId: 0,
|
||||
isPageContent: true,
|
||||
})
|
||||
sm.updateGroundingSnapshot(freshSnapshot([candidate]))
|
||||
|
||||
const bridge = makeMockBridge(true)
|
||||
bridge.supportsAction.mockImplementation((action: string) => action !== 'clickAt')
|
||||
const executor = makeMockExecutor()
|
||||
|
||||
const result = await simulateClickTargetHandler({
|
||||
stateManager: sm,
|
||||
candidateId: 't_0',
|
||||
browserDomBridge: bridge,
|
||||
executor,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.executionRoute).toBe('os_input')
|
||||
expect(result.routeReason).toContain('does not support getClickTarget + clickAt')
|
||||
expect(bridge.clickSelector).not.toHaveBeenCalled()
|
||||
expect(executor.click).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// browser_dom fallback: clickSelector fails → executor.click
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -477,6 +531,40 @@ describe('desktop_click_target handler integration', () => {
|
||||
expect(result.text).toContain('Element not found')
|
||||
})
|
||||
|
||||
it('does not poison duplicate-click guard when the click path fails', async () => {
|
||||
const sm = new RunStateManager()
|
||||
const candidate = makeCandidate({
|
||||
id: 't_0',
|
||||
source: 'ax',
|
||||
selector: undefined,
|
||||
})
|
||||
sm.updateGroundingSnapshot(freshSnapshot([candidate]))
|
||||
|
||||
const bridge = makeMockBridge(true)
|
||||
const executor = {
|
||||
click: vi.fn().mockRejectedValue(new Error('transient click failure')),
|
||||
}
|
||||
|
||||
const first = await simulateClickTargetHandler({
|
||||
stateManager: sm,
|
||||
candidateId: 't_0',
|
||||
browserDomBridge: bridge,
|
||||
executor,
|
||||
})
|
||||
expect(first.isError).toBe(true)
|
||||
expect(sm.getState().lastClickedCandidateId).toBeUndefined()
|
||||
|
||||
executor.click.mockResolvedValueOnce({})
|
||||
const second = await simulateClickTargetHandler({
|
||||
stateManager: sm,
|
||||
candidateId: 't_0',
|
||||
browserDomBridge: bridge,
|
||||
executor,
|
||||
})
|
||||
expect(second.isError).toBe(false)
|
||||
expect(sm.getState().lastClickedCandidateId).toBe('t_0')
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// checkbox: routes to checkCheckbox, not clickSelector
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
|
||||
import type { PointerIntent } from '../desktop-grounding-types'
|
||||
import type { ExecuteAction } from './action-executor'
|
||||
import type { ComputerUseServerRuntime } from './runtime'
|
||||
|
||||
import process from 'node:process'
|
||||
@@ -23,8 +22,10 @@ import process from 'node:process'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { decideBrowserAction } from '../browser-action-router'
|
||||
import { getUnsupportedBrowserDomActions, isBrowserDomActionSupported } from '../browser-dom/capabilities'
|
||||
import { captureDesktopGrounding, formatGroundingForAgent } from '../desktop-grounding'
|
||||
import { resolveSnapByCandidate } from '../snap-resolver'
|
||||
import { sleep } from '../utils/sleep'
|
||||
import { textContent } from './content'
|
||||
import { registerToolWithDescriptor, requireDescriptor } from './tool-descriptors/register-helper'
|
||||
|
||||
@@ -39,9 +40,8 @@ import { registerToolWithDescriptor, requireDescriptor } from './tool-descriptor
|
||||
export function registerDesktopGroundingTools(params: {
|
||||
server: McpServer
|
||||
runtime: ComputerUseServerRuntime
|
||||
executeAction: ExecuteAction
|
||||
}) {
|
||||
const { server, runtime, executeAction } = params
|
||||
const { server, runtime } = params
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// desktop_observe
|
||||
@@ -56,16 +56,61 @@ export function registerDesktopGroundingTools(params: {
|
||||
|
||||
handler: async ({ includeChrome }) => {
|
||||
try {
|
||||
// Try to get an existing CDP bridge (non-fatal if unavailable)
|
||||
// If the agent has a desktop session with a controlled app,
|
||||
// ensure that app is in the foreground before observing.
|
||||
// Falls back to Chrome session check for backward compatibility.
|
||||
const sessionCtrl = runtime.desktopSessionController
|
||||
const activeSession = sessionCtrl.getSession()
|
||||
if (activeSession?.controlledApp) {
|
||||
const currentForeground = await runtime.executor.getForegroundContext()
|
||||
const wasAlreadyInFront = await sessionCtrl.ensureControlledAppInForeground({
|
||||
currentForeground,
|
||||
chromeSessionManager: runtime.chromeSessionManager,
|
||||
activateApp: async (appName) => {
|
||||
await runtime.executor.focusApp({ app: appName })
|
||||
},
|
||||
})
|
||||
if (!wasAlreadyInFront) {
|
||||
await sleep(300)
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Fallback: Chrome session without desktop session
|
||||
const chromeSession = runtime.chromeSessionManager.getSessionInfo()
|
||||
if (chromeSession) {
|
||||
const currentForeground = await runtime.executor.getForegroundContext()
|
||||
if (currentForeground.available && currentForeground.appName !== 'Google Chrome') {
|
||||
if (currentForeground.appName) {
|
||||
runtime.stateManager.savePreviousUserForeground(currentForeground.appName)
|
||||
}
|
||||
const activated = await runtime.chromeSessionManager.bringToFront()
|
||||
if (!activated) {
|
||||
throw new Error('Chrome session is unavailable; call desktop_ensure_chrome before observing Chrome.')
|
||||
}
|
||||
await sleep(300)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get or reconnect a CDP bridge.
|
||||
// NOTICE: `desktop_ensure_chrome` can launch Chrome before its DevTools
|
||||
// endpoint is fully ready. When observe runs later, reconnect from the
|
||||
// recorded session URL instead of staying stuck in AX-only mode.
|
||||
let cdpBridge: import('../browser-dom/cdp-bridge').CdpBridge | undefined
|
||||
try {
|
||||
const status = runtime.cdpBridgeManager.getStatus()
|
||||
if (status.connected) {
|
||||
const cdpStatus = runtime.cdpBridgeManager.getStatus()
|
||||
if (cdpStatus.connected) {
|
||||
cdpBridge = await runtime.cdpBridgeManager.ensureBridge()
|
||||
}
|
||||
else {
|
||||
const chromeSession = runtime.chromeSessionManager.getSessionInfo()
|
||||
if (chromeSession?.cdpUrl) {
|
||||
cdpBridge = await runtime.cdpBridgeManager.ensureBridge(chromeSession.cdpUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// CDP bridge unavailable — graceful degradation
|
||||
// CDP bridge unavailable — graceful degradation to extension or AX
|
||||
}
|
||||
|
||||
const snapshot = await captureDesktopGrounding({
|
||||
@@ -93,10 +138,17 @@ export function registerDesktopGroundingTools(params: {
|
||||
|
||||
// Update foreground context from the observation
|
||||
if (snapshot.foregroundApp && snapshot.foregroundApp !== 'unknown') {
|
||||
const chromeSession = runtime.chromeSessionManager.getSessionInfo()
|
||||
const isAgentOwned = chromeSession
|
||||
? snapshot.foregroundApp === 'Google Chrome'
|
||||
: false
|
||||
|
||||
runtime.stateManager.updateForegroundContext({
|
||||
available: true,
|
||||
appName: snapshot.foregroundApp,
|
||||
platform: process.platform,
|
||||
agentOwned: isAgentOwned,
|
||||
agentWindowPid: isAgentOwned ? chromeSession?.pid : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,6 +206,24 @@ export function registerDesktopGroundingTools(params: {
|
||||
|
||||
const snapshot = state.lastGroundingSnapshot
|
||||
|
||||
// Session: ensure the controlled app is still in foreground before clicking
|
||||
const sessionCtrl = runtime.desktopSessionController
|
||||
const activeSession = sessionCtrl.getSession()
|
||||
if (activeSession?.controlledApp) {
|
||||
const currentForeground = await runtime.executor.getForegroundContext()
|
||||
const wasAlreadyInFront = await sessionCtrl.ensureControlledAppInForeground({
|
||||
currentForeground,
|
||||
chromeSessionManager: runtime.chromeSessionManager,
|
||||
activateApp: async (appName) => {
|
||||
await runtime.executor.focusApp({ app: appName })
|
||||
},
|
||||
})
|
||||
if (!wasAlreadyInFront) {
|
||||
await sleep(200)
|
||||
}
|
||||
sessionCtrl.touch()
|
||||
}
|
||||
|
||||
// Validate: check for duplicate clicks on same candidate without re-observe
|
||||
if (state.lastClickedCandidateId === candidateId) {
|
||||
return {
|
||||
@@ -194,12 +264,11 @@ export function registerDesktopGroundingTools(params: {
|
||||
],
|
||||
}
|
||||
|
||||
// Update RunState — pointer intent
|
||||
runtime.stateManager.updatePointerIntent(intent, candidateId)
|
||||
// Update RunState — pointer intent + clicked candidate (phase: executing)
|
||||
intent.phase = 'executing'
|
||||
runtime.stateManager.updatePointerIntent(intent)
|
||||
|
||||
// Route the click: browser-dom for chrome_dom candidates, OS input for everything else.
|
||||
// Pass button and clickCount so non-left or multi-click requests fall through to OS input
|
||||
// rather than silently degrading to a single left click on the browser-dom path.
|
||||
// Route the click: browser-dom for chrome_dom candidates, OS input for everything else
|
||||
const candidate = snapshot.targetCandidates.find(c => c.id === candidateId)
|
||||
const bridgeConnected = runtime.browserDomBridge?.getStatus().connected ?? false
|
||||
const routeDecision = candidate
|
||||
@@ -208,84 +277,72 @@ export function registerDesktopGroundingTools(params: {
|
||||
|
||||
let executionRoute = routeDecision.route
|
||||
let routeNote = ''
|
||||
let routeReason = routeDecision.reason
|
||||
|
||||
if (routeDecision.route === 'browser_dom' && routeDecision.selector) {
|
||||
// Try browser-dom bridge action first, dispatching by method
|
||||
try {
|
||||
const frameIds = routeDecision.frameId !== undefined ? [routeDecision.frameId] : undefined
|
||||
if (routeDecision.bridgeMethod === 'checkCheckbox') {
|
||||
const frameResults = await runtime.browserDomBridge!.checkCheckbox({
|
||||
selector: routeDecision.selector,
|
||||
frameIds,
|
||||
})
|
||||
// NOTICE: bridge resolve ≠ DOM success. Each frame returns
|
||||
// { success, error } — if none succeeded the selector/frame was
|
||||
// stale and we must fall back to OS click.
|
||||
const anySucceeded = Array.isArray(frameResults) && frameResults.some(
|
||||
fr => (fr.result as Record<string, unknown>)?.success === true,
|
||||
)
|
||||
if (!anySucceeded) {
|
||||
throw new Error('checkCheckbox: no frame reported success')
|
||||
}
|
||||
}
|
||||
else {
|
||||
const clickResult = await runtime.browserDomBridge!.clickSelector({
|
||||
selector: routeDecision.selector,
|
||||
frameIds,
|
||||
})
|
||||
// NOTICE: clickSelector resolves even when clickAt hits no element.
|
||||
// Check per-frame results; if none succeeded, fall back to OS click.
|
||||
const clickFrames = clickResult?.clickResults
|
||||
const anyClickSucceeded = Array.isArray(clickFrames) && clickFrames.some(
|
||||
fr => (fr.result as Record<string, unknown>)?.success === true,
|
||||
)
|
||||
if (!anyClickSucceeded) {
|
||||
throw new Error('clickSelector: no frame reported a successful click')
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (browserError) {
|
||||
// Fallback to OS input on browser-dom failure; still goes through policy pipeline
|
||||
const requiredActions = routeDecision.bridgeMethod === 'checkCheckbox'
|
||||
? ['checkCheckbox']
|
||||
: ['getClickTarget', 'clickAt']
|
||||
|
||||
if (!isBrowserDomActionSupported(runtime.browserDomBridge, ...requiredActions)) {
|
||||
executionRoute = 'os_input'
|
||||
routeNote = `browser-dom ${routeDecision.bridgeMethod ?? 'click'} failed (${browserError instanceof Error ? browserError.message : String(browserError)}), fell back to OS input`
|
||||
const actionResult = await executeAction({
|
||||
kind: 'click',
|
||||
input: {
|
||||
x: snap.snappedPoint.x,
|
||||
y: snap.snappedPoint.y,
|
||||
button: button || 'left',
|
||||
clickCount: clickCount ?? 1,
|
||||
},
|
||||
}, 'desktop_click_target')
|
||||
// If the action was denied or queued for approval, relay the policy result
|
||||
// and do not report a false success or update post-click state.
|
||||
const status = (actionResult.structuredContent as Record<string, unknown> | undefined)?.status
|
||||
if (actionResult.isError || status === 'approval_required' || status === 'denied') {
|
||||
return actionResult
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// OS-level click through policy pipeline — respects approvalMode and policy gates
|
||||
const actionResult = await executeAction({
|
||||
kind: 'click',
|
||||
input: {
|
||||
routeReason = `browser-dom extension transport does not support ${requiredActions.join(' + ')}`
|
||||
routeNote = `browser-dom ${routeDecision.bridgeMethod ?? 'click'} is unavailable on the connected extension transport (${getUnsupportedBrowserDomActions(runtime.browserDomBridge, ...requiredActions).join(', ')} unsupported), fell back to OS input`
|
||||
await runtime.executor.click({
|
||||
x: snap.snappedPoint.x,
|
||||
y: snap.snappedPoint.y,
|
||||
button: button || 'left',
|
||||
clickCount: clickCount ?? 1,
|
||||
},
|
||||
}, 'desktop_click_target')
|
||||
// If the action was denied or queued for approval, relay the policy result
|
||||
// and do not report a false success or update post-click state.
|
||||
const status = (actionResult.structuredContent as Record<string, unknown> | undefined)?.status
|
||||
if (actionResult.isError || status === 'approval_required' || status === 'denied') {
|
||||
return actionResult
|
||||
pointerTrace: intent.path,
|
||||
})
|
||||
}
|
||||
else {
|
||||
// Try browser-dom bridge action first, dispatching by method
|
||||
try {
|
||||
const frameIds = routeDecision.frameId !== undefined ? [routeDecision.frameId] : undefined
|
||||
if (routeDecision.bridgeMethod === 'checkCheckbox') {
|
||||
await runtime.browserDomBridge!.checkCheckbox({
|
||||
selector: routeDecision.selector,
|
||||
frameIds,
|
||||
})
|
||||
}
|
||||
else {
|
||||
await runtime.browserDomBridge!.clickSelector({
|
||||
selector: routeDecision.selector,
|
||||
frameIds,
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (browserError) {
|
||||
// Fallback to OS input on browser-dom failure
|
||||
executionRoute = 'os_input'
|
||||
routeNote = `browser-dom ${routeDecision.bridgeMethod ?? 'click'} failed (${browserError instanceof Error ? browserError.message : String(browserError)}), fell back to OS input`
|
||||
await runtime.executor.click({
|
||||
x: snap.snappedPoint.x,
|
||||
y: snap.snappedPoint.y,
|
||||
button: button || 'left',
|
||||
clickCount: clickCount ?? 1,
|
||||
pointerTrace: intent.path,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// OS-level click (existing path)
|
||||
await runtime.executor.click({
|
||||
x: snap.snappedPoint.x,
|
||||
y: snap.snappedPoint.y,
|
||||
button: button || 'left',
|
||||
clickCount: clickCount ?? 1,
|
||||
pointerTrace: intent.path,
|
||||
})
|
||||
}
|
||||
|
||||
// Record the candidate as clicked only after execution succeeds or bypasses policy
|
||||
runtime.stateManager.recordClickedCandidate(candidateId)
|
||||
// Phase: completed — update ghost pointer state for overlay fadeout
|
||||
intent.phase = 'completed'
|
||||
intent.executionResult = routeNote ? 'fallback' : 'success'
|
||||
intent.executionRoute = `${executionRoute} (${routeReason})`
|
||||
runtime.stateManager.updatePointerIntent(intent, candidateId)
|
||||
|
||||
const candidateDesc = candidate ? `${candidate.source} ${candidate.role} "${candidate.label}"` : candidateId
|
||||
|
||||
@@ -293,7 +350,7 @@ export function registerDesktopGroundingTools(params: {
|
||||
`Clicked: ${candidateDesc}`,
|
||||
` Snap: ${snap.reason}`,
|
||||
` Point: (${snap.snappedPoint.x}, ${snap.snappedPoint.y})`,
|
||||
` Route: ${executionRoute} (${routeDecision.reason})`,
|
||||
` Route: ${executionRoute} (${routeReason})`,
|
||||
` Button: ${button || 'left'}, clicks: ${clickCount ?? 1}`,
|
||||
]
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('registerComputerUseTools: PTY approval bridge', () => {
|
||||
listPendingActions: vi.fn(() => [...pendingActions.values()]),
|
||||
removePendingAction: vi.fn((id: string) => pendingActions.delete(id)),
|
||||
record: vi.fn().mockResolvedValue(undefined),
|
||||
consumeOperation: vi.fn(),
|
||||
getBudgetState: vi.fn(() => ({ operationsExecuted: 0, operationUnitsConsumed: 0 })),
|
||||
getLastScreenshot: vi.fn(() => undefined),
|
||||
},
|
||||
@@ -69,10 +70,27 @@ describe('registerComputerUseTools: PTY approval bridge', () => {
|
||||
},
|
||||
browserDomBridge: {
|
||||
triggerEvent: vi.fn(),
|
||||
clickSelector: vi.fn(),
|
||||
getStatus: vi.fn(() => ({ enabled: false, connected: false })),
|
||||
supportsAction: vi.fn(() => true),
|
||||
},
|
||||
cdpBridgeManager: {
|
||||
getAvailability: vi.fn(),
|
||||
probeAvailability: vi.fn().mockResolvedValue({
|
||||
endpoint: undefined,
|
||||
connected: false,
|
||||
connectable: false,
|
||||
lastError: 'CDP unavailable',
|
||||
}),
|
||||
ensureBridge: vi.fn(),
|
||||
},
|
||||
chromeSessionManager: {
|
||||
ensureAgentWindow: vi.fn(),
|
||||
},
|
||||
desktopSessionController: {
|
||||
getSession: vi.fn(() => null),
|
||||
begin: vi.fn(() => ({ id: 'desktop-session-1' })),
|
||||
addOwnedWindow: vi.fn(),
|
||||
},
|
||||
taskMemory: {},
|
||||
} as unknown as ComputerUseServerRuntime
|
||||
@@ -142,6 +160,73 @@ describe('registerComputerUseTools: PTY approval bridge', () => {
|
||||
expect((runtime.session.getPendingAction as any)('pending-pty-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('executes approved pending desktop_ensure_chrome through the Chrome session manager', async () => {
|
||||
;(runtime.chromeSessionManager.ensureAgentWindow as any).mockResolvedValue({
|
||||
wasAlreadyRunning: false,
|
||||
windowId: 'chrome-window-1',
|
||||
pid: 4242,
|
||||
agentOwned: true,
|
||||
cdpUrl: 'http://127.0.0.1:9333',
|
||||
initialUrl: 'https://example.com',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
;(runtime.cdpBridgeManager.probeAvailability as any).mockResolvedValue({
|
||||
endpoint: 'ws://127.0.0.1/devtools/browser/1',
|
||||
connected: false,
|
||||
connectable: true,
|
||||
})
|
||||
|
||||
pendingActions.set('pending-chrome-1', {
|
||||
id: 'pending-chrome-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
toolName: 'desktop_ensure_chrome',
|
||||
action: {
|
||||
kind: 'desktop_ensure_chrome',
|
||||
input: {
|
||||
url: 'https://example.com',
|
||||
cdpPort: 9333,
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
allowed: true,
|
||||
requiresApproval: true,
|
||||
reasons: ['Opening Chrome requires approval.'],
|
||||
riskLevel: 'medium',
|
||||
estimatedOperationUnits: 2,
|
||||
},
|
||||
context: {
|
||||
available: true,
|
||||
appName: 'Finder',
|
||||
platform: 'darwin',
|
||||
},
|
||||
})
|
||||
|
||||
const executeAction = vi.fn()
|
||||
const { server, invoke } = createMockServer()
|
||||
registerComputerUseTools({
|
||||
server,
|
||||
runtime,
|
||||
executeAction,
|
||||
enableTestTools: false,
|
||||
})
|
||||
|
||||
const result = await invoke('desktop_approve_pending_action', { id: 'pending-chrome-1' })
|
||||
|
||||
expect(result.isError).not.toBe(true)
|
||||
expect(runtime.chromeSessionManager.ensureAgentWindow).toHaveBeenCalledWith({
|
||||
url: 'https://example.com',
|
||||
cdpPort: 9333,
|
||||
})
|
||||
expect(runtime.cdpBridgeManager.ensureBridge).toHaveBeenCalledWith('http://127.0.0.1:9333')
|
||||
expect(runtime.stateManager.getState().chromeSession).toMatchObject({
|
||||
windowId: 'chrome-window-1',
|
||||
pid: 4242,
|
||||
})
|
||||
expect(runtime.session.consumeOperation).toHaveBeenCalledWith(2)
|
||||
expect(executeAction).not.toHaveBeenCalled()
|
||||
expect((runtime.session.getPendingAction as any)('pending-chrome-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns a structured error when browser_dom_trigger_event receives malformed optsJson', async () => {
|
||||
;(runtime.browserDomBridge.getStatus as any).mockReturnValue({
|
||||
enabled: true,
|
||||
@@ -172,4 +257,65 @@ describe('registerComputerUseTools: PTY approval bridge', () => {
|
||||
})
|
||||
expect((runtime.browserDomBridge.triggerEvent as any)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects browser_dom_click when the connected extension transport is read-only', async () => {
|
||||
;(runtime.browserDomBridge.getStatus as any).mockReturnValue({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8765,
|
||||
pendingRequests: 0,
|
||||
})
|
||||
;(runtime.browserDomBridge.supportsAction as any).mockImplementation((action: string) => action !== 'clickAt')
|
||||
|
||||
const { server, invoke } = createMockServer()
|
||||
registerComputerUseTools({
|
||||
server,
|
||||
runtime,
|
||||
executeAction: vi.fn(),
|
||||
enableTestTools: false,
|
||||
})
|
||||
|
||||
const result = await invoke('browser_dom_click', {
|
||||
selector: '#submit',
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
status: 'unavailable',
|
||||
unsupportedActions: ['clickAt'],
|
||||
})
|
||||
expect((runtime.browserDomBridge.clickSelector as any)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects browser_dom_trigger_event when the connected extension transport does not support writes', async () => {
|
||||
;(runtime.browserDomBridge.getStatus as any).mockReturnValue({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8765,
|
||||
pendingRequests: 0,
|
||||
})
|
||||
;(runtime.browserDomBridge.supportsAction as any).mockImplementation((action: string) => action !== 'triggerEvent')
|
||||
|
||||
const { server, invoke } = createMockServer()
|
||||
registerComputerUseTools({
|
||||
server,
|
||||
runtime,
|
||||
executeAction: vi.fn(),
|
||||
enableTestTools: false,
|
||||
})
|
||||
|
||||
const result = await invoke('browser_dom_trigger_event', {
|
||||
selector: '#app',
|
||||
eventName: 'click',
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
status: 'unavailable',
|
||||
unsupportedActions: ['triggerEvent'],
|
||||
})
|
||||
expect((runtime.browserDomBridge.triggerEvent as any)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { ComputerUseServerRuntime } from './runtime'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { getUnsupportedBrowserDomActions, isBrowserDomActionSupported } from '../browser-dom/capabilities'
|
||||
import { getRuntimePreflight } from '../preflight'
|
||||
import { summarizeRunState } from '../transparency'
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
summarizeCoordinateSpace,
|
||||
} from './formatters'
|
||||
import { refreshRuntimeRunState } from './refresh-run-state'
|
||||
import { executeChromeEnsure } from './register-chrome-session'
|
||||
import { createAcquirePtyCallback, executeApprovedPtyCreate } from './register-pty'
|
||||
import { formatWorkflowStructuredContent } from './workflow-formatter'
|
||||
import { createWorkflowPrepToolExecutor } from './workflow-prep-tools'
|
||||
@@ -82,16 +84,20 @@ function summarizeBrowserDomFrameResults(label: string, results: Array<BrowserDo
|
||||
return `${label}: ${successfulFrames.length}/${results.length} frame(s) succeeded.`
|
||||
}
|
||||
|
||||
function buildBrowserDomUnavailableResponse(runtime: ComputerUseServerRuntime) {
|
||||
function buildBrowserDomUnavailableResponse(runtime: ComputerUseServerRuntime, unsupportedActions?: string[]) {
|
||||
const status = runtime.browserDomBridge.getStatus()
|
||||
const detail = unsupportedActions?.length
|
||||
? `connected extension transport does not support ${unsupportedActions.join(', ')}`
|
||||
: status.lastError || 'the browser extension is not connected yet'
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
textContent(`Browser DOM bridge is unavailable: ${status.lastError || 'the browser extension is not connected yet'}.`),
|
||||
textContent(`Browser DOM bridge is unavailable: ${detail}.`),
|
||||
],
|
||||
structuredContent: {
|
||||
status: 'unavailable',
|
||||
bridge: status,
|
||||
unsupportedActions,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -555,6 +561,9 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions
|
||||
async ({ selector, tabId, frameIds }) => {
|
||||
if (!runtime.browserDomBridge.getStatus().connected)
|
||||
return buildBrowserDomUnavailableResponse(runtime)
|
||||
const requiredActions = ['getClickTarget', 'clickAt']
|
||||
if (!isBrowserDomActionSupported(runtime.browserDomBridge, ...requiredActions))
|
||||
return buildBrowserDomUnavailableResponse(runtime, getUnsupportedBrowserDomActions(runtime.browserDomBridge, ...requiredActions))
|
||||
|
||||
const result = await runtime.browserDomBridge.clickSelector({
|
||||
selector,
|
||||
@@ -608,6 +617,9 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions
|
||||
async ({ selector, tabId, frameIds }) => {
|
||||
if (!runtime.browserDomBridge.getStatus().connected)
|
||||
return buildBrowserDomUnavailableResponse(runtime)
|
||||
const requiredActions = ['readInputValue']
|
||||
if (!isBrowserDomActionSupported(runtime.browserDomBridge, ...requiredActions))
|
||||
return buildBrowserDomUnavailableResponse(runtime, getUnsupportedBrowserDomActions(runtime.browserDomBridge, ...requiredActions))
|
||||
|
||||
const results = await runtime.browserDomBridge.readInputValue({
|
||||
selector,
|
||||
@@ -641,6 +653,9 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions
|
||||
async ({ selector, value, simulateKeystrokes, blur, tabId, frameIds }) => {
|
||||
if (!runtime.browserDomBridge.getStatus().connected)
|
||||
return buildBrowserDomUnavailableResponse(runtime)
|
||||
const requiredActions = ['setInputValue']
|
||||
if (!isBrowserDomActionSupported(runtime.browserDomBridge, ...requiredActions))
|
||||
return buildBrowserDomUnavailableResponse(runtime, getUnsupportedBrowserDomActions(runtime.browserDomBridge, ...requiredActions))
|
||||
|
||||
const results = await runtime.browserDomBridge.setInputValue({
|
||||
selector,
|
||||
@@ -676,6 +691,9 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions
|
||||
async ({ selector, checked, tabId, frameIds }) => {
|
||||
if (!runtime.browserDomBridge.getStatus().connected)
|
||||
return buildBrowserDomUnavailableResponse(runtime)
|
||||
const requiredActions = ['checkCheckbox']
|
||||
if (!isBrowserDomActionSupported(runtime.browserDomBridge, ...requiredActions))
|
||||
return buildBrowserDomUnavailableResponse(runtime, getUnsupportedBrowserDomActions(runtime.browserDomBridge, ...requiredActions))
|
||||
|
||||
const results = await runtime.browserDomBridge.checkCheckbox({
|
||||
selector,
|
||||
@@ -709,6 +727,9 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions
|
||||
async ({ selector, value, tabId, frameIds }) => {
|
||||
if (!runtime.browserDomBridge.getStatus().connected)
|
||||
return buildBrowserDomUnavailableResponse(runtime)
|
||||
const requiredActions = ['selectOption']
|
||||
if (!isBrowserDomActionSupported(runtime.browserDomBridge, ...requiredActions))
|
||||
return buildBrowserDomUnavailableResponse(runtime, getUnsupportedBrowserDomActions(runtime.browserDomBridge, ...requiredActions))
|
||||
|
||||
const results = await runtime.browserDomBridge.selectOption({
|
||||
selector,
|
||||
@@ -742,6 +763,9 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions
|
||||
async ({ selector, timeoutMs, tabId, frameIds }) => {
|
||||
if (!runtime.browserDomBridge.getStatus().connected)
|
||||
return buildBrowserDomUnavailableResponse(runtime)
|
||||
const requiredActions = ['waitForElement']
|
||||
if (!isBrowserDomActionSupported(runtime.browserDomBridge, ...requiredActions))
|
||||
return buildBrowserDomUnavailableResponse(runtime, getUnsupportedBrowserDomActions(runtime.browserDomBridge, ...requiredActions))
|
||||
|
||||
const results = await runtime.browserDomBridge.waitForElement({
|
||||
selector,
|
||||
@@ -805,6 +829,9 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions
|
||||
async ({ selector, properties, tabId, frameIds }) => {
|
||||
if (!runtime.browserDomBridge.getStatus().connected)
|
||||
return buildBrowserDomUnavailableResponse(runtime)
|
||||
const requiredActions = ['getComputedStyles']
|
||||
if (!isBrowserDomActionSupported(runtime.browserDomBridge, ...requiredActions))
|
||||
return buildBrowserDomUnavailableResponse(runtime, getUnsupportedBrowserDomActions(runtime.browserDomBridge, ...requiredActions))
|
||||
|
||||
const results = await runtime.browserDomBridge.getComputedStyles({
|
||||
selector,
|
||||
@@ -839,6 +866,9 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions
|
||||
async ({ selector, eventName, eventType, optsJson, tabId, frameIds }) => {
|
||||
if (!runtime.browserDomBridge.getStatus().connected)
|
||||
return buildBrowserDomUnavailableResponse(runtime)
|
||||
const requiredActions = ['triggerEvent']
|
||||
if (!isBrowserDomActionSupported(runtime.browserDomBridge, ...requiredActions))
|
||||
return buildBrowserDomUnavailableResponse(runtime, getUnsupportedBrowserDomActions(runtime.browserDomBridge, ...requiredActions))
|
||||
|
||||
let opts: Record<string, unknown> | undefined
|
||||
if (optsJson?.trim()) {
|
||||
@@ -963,6 +993,30 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions
|
||||
return result
|
||||
}
|
||||
|
||||
if (pending.action.kind === 'desktop_ensure_chrome') {
|
||||
const result = await executeChromeEnsure(
|
||||
runtime,
|
||||
pending.action.input,
|
||||
pending.policy.estimatedOperationUnits,
|
||||
)
|
||||
|
||||
await runtime.session.record({
|
||||
event: result.isError === true ? 'failed' : 'executed',
|
||||
toolName: pending.toolName,
|
||||
action: pending.action,
|
||||
context: pending.context,
|
||||
policy: pending.policy,
|
||||
result: {
|
||||
pendingActionId: id,
|
||||
...(typeof result.structuredContent === 'object' && result.structuredContent !== null
|
||||
? result.structuredContent as Record<string, unknown>
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return await executeAction(pending.action, pending.toolName, {
|
||||
skipApprovalQueue: true,
|
||||
})
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { ChromeSessionManager } from '../chrome-session-manager'
|
||||
import type { DesktopSessionController } from '../desktop-session'
|
||||
import type { ComputerUseConfig, DesktopExecutor, TerminalRunner } from '../types'
|
||||
import type { CdpBridgeManager } from './cdp-manager'
|
||||
|
||||
import { platform } from 'node:process'
|
||||
|
||||
import { BrowserDomExtensionBridge } from '../browser-dom/extension-bridge'
|
||||
import { createChromeSessionManager } from '../chrome-session-manager'
|
||||
import { resolveComputerUseConfig } from '../config'
|
||||
import { createDesktopSessionController } from '../desktop-session'
|
||||
import { createDryRunExecutor } from '../executors/dry-run'
|
||||
import { createLinuxX11Executor } from '../executors/linux-x11'
|
||||
import { createMacOSLocalExecutor } from '../executors/macos-local'
|
||||
@@ -30,6 +34,10 @@ export interface ComputerUseServerRuntime {
|
||||
stateManager: RunStateManager
|
||||
/** High-level task memory for the current session. */
|
||||
taskMemory: TaskMemoryManager
|
||||
/** Agent-owned Chrome session lifecycle manager. */
|
||||
chromeSessionManager: ChromeSessionManager
|
||||
/** Desktop session ownership controller. */
|
||||
desktopSessionController: DesktopSessionController
|
||||
}
|
||||
|
||||
function createExecutor(config: ComputerUseConfig, options: ComputerUseServerOptions = {}): DesktopExecutor {
|
||||
@@ -77,5 +85,13 @@ export async function createRuntime(config = resolveComputerUseConfig(), options
|
||||
cdpBridgeManager,
|
||||
stateManager,
|
||||
taskMemory,
|
||||
chromeSessionManager: createChromeSessionManager(config, {
|
||||
onSessionLost: () => {
|
||||
// NOTICE: Chrome session loss invalidates the agent-owned CDP endpoint.
|
||||
// Close the bridge proactively so later observe/ensure flows reconnect cleanly.
|
||||
cdpBridgeManager.close().catch(() => {})
|
||||
},
|
||||
}),
|
||||
desktopSessionController: createDesktopSessionController(stateManager),
|
||||
} satisfies ComputerUseServerRuntime
|
||||
}
|
||||
|
||||
@@ -73,6 +73,19 @@ export const desktopDescriptors: ToolDescriptor[] = [
|
||||
public: true,
|
||||
defaultDeferred: false,
|
||||
},
|
||||
{
|
||||
canonicalName: 'desktop_ensure_chrome',
|
||||
displayName: 'Desktop Ensure Chrome',
|
||||
summary: 'Ensure the agent has a dedicated Chrome window. Launches Chrome if not running, creates a new window if already running. Returns session info with PID and CDP URL. Idempotent — repeated calls return the existing session.',
|
||||
lane: 'desktop',
|
||||
kind: 'control',
|
||||
readOnly: false,
|
||||
destructive: false,
|
||||
concurrencySafe: false,
|
||||
requiresApprovalByDefault: false,
|
||||
public: true,
|
||||
defaultDeferred: false,
|
||||
},
|
||||
|
||||
// Desktop interaction tools
|
||||
{
|
||||
|
||||
@@ -36,8 +36,8 @@ export type ToolKind
|
||||
|
||||
/**
|
||||
* Tool descriptor defines the canonical metadata for a single MCP tool.
|
||||
* All fields except `defaultDeferred` are required (fail-closed policy).
|
||||
* `defaultDeferred` defaults to false when omitted.
|
||||
* Core fields are required (fail-closed policy). Optional fields must have
|
||||
* explicit default behavior at the registration call site.
|
||||
*/
|
||||
export interface ToolDescriptor {
|
||||
/**
|
||||
@@ -101,6 +101,7 @@ export interface ToolDescriptor {
|
||||
/**
|
||||
* Whether this tool is hidden from the default tool list to reduce context bloat.
|
||||
* True = deferred loading (must be explicitly enabled via tool_search).
|
||||
* Omitted = false.
|
||||
*/
|
||||
defaultDeferred?: boolean
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
* process. Persistent audit lives in session trace / JSONL.
|
||||
*/
|
||||
|
||||
import type { DesktopSession } from './desktop-session'
|
||||
import type { TaskMemory } from './task-memory/types'
|
||||
import type {
|
||||
BrowserSurfaceAvailability,
|
||||
ChromeSessionInfo,
|
||||
DisplayInfo,
|
||||
ExecutionTarget,
|
||||
ForegroundContext,
|
||||
@@ -185,6 +187,16 @@ export interface RunState {
|
||||
/** Candidate id of the last `desktop_click_target` call for duplicate protection. */
|
||||
lastClickedCandidateId?: string
|
||||
|
||||
// --- Chrome Session ----------------------------------------------------
|
||||
/** Agent's dedicated Chrome session (managed by ChromeSessionManager). */
|
||||
chromeSession?: ChromeSessionInfo
|
||||
/** The user's foreground app before the agent took over. */
|
||||
previousUserForegroundApp?: string
|
||||
|
||||
// --- Desktop Session ---------------------------------------------------
|
||||
/** Agent's active desktop execution session. */
|
||||
desktopSession?: DesktopSession
|
||||
|
||||
// --- Meta -------------------------------------------------------------
|
||||
/** ISO timestamp of the last state update. */
|
||||
updatedAt: string
|
||||
@@ -409,19 +421,14 @@ export class RunStateManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the last pointer snap intent and clicked candidate id.
|
||||
* Store the last pointer snap intent and, optionally, the clicked candidate id
|
||||
* once an execution path has actually succeeded.
|
||||
*/
|
||||
updatePointerIntent(intent: import('./desktop-grounding-types').PointerIntent, candidateId: string): void {
|
||||
updatePointerIntent(intent: import('./desktop-grounding-types').PointerIntent, candidateId?: string): void {
|
||||
this.state.lastPointerIntent = intent
|
||||
this.state.lastClickedCandidateId = candidateId
|
||||
this.touch()
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the candidate id that was just successfully clicked.
|
||||
*/
|
||||
recordClickedCandidate(candidateId: string): void {
|
||||
this.state.lastClickedCandidateId = candidateId
|
||||
if (candidateId !== undefined) {
|
||||
this.state.lastClickedCandidateId = candidateId
|
||||
}
|
||||
this.touch()
|
||||
}
|
||||
|
||||
@@ -435,6 +442,52 @@ export class RunStateManager {
|
||||
this.touch()
|
||||
}
|
||||
|
||||
// -- Chrome Session updates ---------------------------------------------
|
||||
|
||||
/**
|
||||
* Store the agent's Chrome session info.
|
||||
* Called after ChromeSessionManager.ensureAgentWindow() succeeds.
|
||||
*/
|
||||
updateChromeSession(info: ChromeSessionInfo): void {
|
||||
this.state.chromeSession = info
|
||||
this.touch()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the Chrome session (e.g. on session end or Chrome crash).
|
||||
*/
|
||||
clearChromeSession(): void {
|
||||
this.state.chromeSession = undefined
|
||||
this.state.previousUserForegroundApp = undefined
|
||||
this.touch()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the user's foreground app before agent takes over.
|
||||
*/
|
||||
savePreviousUserForeground(appName: string): void {
|
||||
this.state.previousUserForegroundApp = appName
|
||||
this.touch()
|
||||
}
|
||||
|
||||
// -- Desktop Session updates --------------------------------------------
|
||||
|
||||
/**
|
||||
* Update the agent's desktop session.
|
||||
*/
|
||||
updateDesktopSession(session: DesktopSession): void {
|
||||
this.state.desktopSession = session
|
||||
this.touch()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the desktop session.
|
||||
*/
|
||||
clearDesktopSession(): void {
|
||||
this.state.desktopSession = undefined
|
||||
this.touch()
|
||||
}
|
||||
|
||||
clearTask() {
|
||||
this.state.activeTask = undefined
|
||||
this.touch()
|
||||
|
||||
@@ -121,6 +121,33 @@ export interface ForegroundContext {
|
||||
windowBounds?: Bounds
|
||||
platform: NodeJS.Platform
|
||||
unavailableReason?: string
|
||||
/** Whether the current foreground app is agent-owned (launched/managed by the agent). */
|
||||
agentOwned?: boolean
|
||||
/** PID of the agent-owned window (if any). */
|
||||
agentWindowPid?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* State of the agent's dedicated Chrome session.
|
||||
*
|
||||
* Created by `ChromeSessionManager.ensureAgentWindow()` and persisted in
|
||||
* `RunState.chromeSession` for the lifetime of the agent session.
|
||||
*/
|
||||
export interface ChromeSessionInfo {
|
||||
/** Whether Chrome was already running before the agent launched it. */
|
||||
wasAlreadyRunning: boolean
|
||||
/** Window identity string from observe-windows (ownerPid:layer:title). */
|
||||
windowId: string
|
||||
/** CDP WebSocket URL if Chrome was launched with --remote-debugging-port. */
|
||||
cdpUrl?: string
|
||||
/** Chrome process PID. */
|
||||
pid: number
|
||||
/** Whether the agent started this Chrome instance. */
|
||||
agentOwned: boolean
|
||||
/** The URL navigated to (if any). */
|
||||
initialUrl?: string
|
||||
/** ISO timestamp of session creation. */
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface WindowInfo {
|
||||
@@ -235,6 +262,11 @@ export interface DesktopClickTargetInput {
|
||||
button?: MouseButton
|
||||
}
|
||||
|
||||
export interface DesktopEnsureChromeApprovalInput {
|
||||
url?: string
|
||||
cdpPort?: number
|
||||
}
|
||||
|
||||
export interface ScreenshotRequest {
|
||||
label?: string
|
||||
}
|
||||
@@ -338,6 +370,7 @@ export type ActionInvocation
|
||||
|
||||
export type PendingExecutableAction
|
||||
= | ActionInvocation
|
||||
| { kind: 'desktop_ensure_chrome', input: DesktopEnsureChromeApprovalInput }
|
||||
| { kind: 'pty_create', input: PtyCreateApprovalInput }
|
||||
|
||||
export interface PolicyDecision {
|
||||
@@ -515,6 +548,15 @@ export interface BrowserDomInteractiveElement {
|
||||
export interface BrowserDomFrameDom {
|
||||
url?: string
|
||||
title?: string
|
||||
frameName?: string
|
||||
frameOffset?: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
frameOffsetInParent?: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
bodyText?: string
|
||||
frameRect?: {
|
||||
x: number
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Async sleep utility.
|
||||
*
|
||||
* Extracted as a standalone module so tests can mock it via `vi.mock`
|
||||
* to avoid real delays from setTimeout.
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
Reference in New Issue
Block a user