mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): web bridge — run the renderer in a plain browser
Add a web implementation of window.hermesDesktop (src/web-bridge) so the
desktop renderer boots in a browser against a gateway with no Electron.
main.tsx installs it only when no preload has claimed the window; under
Electron it is a no-op.
The bridge is same-origin only and mirrors the dashboard SPA's auth in
both modes: gated/OAuth (session cookie + single-use /api/auth/ws-ticket
mint per connect) and loopback/token (injected session token). The
synthesized connection reports mode 'remote', so fs/git/session surfaces
route to the backend through the same paths the Electron app uses
against a remote gateway. fs/git/clipboard-image/session-window methods
map to existing gateway endpoints; Electron-only surface is either an
inert stub (WEB_STUBBED_SURFACE) or deliberately absent so ?.-guarded
callers hide the feature (WEB_OMITTED_SURFACE), each with its reason.
Both bridges now carry a host marker ('electron' | 'web'; absent means
electron) so UI can gate host-specific surface on one field.
parity.test.ts pins the contract between the two bridges: it loads the
real preload with 'electron' mocked and asserts every preload key is
web-implemented or a registered omission, that registries carry no
stale entries, and that shared namespaces expose the same methods. A
new preload method fails the suite until its web story is decided.
vite.config.ts gains an opt-in dev proxy (HERMES_SPIKE_BACKEND) that
forwards /api (HTTP + WS) to a running 'hermes dashboard' for browser
dev; unset, the Electron dev flow is unchanged.
Verified: tsc -b, eslint, parity suite (6/6), and a live browser boot
against a local dashboard — gateway WS open, sessions loaded, message
streamed end-to-end.
This commit is contained in:
parent
f7c9feb395
commit
09c7854662
6 changed files with 687 additions and 1 deletions
|
|
@ -1,6 +1,9 @@
|
|||
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
// Bridge host marker — the renderer's web bridge (src/web-bridge) sets
|
||||
// 'web'; UI gates Electron-only surface on this field.
|
||||
host: 'electron',
|
||||
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
|
||||
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
|
||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||
|
|
|
|||
5
apps/desktop/src/global.d.ts
vendored
5
apps/desktop/src/global.d.ts
vendored
|
|
@ -10,6 +10,11 @@ export {}
|
|||
declare global {
|
||||
interface Window {
|
||||
hermesDesktop: {
|
||||
// Which bridge implementation is serving this window: 'electron' (the
|
||||
// preload IPC bridge) or 'web' (src/web-bridge, same-origin gateway
|
||||
// calls). Absent means electron (bridges older than the marker). UI
|
||||
// that only makes sense on one host gates on this single field.
|
||||
host?: 'electron' | 'web'
|
||||
// Resolve a backend connection. Omit `profile` (or pass the primary) for
|
||||
// the window's backend; pass a named profile to lazily spawn/reuse that
|
||||
// profile's backend from the pool.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ import { I18nProvider } from './i18n'
|
|||
import { installClipboardShim } from './lib/clipboard'
|
||||
import { queryClient } from './lib/query-client'
|
||||
import { ThemeProvider } from './themes/context'
|
||||
import { installWebBridge } from './web-bridge'
|
||||
|
||||
// When no Electron preload has installed the IPC bridge, this window is a
|
||||
// plain browser (the gateway-served /app surface, or vite dev against a
|
||||
// dashboard backend): install the web bridge so the same renderer runs
|
||||
// unmodified. Under Electron the preload has already claimed the window and
|
||||
// this is a no-op. See docs/plans/2026-07-10-001.
|
||||
installWebBridge()
|
||||
|
||||
installClipboardShim()
|
||||
|
||||
|
|
|
|||
550
apps/desktop/src/web-bridge/index.ts
Normal file
550
apps/desktop/src/web-bridge/index.ts
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
/**
|
||||
* The web implementation of `window.hermesDesktop` — the bridge the desktop
|
||||
* renderer uses for every platform service (RFC docs/plans/2026-07-10-001,
|
||||
* Phase 1).
|
||||
*
|
||||
* Under Electron the preload script installs the real IPC bridge before any
|
||||
* renderer code runs, so this module is inert there. In a plain browser
|
||||
* (`/app` served by the gateway, or `vite dev` with the HERMES_SPIKE_BACKEND
|
||||
* proxy) no preload exists, and main.tsx installs this bridge instead.
|
||||
*
|
||||
* Design rules:
|
||||
* - Same-origin only. Every REST call and WebSocket targets the page origin;
|
||||
* auth mirrors web/src/lib/api.ts exactly (the dashboard SPA, which is the
|
||||
* proven browser client of this backend):
|
||||
* · gated / OAuth mode (`window.__HERMES_AUTH_REQUIRED__`): HttpOnly
|
||||
* session cookie rides on `credentials: 'include'`; WS upgrades mint a
|
||||
* single-use ticket at POST /api/auth/ws-ticket.
|
||||
* · loopback / token mode: the gateway injects
|
||||
* `window.__HERMES_SESSION_TOKEN__` into index.html; REST sends it as
|
||||
* X-Hermes-Session-Token, WS appends `?token=`.
|
||||
* - The synthesized connection reports `mode: 'remote'` (RFC D4). That flips
|
||||
* isDesktopFsRemoteMode() and every remote-mode branch, so fs/git/session
|
||||
* surfaces route to the backend's filesystem through the same code paths
|
||||
* the Electron app uses against a remote gateway.
|
||||
* - Everything Electron-only returns an inert value and is listed in
|
||||
* WEB_STUBBED_SURFACE below. The parity test (parity.test.ts) fails when a
|
||||
* new preload method is neither implemented nor registered there, so the
|
||||
* two bridges cannot drift silently.
|
||||
*/
|
||||
import { buildHermesWebSocketUrl } from '@hermes/shared'
|
||||
|
||||
type HermesDesktopBridge = NonNullable<Window['hermesDesktop']>
|
||||
|
||||
/**
|
||||
* Preload methods that exist on web only as inert stubs, with the reason.
|
||||
* Grow this list deliberately — each entry is a product decision, not a
|
||||
* shrug. The parity test enforces membership.
|
||||
*/
|
||||
export const WEB_STUBBED_SURFACE: Record<string, string> = {
|
||||
applyConnectionConfig: 'connection is fixed to the serving origin',
|
||||
cancelBootstrap: 'no first-launch bootstrap in a browser',
|
||||
cloud: 'the browser reaches agents via the portal, not embedded discovery',
|
||||
fetchLinkTitle: 'needs a cross-origin fetch proxy; PrettyLink falls back to the URL',
|
||||
getRecentLogs: 'main-process log buffer does not exist on web',
|
||||
normalizePreviewTarget: 'main-process path/URL normalization; preview falls back',
|
||||
notify: 'delivered via the Notification API when permitted',
|
||||
oauthLoginConnectionConfig: 'sign-in happens on the gateway login page itself',
|
||||
oauthLogoutConnectionConfig: 'sign-out happens on the gateway login page itself',
|
||||
onBackendExit: 'no child process to observe',
|
||||
onBootProgress: 'no main-process boot pipeline; snapshot comes from getBootProgress',
|
||||
onBootstrapEvent: 'no first-launch bootstrap in a browser',
|
||||
onPreviewFileChanged: 'no file watcher on web (Phase 2 candidate)',
|
||||
petOverlay: 'no OS overlay windows in a browser',
|
||||
probeConnectionConfig: 'connection is fixed to the serving origin',
|
||||
profile: 'a hosted instance serves one profile',
|
||||
repairBootstrap: 'no first-launch bootstrap in a browser',
|
||||
requestMicrophoneAccess: 'getUserMedia prompts at point of use',
|
||||
resetBootstrap: 'no first-launch bootstrap in a browser',
|
||||
revealLogs: 'no local log directory to reveal',
|
||||
saveConnectionConfig: 'connection is fixed to the serving origin',
|
||||
selectPaths: 'native file picker; gateway-fs picker is Phase 2',
|
||||
settings: 'default project dir is a local-machine concept',
|
||||
stopPreviewFileWatch: 'no file watcher on web (Phase 2 candidate)',
|
||||
terminal: 'PTY-over-WebSocket lands in Phase 2 (/api/pty)',
|
||||
testConnectionConfig: 'connection is fixed to the serving origin',
|
||||
themes: 'marketplace fetch needs a proxy endpoint; deferred (RFC D6)',
|
||||
uninstall: 'nothing installed locally',
|
||||
updates: 'the instance updates server-side',
|
||||
watchPreviewFile: 'no file watcher on web (Phase 2 candidate)'
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload methods deliberately ABSENT from the web bridge. Every entry is
|
||||
* optional in the `Window['hermesDesktop']` type and every caller guards with
|
||||
* `?.`, so absence cleanly disables the feature (the honest signal — no inert
|
||||
* function pretending to work). The parity test enforces that each entry
|
||||
* really is missing from the web bridge and really does exist on preload.
|
||||
*/
|
||||
export const WEB_OMITTED_SURFACE: Record<string, string> = {
|
||||
git: 'remote mode routes desktop-git.ts to /api/git/* (remoteGit); the Electron-local branch is unused',
|
||||
onClosePreviewRequested: 'no app menu to emit it',
|
||||
onConnectionApplied: 'connection cannot be soft-switched from the server side',
|
||||
onDeepLink: 'no hermes:// protocol handler in a browser tab',
|
||||
onFocusSession: 'no cross-window notification routing',
|
||||
onNotificationAction: 'Notification API actions are not wired (Phase 2 with real notify)',
|
||||
onOpenUpdatesRequested: 'no app menu to emit it',
|
||||
onPowerResume: "browsers have no resume signal; the boot path's online/visibilitychange listeners cover wake",
|
||||
onWindowStateChanged: 'no native window chrome to report',
|
||||
renamePath: 'no gateway rename endpoint yet; project-tree rename hides',
|
||||
revealPath: 'no OS file manager to reveal into',
|
||||
setNativeTheme: 'no native window chrome to theme',
|
||||
setPreviewShortcutActive: 'no global shortcut registration',
|
||||
setTitleBarTheme: 'no native title bar',
|
||||
setTranslucency: 'no compositor-backed window translucency',
|
||||
trashPath: 'no OS trash; destructive delete needs its own web decision',
|
||||
zoom: 'the browser owns page zoom (Ctrl +/-)'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth + transport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface WebAuthGlobals {
|
||||
__HERMES_AUTH_REQUIRED__?: boolean
|
||||
__HERMES_BASE_PATH__?: string
|
||||
__HERMES_SESSION_TOKEN__?: string
|
||||
}
|
||||
|
||||
function authGlobals(): WebAuthGlobals {
|
||||
return window as WebAuthGlobals
|
||||
}
|
||||
|
||||
/** URL prefix when the gateway is reverse-proxied below a subpath. */
|
||||
function basePath(): string {
|
||||
return authGlobals().__HERMES_BASE_PATH__ ?? ''
|
||||
}
|
||||
|
||||
function isGated(): boolean {
|
||||
return Boolean(authGlobals().__HERMES_AUTH_REQUIRED__)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loopback/token-mode session token. The gateway injects it into the served
|
||||
* index.html; the vite-dev fallback keeps the HERMES_SPIKE_BACKEND proxy
|
||||
* workflow usable, where no HTML injection happens.
|
||||
*/
|
||||
function sessionToken(): string {
|
||||
return authGlobals().__HERMES_SESSION_TOKEN__ ?? import.meta.env.VITE_HERMES_SPIKE_TOKEN ?? ''
|
||||
}
|
||||
|
||||
async function webFetch(path: string, init?: RequestInit & { timeoutMs?: number }): Promise<Response> {
|
||||
const headers = new Headers(init?.headers)
|
||||
|
||||
if (!isGated() && !headers.has('X-Hermes-Session-Token')) {
|
||||
headers.set('X-Hermes-Session-Token', sessionToken())
|
||||
}
|
||||
|
||||
return fetch(`${basePath()}${path}`, {
|
||||
...init,
|
||||
credentials: init?.credentials ?? 'include',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(init?.timeoutMs ?? 30_000)
|
||||
})
|
||||
}
|
||||
|
||||
async function webFetchJson<T>(path: string, init?: RequestInit & { timeoutMs?: number }): Promise<T> {
|
||||
const res = await webFetch(path, init)
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
|
||||
throw new Error(`${res.status}: ${text || res.statusText}`)
|
||||
}
|
||||
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the WS auth query pair. Gated mode mints a fresh single-use ticket
|
||||
* (short TTL — callers must mint immediately before opening the socket, which
|
||||
* is exactly what resolveGatewayWsUrl() in the boot path does by re-calling
|
||||
* getGatewayWsUrl on every connect).
|
||||
*/
|
||||
async function wsAuthParam(): Promise<readonly [string, string]> {
|
||||
if (isGated()) {
|
||||
const { ticket } = await webFetchJson<{ ticket: string }>('/api/auth/ws-ticket', { method: 'POST' })
|
||||
|
||||
return ['ticket', ticket]
|
||||
}
|
||||
|
||||
return ['token', sessionToken()]
|
||||
}
|
||||
|
||||
async function gatewayWsUrl(): Promise<string> {
|
||||
return buildHermesWebSocketUrl({
|
||||
authParam: await wsAuthParam(),
|
||||
basePath: basePath(),
|
||||
path: '/api/ws'
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection synthesis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function webConnection(): Awaited<ReturnType<HermesDesktopBridge['getConnection']>> {
|
||||
return {
|
||||
// 'oauth' makes the boot path mint a fresh ticket per (re)connect and
|
||||
// routes reauth failures to the sign-in-again flow (GatewayReauthRequired).
|
||||
authMode: isGated() ? 'oauth' : 'token',
|
||||
baseUrl: `${window.location.origin}${basePath()}`,
|
||||
isFullscreen: false,
|
||||
logs: [],
|
||||
// 'remote' is the keystone (RFC D4): every fs/git/session surface treats
|
||||
// the backend as the filesystem of record, exactly like the Electron
|
||||
// app's remote-gateway mode.
|
||||
mode: 'remote',
|
||||
nativeOverlayWidth: 0,
|
||||
source: 'settings',
|
||||
token: isGated() ? '' : sessionToken(),
|
||||
windowButtonPosition: null,
|
||||
// Pre-built URL for consumers that read conn.wsUrl directly. In gated
|
||||
// mode a baked ticket would be stale by connect time; the boot path
|
||||
// always re-mints via getGatewayWsUrl (resolveGatewayWsUrl), so bake the
|
||||
// token form only when it is actually valid.
|
||||
wsUrl: buildHermesWebSocketUrl({
|
||||
authParam: isGated() ? undefined : ['token', sessionToken()],
|
||||
basePath: basePath(),
|
||||
path: '/api/ws'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const unsubscribe = () => () => {}
|
||||
|
||||
function anchorDownload(href: string, filename?: string) {
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
a.download = filename ?? ''
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
|
||||
function openTab(url: string): void {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function appUrl(hashRoute: string): string {
|
||||
return `${window.location.origin}${window.location.pathname}${hashRoute}`
|
||||
}
|
||||
|
||||
const INACTIVE_BOOTSTRAP: Awaited<ReturnType<HermesDesktopBridge['getBootstrapState']>> = {
|
||||
active: false,
|
||||
completedAt: null,
|
||||
error: null,
|
||||
log: [],
|
||||
manifest: null,
|
||||
stages: {},
|
||||
startedAt: null,
|
||||
unsupportedPlatform: null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The bridge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createWebBridge(): HermesDesktopBridge {
|
||||
const bridge: HermesDesktopBridge = {
|
||||
host: 'web',
|
||||
|
||||
// --- connection + boot -------------------------------------------------
|
||||
api: async request => {
|
||||
const hasBody = request.body !== undefined
|
||||
|
||||
return webFetchJson(request.path, {
|
||||
body: hasBody ? JSON.stringify(request.body) : undefined,
|
||||
headers: hasBody ? { 'Content-Type': 'application/json' } : undefined,
|
||||
method: request.method ?? 'GET',
|
||||
timeoutMs: request.timeoutMs
|
||||
})
|
||||
},
|
||||
getConnection: async () => webConnection(),
|
||||
getGatewayWsUrl: async () => gatewayWsUrl(),
|
||||
revalidateConnection: async () => ({ ok: true, rebuilt: false }),
|
||||
touchBackend: async () => ({ ok: true }),
|
||||
getBootProgress: async () => ({
|
||||
error: null,
|
||||
fakeMode: false,
|
||||
message: 'Connecting to gateway',
|
||||
phase: 'renderer.boot',
|
||||
progress: 90,
|
||||
running: true,
|
||||
timestamp: Date.now()
|
||||
}),
|
||||
getConnectionConfig: async () => ({
|
||||
cloudOrg: '',
|
||||
envOverride: false,
|
||||
mode: 'remote',
|
||||
profile: null,
|
||||
remoteAuthMode: isGated() ? 'oauth' : 'token',
|
||||
remoteOauthConnected: isGated(),
|
||||
remoteTokenPreview: null,
|
||||
remoteTokenSet: !isGated(),
|
||||
remoteUrl: `${window.location.origin}${basePath()}`
|
||||
}),
|
||||
getVersion: async () => ({
|
||||
appVersion: 'web',
|
||||
electronVersion: 'web',
|
||||
hermesRoot: '',
|
||||
nodeVersion: 'web',
|
||||
platform: 'web'
|
||||
}),
|
||||
getRemoteDisplayReason: async () => null,
|
||||
sanitizeWorkspaceCwd: async cwd => ({ cwd: cwd ?? '', sanitized: false }),
|
||||
|
||||
// --- windows (browser tabs) --------------------------------------------
|
||||
openSessionWindow: async sessionId => {
|
||||
const id = String(sessionId ?? '').trim()
|
||||
|
||||
if (!id) {
|
||||
return { error: 'invalid-session', ok: false }
|
||||
}
|
||||
|
||||
openTab(appUrl(`#/${encodeURIComponent(id)}`))
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
openNewSessionWindow: async () => {
|
||||
openTab(appUrl('#/'))
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
openExternal: async url => {
|
||||
openTab(url)
|
||||
},
|
||||
openPreviewInBrowser: async url => {
|
||||
openTab(url)
|
||||
},
|
||||
signalDeepLinkReady: async () => ({ ok: true }),
|
||||
|
||||
// --- clipboard / files / media ------------------------------------------
|
||||
writeClipboard: async text => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
|
||||
return true
|
||||
},
|
||||
getPathForFile: () => '',
|
||||
readFileDataUrl: async filePath => {
|
||||
const { dataUrl } = await webFetchJson<{ dataUrl: string }>(
|
||||
`/api/fs/read-data-url?path=${encodeURIComponent(filePath)}`
|
||||
)
|
||||
|
||||
return dataUrl
|
||||
},
|
||||
readFileText: async filePath =>
|
||||
webFetchJson(`/api/fs/read-text?path=${encodeURIComponent(filePath)}`),
|
||||
readDir: async dirPath => {
|
||||
try {
|
||||
return await webFetchJson(`/api/fs/list?path=${encodeURIComponent(dirPath)}`)
|
||||
} catch (error) {
|
||||
return { entries: [], error: error instanceof Error ? error.message : 'read-error' }
|
||||
}
|
||||
},
|
||||
gitRoot: async startPath => {
|
||||
const { root } = await webFetchJson<{ root: null | string }>(
|
||||
`/api/fs/git-root?path=${encodeURIComponent(startPath)}`
|
||||
)
|
||||
|
||||
return root
|
||||
},
|
||||
writeTextFile: async (filePath, content) =>
|
||||
webFetchJson('/api/fs/write-text', {
|
||||
body: JSON.stringify({ content, path: filePath }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST'
|
||||
}),
|
||||
saveImageFromUrl: async url => {
|
||||
anchorDownload(url)
|
||||
|
||||
return true
|
||||
},
|
||||
saveImageBuffer: async (data, ext) => {
|
||||
const blob = new Blob([data instanceof Uint8Array ? (data as Uint8Array<ArrayBuffer>) : data])
|
||||
const href = URL.createObjectURL(blob)
|
||||
const filename = `hermes-image-${Date.now()}.${ext.replace(/^\./, '')}`
|
||||
anchorDownload(href, filename)
|
||||
setTimeout(() => URL.revokeObjectURL(href), 30_000)
|
||||
|
||||
return filename
|
||||
},
|
||||
saveClipboardImage: async () => {
|
||||
// Composer paste path: the bytes must land on the BACKEND (vision needs
|
||||
// them; the browser page has no local path), so stage through the same
|
||||
// upload endpoint the dashboard chat uses and hand back the
|
||||
// gateway-visible path.
|
||||
try {
|
||||
const items = await navigator.clipboard.read()
|
||||
|
||||
for (const item of items) {
|
||||
const mime = item.types.find(t => t.startsWith('image/'))
|
||||
|
||||
if (!mime) {
|
||||
continue
|
||||
}
|
||||
|
||||
const blob = await item.getType(mime)
|
||||
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
|
||||
const { path } = await webFetchJson<{ path: string }>('/api/chat/image-upload', {
|
||||
body: JSON.stringify({ data_url: dataUrl }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
return path
|
||||
}
|
||||
} catch {
|
||||
// Permission denied or no image on the clipboard — same "nothing to
|
||||
// paste" result either way.
|
||||
}
|
||||
|
||||
return ''
|
||||
},
|
||||
|
||||
// --- boot-blocking bootstrap snapshot (spike pitfall #1) -----------------
|
||||
getBootstrapState: async () => INACTIVE_BOOTSTRAP,
|
||||
|
||||
// --- inert surface (see WEB_STUBBED_SURFACE for reasons) ----------------
|
||||
applyConnectionConfig: async () => {
|
||||
throw new Error('The connection is managed by the server on web.')
|
||||
},
|
||||
saveConnectionConfig: async () => {
|
||||
throw new Error('The connection is managed by the server on web.')
|
||||
},
|
||||
testConnectionConfig: async () => ({
|
||||
baseUrl: `${window.location.origin}${basePath()}`,
|
||||
ok: true,
|
||||
version: null
|
||||
}),
|
||||
probeConnectionConfig: async remoteUrl => ({
|
||||
authMode: 'unknown',
|
||||
baseUrl: remoteUrl,
|
||||
error: 'Connection probing is not available on web.',
|
||||
providers: [],
|
||||
reachable: false,
|
||||
version: null
|
||||
}),
|
||||
oauthLoginConnectionConfig: async remoteUrl => ({
|
||||
baseUrl: remoteUrl,
|
||||
connected: false,
|
||||
ok: false
|
||||
}),
|
||||
oauthLogoutConnectionConfig: async () => ({ connected: false, ok: false }),
|
||||
cloud: {
|
||||
agentSignIn: async dashboardUrl => ({ baseUrl: dashboardUrl, connected: false }),
|
||||
discover: async () => ({ agents: [], needsOrgSelection: false }),
|
||||
login: async () => ({ ok: false, portalBaseUrl: '', signedIn: false }),
|
||||
logout: async () => ({ ok: true, portalBaseUrl: '', signedIn: false }),
|
||||
status: async () => ({ portalBaseUrl: '', signedIn: false })
|
||||
},
|
||||
profile: {
|
||||
get: async () => ({ profile: null }),
|
||||
set: async () => ({ profile: null })
|
||||
},
|
||||
notify: async payload => {
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') {
|
||||
return false
|
||||
}
|
||||
|
||||
new Notification(payload.title ?? 'Hermes', { body: payload.body, silent: payload.silent })
|
||||
|
||||
return true
|
||||
},
|
||||
requestMicrophoneAccess: async () => true,
|
||||
selectPaths: async () => [],
|
||||
normalizePreviewTarget: async () => null,
|
||||
watchPreviewFile: async () => ({ id: '', path: '' }),
|
||||
stopPreviewFileWatch: async () => false,
|
||||
fetchLinkTitle: async () => '',
|
||||
settings: {
|
||||
getDefaultProjectDir: async () => ({ defaultLabel: '', dir: null, resolvedCwd: '' }),
|
||||
pickDefaultProjectDir: async () => ({ canceled: true, dir: null }),
|
||||
setDefaultProjectDir: async dir => ({ dir })
|
||||
},
|
||||
revealLogs: async () => ({ error: 'Not available on web.', ok: false, path: '' }),
|
||||
getRecentLogs: async () => ({ lines: [], path: '' }),
|
||||
petOverlay: {
|
||||
close: async () => ({ ok: false }),
|
||||
control: () => {},
|
||||
onControl: unsubscribe,
|
||||
onState: unsubscribe,
|
||||
open: async () => ({ ok: false }),
|
||||
pushState: () => {},
|
||||
setBounds: () => {},
|
||||
setFocusable: () => {},
|
||||
setIgnoreMouse: () => {}
|
||||
},
|
||||
terminal: {
|
||||
dispose: async () => false,
|
||||
onData: unsubscribe,
|
||||
onExit: unsubscribe,
|
||||
resize: async () => false,
|
||||
start: async () => {
|
||||
throw new Error('The embedded terminal is not available on web yet.')
|
||||
},
|
||||
write: async () => false
|
||||
},
|
||||
onPreviewFileChanged: unsubscribe,
|
||||
onBackendExit: unsubscribe,
|
||||
onBootProgress: unsubscribe,
|
||||
onBootstrapEvent: unsubscribe,
|
||||
resetBootstrap: async () => ({ ok: false }),
|
||||
repairBootstrap: async () => ({ ok: false }),
|
||||
cancelBootstrap: async () => ({ cancelled: false, ok: false }),
|
||||
updates: {
|
||||
apply: async () => ({ error: 'Updates are managed server-side on web.', ok: false }),
|
||||
check: async () => ({ reason: 'web', supported: false }),
|
||||
getBranch: async () => ({ branch: '' }),
|
||||
onProgress: unsubscribe,
|
||||
setBranch: async name => ({ branch: name })
|
||||
},
|
||||
uninstall: {
|
||||
run: async () => ({ error: 'Not available on web.', ok: false }),
|
||||
summary: async () => ({
|
||||
agent_installed: false,
|
||||
gui_installed: false,
|
||||
hermes_home: '',
|
||||
packaged_app_paths: [],
|
||||
platform: 'web',
|
||||
source_built_artifacts: [],
|
||||
userdata_dir: '',
|
||||
userdata_exists: false
|
||||
})
|
||||
},
|
||||
themes: {
|
||||
fetchMarketplace: async () => {
|
||||
throw new Error('Theme marketplace is not available on web yet.')
|
||||
},
|
||||
searchMarketplace: async () => []
|
||||
}
|
||||
}
|
||||
|
||||
return bridge
|
||||
}
|
||||
|
||||
/** True when the renderer is running against the web bridge (no Electron). */
|
||||
export function isWebHost(): boolean {
|
||||
return window.hermesDesktop?.host === 'web'
|
||||
}
|
||||
|
||||
/** Install the web bridge when no Electron preload has claimed the window. */
|
||||
export function installWebBridge(): void {
|
||||
if (window.hermesDesktop) {
|
||||
return
|
||||
}
|
||||
|
||||
window.hermesDesktop = createWebBridge()
|
||||
}
|
||||
107
apps/desktop/src/web-bridge/parity.test.ts
Normal file
107
apps/desktop/src/web-bridge/parity.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Bridge parity contract (RFC docs/plans/2026-07-10-001 §2.1).
|
||||
*
|
||||
* The renderer runs against two bridge implementations: the Electron preload
|
||||
* (electron/preload.ts) and the web bridge (this directory). This test pins
|
||||
* the RELATION between them — not a snapshot of either:
|
||||
*
|
||||
* every key the preload exposes ⊆ web-implemented ∪ WEB_OMITTED_SURFACE
|
||||
*
|
||||
* with WEB_STUBBED_SURFACE / WEB_OMITTED_SURFACE required to stay accurate
|
||||
* (no stale entries, omissions really absent). Adding a method to preload.ts
|
||||
* without deciding its web story fails here — the failure message tells the
|
||||
* author exactly what decision is owed.
|
||||
*
|
||||
* The preload is imported with 'electron' mocked, so the object under test is
|
||||
* the REAL exposed bridge, not a copy of its key list.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createWebBridge, WEB_OMITTED_SURFACE, WEB_STUBBED_SURFACE } from './index'
|
||||
|
||||
const captured = vi.hoisted(() => ({ apis: {} as Record<string, Record<string, unknown>> }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
contextBridge: {
|
||||
exposeInMainWorld: (name: string, api: Record<string, unknown>) => {
|
||||
captured.apis[name] = api
|
||||
}
|
||||
},
|
||||
ipcRenderer: {
|
||||
invoke: async () => undefined,
|
||||
on: () => {},
|
||||
removeListener: () => {},
|
||||
send: () => {}
|
||||
},
|
||||
webUtils: {
|
||||
getPathForFile: () => ''
|
||||
}
|
||||
}))
|
||||
|
||||
// Side-effect import: runs exposeInMainWorld with the mocked contextBridge.
|
||||
await import('../../electron/preload')
|
||||
|
||||
function preloadBridge(): Record<string, unknown> {
|
||||
const api = captured.apis.hermesDesktop
|
||||
|
||||
if (!api) {
|
||||
throw new Error('preload.ts did not expose hermesDesktop — did the module shape change?')
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
describe('web bridge parity with the Electron preload', () => {
|
||||
const preload = preloadBridge()
|
||||
const web = createWebBridge() as unknown as Record<string, unknown>
|
||||
|
||||
it('both bridges declare their host marker', () => {
|
||||
expect(preload.host).toBe('electron')
|
||||
expect(web.host).toBe('web')
|
||||
})
|
||||
|
||||
it('every preload key is web-implemented or a registered omission', () => {
|
||||
const undecided = Object.keys(preload).filter(key => !(key in web) && !(key in WEB_OMITTED_SURFACE))
|
||||
|
||||
// A non-empty list means preload.ts gained surface whose web story nobody
|
||||
// decided. Implement it in createWebBridge(), stub it there (and register
|
||||
// the reason in WEB_STUBBED_SURFACE), or register a deliberate absence in
|
||||
// WEB_OMITTED_SURFACE.
|
||||
expect(undecided).toEqual([])
|
||||
})
|
||||
|
||||
it('registered omissions are really absent from the web bridge', () => {
|
||||
const wronglyPresent = Object.keys(WEB_OMITTED_SURFACE).filter(key => key in web)
|
||||
|
||||
expect(wronglyPresent).toEqual([])
|
||||
})
|
||||
|
||||
it('registries carry no stale entries for surface the preload no longer has', () => {
|
||||
const staleOmissions = Object.keys(WEB_OMITTED_SURFACE).filter(key => !(key in preload))
|
||||
const staleStubs = Object.keys(WEB_STUBBED_SURFACE).filter(key => !(key in preload))
|
||||
|
||||
expect(staleOmissions).toEqual([])
|
||||
expect(staleStubs).toEqual([])
|
||||
})
|
||||
|
||||
it('registered stubs exist on the web bridge', () => {
|
||||
const missingStubs = Object.keys(WEB_STUBBED_SURFACE).filter(key => !(key in web))
|
||||
|
||||
expect(missingStubs).toEqual([])
|
||||
})
|
||||
|
||||
it('namespace objects present on both bridges expose the same methods', () => {
|
||||
const namespaces = Object.keys(preload).filter(
|
||||
key => typeof preload[key] === 'object' && preload[key] !== null && key in web
|
||||
)
|
||||
|
||||
for (const namespace of namespaces) {
|
||||
const preloadKeys = Object.keys(preload[namespace] as object).sort()
|
||||
const webKeys = Object.keys(web[namespace] as object).sort()
|
||||
|
||||
// Same relation one level down: a method added to a preload namespace
|
||||
// must appear on the web namespace too (implemented or inert).
|
||||
expect({ [namespace]: webKeys }).toEqual({ [namespace]: preloadKeys })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -72,7 +72,20 @@ export default defineConfig({
|
|||
strictPort: true,
|
||||
fs: {
|
||||
allow: fsAllow
|
||||
}
|
||||
},
|
||||
// PHASE 0 SPIKE: `HERMES_SPIKE_BACKEND=http://127.0.0.1:<port> vite` proxies
|
||||
// /api (HTTP + WS) to a running `hermes dashboard`, standing in for the
|
||||
// same-origin serving the real deployment gets from mount_spa. Unset (the
|
||||
// normal Electron dev flow) this adds nothing. See docs/plans/2026-07-10-001.
|
||||
proxy: process.env.HERMES_SPIKE_BACKEND
|
||||
? {
|
||||
'/api': {
|
||||
target: process.env.HERMES_SPIKE_BACKEND,
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
preview: {
|
||||
host: '127.0.0.1',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue