feat(stage-tamagotchi,stage-ui,computer-use-mcp): intro agent-owned session and ghost pointer phases (#1649)

This commit is contained in:
刘梓恒
2026-04-25 04:14:33 +08:00
committed by GitHub
parent 3545c71b3a
commit d7402ade9f
44 changed files with 7721 additions and 3969 deletions
+16 -15
View File
@@ -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'
+115
View File
@@ -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.
+3727 -3033
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -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
},
}
}
+3 -1
View File
@@ -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
}
+64 -11
View File
@@ -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()
+42
View File
@@ -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))
}