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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
533 changes: 497 additions & 36 deletions apps/desktop/electron/main.cjs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/desktop/electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getDomSummary: () => ipcRenderer.invoke('hermes:browser:get-dom-summary'),
getScreenshot: () => ipcRenderer.invoke('hermes:browser:get-screenshot'),
getSelectedText: () => ipcRenderer.invoke('hermes:browser:get-selected-text'),
getInteractiveSnapshot: () => ipcRenderer.invoke('hermes:browser:get-interactive-snapshot'),
executeClick: payload => ipcRenderer.invoke('hermes:browser:execute-click', payload),
verifyActionTarget: payload => ipcRenderer.invoke('hermes:browser:verify-action-target', payload),
navigate: payload => ipcRenderer.invoke('hermes:browser:navigate', payload),
reload: () => ipcRenderer.invoke('hermes:browser:reload', { source: 'user' }),
Expand Down
62 changes: 56 additions & 6 deletions apps/desktop/src/app/browser-runtime/action-gateway-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
} from './action-gateway'
import {
type DesktopBrowserBridge,
executeDesktopClick,
getDesktopSnapshot,
VERIFICATION_FAILURE_REASONS,
verifyDesktopActionTarget,
Expand Down Expand Up @@ -147,8 +148,59 @@ export function BrowserActionGateway({ desktopBridge }: { desktopBridge?: Deskto
}
}

// ── Click/type — pre-action verification (Phase 2F-A) ────────────
if (actionType === 'click' || actionType === 'type') {
// ── Click — real execution (Phase 2F-B1) ─────────────────────────
if (actionType === 'click') {
if (!desktopBridge) {
return {
status: 'failed' as const,
error: 'Desktop browser bridge is unavailable.',
}
}

if (!request.safetyContext) {
return {
status: 'failed' as const,
error: 'Missing safety context — cannot execute click.',
}
}

const result = await executeDesktopClick(desktopBridge, request.safetyContext)

if (!result.ok) {
return {
status: 'failed' as const,
error: `Click failed: ${result.reason || 'unknown'}.`,
postActionSnapshot: result.postActionSnapshot,
preActionVerification: result.verification
? {
verifiedAt: new Date().toISOString(),
currentUrl: result.currentUrl || '',
refValid: result.verification.refValid,
invalidationReason: result.verification.invalidationReason,
currentFingerprint: result.verification.currentFingerprint as PreActionVerification['currentFingerprint'],
snapshot: result.postActionSnapshot || ({} as PreActionVerification['snapshot']),
}
: undefined,
}
}

return {
status: 'executed' as const,
postActionSnapshot: result.postActionSnapshot,
preActionVerification: result.verification
? {
verifiedAt: new Date().toISOString(),
currentUrl: result.currentUrl || '',
refValid: result.verification.refValid,
currentFingerprint: result.verification.currentFingerprint as PreActionVerification['currentFingerprint'],
snapshot: result.postActionSnapshot || ({} as PreActionVerification['snapshot']),
}
: undefined,
}
}

// ── Type — pre-action verification only (Phase 2F-A) ─────────────
if (actionType === 'type') {
let preActionVerification: PreActionVerification | undefined

if (request.safetyContext && desktopBridge) {
Expand All @@ -172,13 +224,11 @@ export function BrowserActionGateway({ desktopBridge }: { desktopBridge?: Deskto
}
}

// Phase 2F-A does NOT execute real click/type.
// The verification result is attached to the log for the user to inspect.
return {
status: 'failed' as const,
error: `"${actionType}" execution is not yet implemented (Phase 2F-A). `
error: '"type" execution is not yet implemented (Phase 2F-A). '
+ (preActionVerification?.refValid
? 'Pre-action verification passed — target element found and matches safety context. Ready for Phase 2F-B executor.'
? 'Pre-action verification passed. Ready for Phase 2F-B2 executor.'
: `Pre-action verification failed: ${preActionVerification?.invalidationReason || 'unknown'}. `),
preActionVerification,
}
Expand Down
77 changes: 58 additions & 19 deletions apps/desktop/src/app/browser-runtime/action-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,8 @@ describe('Phase 2E — action classification sets', () => {
expect(PERMANENTLY_DENIED_ACTIONS.has('navigate')).toBe(false)
})

it('AWAITING_SAFETY_ACTIONS contains click and type', () => {
expect(AWAITING_SAFETY_ACTIONS.has('click')).toBe(true)
it('AWAITING_SAFETY_ACTIONS contains only type', () => {
expect(AWAITING_SAFETY_ACTIONS.has('type')).toBe(true)
expect(AWAITING_SAFETY_ACTIONS.has('type')).toBe(true)
})

Expand All @@ -462,9 +462,8 @@ describe('Phase 2E — action classification sets', () => {
expect(AWAITING_SAFETY_ACTIONS.has('eval')).toBe(false)
})

it('EXECUTABLE_ACTIONS only contains navigate', () => {
expect(EXECUTABLE_ACTIONS.has('navigate')).toBe(true)
expect(EXECUTABLE_ACTIONS.has('click')).toBe(false)
it('EXECUTABLE_ACTIONS contains click and navigate', () => {
expect(EXECUTABLE_ACTIONS.has('click')).toBe(true)
expect(EXECUTABLE_ACTIONS.has('type')).toBe(false)
expect(EXECUTABLE_ACTIONS.has('eval')).toBe(false)
expect(EXECUTABLE_ACTIONS.has('snapshot')).toBe(false)
Expand All @@ -476,8 +475,8 @@ describe('Phase 2E — isActionExecutable', () => {
expect(isActionExecutable('navigate')).toBe(true)
})

it('returns false for click, type, eval, scroll, press_key', () => {
expect(isActionExecutable('click')).toBe(false)
it('returns true for click, false for type, eval, scroll, press_key', () => {
expect(isActionExecutable('click')).toBe(true)
expect(isActionExecutable('type')).toBe(false)
expect(isActionExecutable('eval')).toBe(false)
expect(isActionExecutable('scroll')).toBe(false)
Expand Down Expand Up @@ -507,7 +506,7 @@ describe('Phase 2E — getBlockedActionReason', () => {
})

it('returns a reason for awaiting-safety actions', () => {
const reason = getBlockedActionReason('click')
const reason = getBlockedActionReason('type')
expect(reason).toBeTruthy()
expect(reason).toContain('Phase 2E')
expect(reason).toContain('Phase 2F')
Expand Down Expand Up @@ -584,8 +583,8 @@ describe('Phase 2E — proposeAction accepts safetyContext', () => {
})

describe('Phase 2E — click/type non-execution contract', () => {
it('click is NOT in EXECUTABLE_ACTIONS', () => {
expect(EXECUTABLE_ACTIONS.has('click')).toBe(false)
it('click IS in EXECUTABLE_ACTIONS', () => {
expect(EXECUTABLE_ACTIONS.has('click')).toBe(true)
})

it('type is NOT in EXECUTABLE_ACTIONS', () => {
Expand All @@ -597,8 +596,8 @@ describe('Phase 2E — click/type non-execution contract', () => {
expect(AWAITING_SAFETY_ACTIONS.has('eval')).toBe(false)
})

it('navigate is the only executable action', () => {
expect([...EXECUTABLE_ACTIONS]).toEqual(['navigate'])
it('click and navigate are the only executable actions', () => {
expect([...EXECUTABLE_ACTIONS].sort()).toEqual(['click', 'navigate'])
})
})

Expand Down Expand Up @@ -640,17 +639,17 @@ describe('Phase 2F-A — read-only actions produce real data', () => {
})

describe('Phase 2F-A — EXECUTABLE_ACTIONS still only contains navigate', () => {
it('only navigate is directly executable', () => {
expect([...EXECUTABLE_ACTIONS].sort()).toEqual(['navigate'])
it('click and navigate are directly executable', () => {
expect([...EXECUTABLE_ACTIONS].sort()).toEqual(['click', 'navigate'])
})

it('click and type are STILL not executable', () => {
expect(EXECUTABLE_ACTIONS.has('click')).toBe(false)
expect(EXECUTABLE_ACTIONS.has('type')).toBe(false)
it('type is STILL not executable', () => {
expect(EXECUTABLE_ACTIONS.has('click')).toBe(true)
expect(EXECUTABLE_ACTIONS.has('type')).toBe(false); expect(EXECUTABLE_ACTIONS.has('click')).toBe(true)
})

it('click and type are STILL in AWAITING_SAFETY_ACTIONS', () => {
expect(AWAITING_SAFETY_ACTIONS.has('click')).toBe(true)
it('type is STILL in AWAITING_SAFETY_ACTIONS', () => {
expect(AWAITING_SAFETY_ACTIONS.has('type')).toBe(true)
expect(AWAITING_SAFETY_ACTIONS.has('type')).toBe(true)
})
})
Expand Down Expand Up @@ -688,3 +687,43 @@ describe('Phase 2F-A — proposeAction with safetyContext for verification', ()
expect(pending?.safetyContext?.elementFingerprint?.tagName).toBe('BUTTON')
})
})

// ═══════════════════════════════════════════════════════════════════════
// Phase 2F-B1 — Real click execution contract
// ═══════════════════════════════════════════════════════════════════════

describe('Phase 2F-B1 — click execution contract', () => {
it('EXECUTABLE_ACTIONS contains click', () => {
expect(EXECUTABLE_ACTIONS.has('click')).toBe(true)
})

it('EXECUTABLE_ACTIONS contains navigate', () => {
expect(EXECUTABLE_ACTIONS.has('navigate')).toBe(true)
})

it('EXECUTABLE_ACTIONS does NOT contain type', () => {
expect(EXECUTABLE_ACTIONS.has('type')).toBe(false)
})

it('AWAITING_SAFETY_ACTIONS only contains type', () => {
expect(AWAITING_SAFETY_ACTIONS.has('type')).toBe(true)
expect(AWAITING_SAFETY_ACTIONS.has('click')).toBe(false)
})

it('PERMANENTLY_DENIED_ACTIONS unchanged', () => {
expect(PERMANENTLY_DENIED_ACTIONS.has('eval')).toBe(true)
expect(PERMANENTLY_DENIED_ACTIONS.has('press_key')).toBe(true)
expect(PERMANENTLY_DENIED_ACTIONS.has('scroll')).toBe(true)
expect(PERMANENTLY_DENIED_ACTIONS.has('click')).toBe(false)
})

it('getBlockedActionReason returns null for click (executable)', () => {
expect(getBlockedActionReason('click')).toBeNull()
})

it('getBlockedActionReason returns a reason for type (awaiting)', () => {
const reason = getBlockedActionReason('type')
expect(reason).toBeTruthy()
expect(reason).toContain('type')
})
})
2 changes: 1 addition & 1 deletion apps/desktop/src/app/browser-runtime/action-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,6 @@ export const PERMANENTLY_DENIED_ACTIONS: ReadonlySet<string> = new Set([
* @see docs/architecture/desktop-browser-agent-action-safety.md §7.3
*/
export const AWAITING_SAFETY_ACTIONS: ReadonlySet<string> = new Set([
'click',
'type',
])

Expand All @@ -252,6 +251,7 @@ export const AWAITING_SAFETY_ACTIONS: ReadonlySet<string> = new Set([
* approval (Phase 2C).
*/
export const EXECUTABLE_ACTIONS: ReadonlySet<string> = new Set([
'click',
'navigate',
])

Expand Down
Loading
Loading