Files
hermes-agent/evals/desktop_mcp_oauth/renderer_lifecycle.mjs
T

201 lines
8.0 KiB
JavaScript

import { createRequire } from 'node:module'
import fs from 'node:fs/promises'
import assert from 'node:assert/strict'
import http from 'node:http'
import path from 'node:path'
const repo = process.argv[2],
out = process.argv[3],
scenario = process.argv[4] || 'approved'
const require = createRequire(path.join(repo, 'package.json'))
const { build } = require('esbuild')
const { chromium } = require('playwright')
const requests = []
const rpcCalls = []
const nativeCalls = []
const handlers = new Map()
const nativeBuild = await build({
entryPoints: [repo + '/apps/desktop/electron/mcp-oauth-callback-ipc.ts'],
bundle: true,
write: false,
platform: 'node',
format: 'cjs',
external: ['electron']
})
const nativeModule = { exports: {} }
new Function('require', 'module', 'exports', nativeBuild.outputFiles[0].text)(
id => (id === 'electron' ? { ipcMain: { handle: (name, fn) => handlers.set(name, fn) } } : require(id)),
nativeModule,
nativeModule.exports
)
nativeModule.exports.registerMcpOauthCallbackIpc()
const { WebSocketServer } = require('ws')
const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' })
await new Promise(r => wss.once('listening', r))
let approved = false
let redirectUri
wss.on('connection', ws =>
ws.on('message', raw => {
const m = JSON.parse(raw)
rpcCalls.push(m)
let result = { ok: true }
if (m.method === 'mcp.servers.oauth.start') {
redirectUri = m.params.client_redirect_uri
result = {
ok: true,
session_id: 'fixture-session',
auth_url:
'http://127.0.0.1/authorize?state=fixture-state&redirect_uri=' +
encodeURIComponent(scenario === 'legacy' ? 'http://remote.invalid/callback' : redirectUri)
}
}
if (m.method === 'mcp.servers.oauth.callback') {
approved = m.params.state === 'fixture-state' && m.params.code === 'fixture-code'
result = { ok: approved }
}
if (m.method === 'mcp.servers.oauth.poll')
result = { ok: true, status: approved ? 'approved' : 'pending', tools: [] }
ws.send(JSON.stringify({ jsonrpc: '2.0', id: m.id, result }))
})
)
const entry = `import React from 'react'; import {createRoot} from 'react-dom/client';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
import {McpTab} from '${repo}/apps/desktop/src/app/skills/mcp-tab.tsx';
import {setApiRequestProfile,setApiRequestConnection} from '${repo}/apps/desktop/src/api/client.ts';
setApiRequestProfile('profile-b');setApiRequestConnection('fixture-remote');
const queryClient=new QueryClient({defaultOptions:{queries:{retry:false}}});function Probe(){const [profile,setProfile]=React.useState('profile-b');const [shown,setShown]=React.useState(true);window.cancelFlow=()=>setProfile('profile-c');window.unmountFlow=()=>setShown(false);return <QueryClientProvider client={queryClient}>{shown && <McpTab gateway={null} profile={{connectionId:'fixture-remote',profile}} />}</QueryClientProvider>};createRoot(document.getElementById('root')).render(<Probe/>);`
const bundle = await build({
stdin: { contents: entry, resolveDir: repo, loader: 'tsx' },
bundle: true,
write: false,
format: 'esm',
platform: 'browser',
jsx: 'automatic',
alias: { '@': repo + '/apps/desktop/src', '@hermes/shared': repo + '/apps/shared/src' },
define: {
'process.env.NODE_ENV': '"production"',
'import.meta.env': '{}',
'import.meta.env.DEV': 'false',
'import.meta.env.PROD': 'true'
},
loader: { '.woff2': 'dataurl', '.woff': 'dataurl', '.svg': 'dataurl' },
logLevel: 'warning'
})
const server = http.createServer(async (req, res) => {
res.setHeader('Content-Type', req.url === '/app.js' ? 'text/javascript' : 'text/html')
res.end(
req.url === '/app.js'
? bundle.outputFiles[0].text
: '<div id="root"></div><script type="module" src="/app.js"></script>'
)
})
await new Promise(r => server.listen(0, '127.0.0.1', r))
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox'],
...(process.env.CHROMIUM_EXECUTABLE ? { executablePath: process.env.CHROMIUM_EXECUTABLE } : {})
})
try {
const page = await browser.newPage()
const errors = []
page.on('pageerror', e => {
errors.push(e.message)
console.error(e.stack)
})
page.on('console', m => console.log(m.type(), m.text()))
await page.exposeFunction('recordRequest', r => {
requests.push(r)
console.log('request', JSON.stringify(r))
if (r.path === '/api/config')
return { mcp_servers: { reports: { url: 'https://fixture.invalid/mcp', auth: 'oauth' } } }
if (r.path.endsWith('/test')) return { ok: false, tools: [], auth_required: true, error: 'OAuth required' }
if (r.path.includes('/logs')) return { lines: [] }
if (r.path.includes('/catalog')) return { entries: [] }
if (r.path.endsWith('/auth'))
return { flow_id: 'f', status: 'error', error: 'fixture remote HTTP redirect rejected' }
return { ok: true, providers: [] }
})
await page.exposeFunction('nativeOAuth', async (action, id) => {
nativeCalls.push({ action, id })
return handlers.get('hermes:mcp-oauth:' + action)({}, id)
})
await page.exposeFunction('fixtureConnection', async scope => {
nativeCalls.push({ action: 'connection', scope })
return { wsUrl: 'ws://127.0.0.1:' + wss.address().port, connectionId: scope.connectionId, authMode: 'token' }
})
await page.exposeFunction('openAuthorization', async url => {
nativeCalls.push({ action: 'open', url })
if (scenario === 'cancel' || scenario === 'unmount') {
await page.evaluate(s => (s === 'unmount' ? window.unmountFlow() : window.cancelFlow()), scenario)
return
}
const u = new URL(url)
await fetch(u.searchParams.get('redirect_uri') + '?code=fixture-code&state=' + u.searchParams.get('state'))
})
await page.addInitScript(() => {
window.hermesDesktop = {
api: r => window.recordRequest(r),
getConnectionFor: r => window.fixtureConnection(r),
mcpOauth: {
listen: () => {
window.nativeListen = true
return window.nativeOAuth('listen')
},
wait: id => window.nativeOAuth('wait', id),
cancel: id => window.nativeOAuth('cancel', id)
},
openExternal: url => window.openAuthorization(url)
}
})
await page.goto('http://127.0.0.1:' + server.address().port)
await page.waitForTimeout(1500)
console.log('BODY', await page.locator('body').innerText())
await page.getByText('Reports', { exact: true }).first().click()
await page.waitForTimeout(300)
console.log('SELECTED', await page.locator('body').innerText())
await page
.getByRole('button', { name: /Authenticate|Sign in/ })
.first()
.click()
await page.waitForTimeout(2500)
const hasScope = await page.evaluate(() => !!window.nativeListen)
const result = {
repo,
scenario,
requests,
rpcCalls,
nativeCalls,
approved,
hasScope,
errors,
text: await page.locator('body').innerText(),
fidelity: 'production McpTab + all real renderer imports in headless Chromium; fixture native API transport'
}
await fs.writeFile(out, JSON.stringify(result, null, 2))
console.log(JSON.stringify(result))
if (scenario === 'cancel' || scenario === 'unmount') {
assert(
rpcCalls.some(m => m.method === 'mcp.servers.oauth.cancel'),
'abandoned component must cancel backend'
)
assert(
nativeCalls.some(m => m.action === 'cancel'),
'abandoned component must close listener'
)
assert(
nativeCalls.filter(m => m.action === 'connection').every(m => m.scope.profile === 'profile-b'),
'cleanup must retain original owner'
)
assert(!rpcCalls.some(m => m.method === 'mcp.servers.oauth.callback'), 'no callback after abandonment')
}
if (scenario === 'approved') {
assert(approved && hasScope, 'Skills must complete OAuth through its native listener')
assert(!requests.some(r => r.path.endsWith('/auth')), 'native flow must not use REST auth')
}
assert.deepEqual(errors, [])
} finally {
await browser.close()
server.close()
for (const c of wss.clients) c.terminate()
wss.close()
}