hermes-agent / apps /desktop /src /api /plugins.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
a1b6014 verified
Raw History Blame Contribute Delete
5.65 kB
import { reconnectBackoffDelayMs } from '@hermes/shared'
import type { HermesConnection } from '@/global'
import { RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout'
import { getApiRequestConnection, getApiRequestProfile, hermesApi, profileScoped } from './client'
/** Resolve the ACTIVE backend's connection descriptor, (connectionId,
* profile)-scoped β€” mirroring how store/profile resolves $connection: a
* registry agent's descriptor comes from getConnectionFor (its SOURCE
* connection), everything else from the profile-keyed local pool. The
* getConnectionFor bridge is optional (older Desktop mains); without it the
* profile-scoped pool lookup is the best available answer.
*
* Both branches are IPC round-trips into the main process with no timeout of
* their own (#93454) β€” a wedged main-process round-trip otherwise hangs
* pluginSocket's connect() forever instead of falling back to the polling
* fallback every consumer already has. Bound the same way store/gateway's
* openSecondary bounds the same *For/plain pair.
*
* Exported for tests. */
export async function activeConnection(): Promise<HermesConnection> {
const getConnectionFor = window.hermesDesktop.getConnectionFor
const connectionId = getApiRequestConnection()
const profile = getApiRequestProfile()
if (connectionId && getConnectionFor) {
return withTimeout(
getConnectionFor({ connectionId, profile }),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out connecting to profile "${profile}"`
)
}
return withTimeout(
window.hermesDesktop.getConnection(profile),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out connecting to profile "${profile}"`
)
}
/** Options for a plugin REST call β€” mirrors the app's own `hermesDesktop.api`
* shape, minus the path (which is namespace-derived). */
export interface PluginRestOptions {
method?: string
body?: unknown
/** Single-file multipart upload (see HermesApiRequest.upload). */
upload?: { filename: string; contentType?: string; bytes: ArrayBuffer }
timeoutMs?: number
}
// Normalize `path` to a leading-slash suffix relative to `/api/plugins/<id>`.
// The namespace is the boundary β€” reject `..` so a relative segment can't
// normalize out into another plugin's API or a core route. Check the path
// portion only (before any query/hash).
function pluginPathSuffix(caller: string, path: string): string {
const suffix = path.startsWith('/') ? path : `/${path}`
if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) {
throw new Error(`${caller}: illegal path traversal in "${path}"`)
}
return suffix
}
/** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's
* own backend namespace β€” `path` is relative to `/api/plugins/<pluginId>`
* ('/board' β†’ `/api/plugins/kanban/board`), so a plugin can't address another
* plugin's API or a core route through it. Profile-aware like every desktop
* REST call. Broader reach (core endpoints, another namespace) is the future
* declared-capability seam; today the namespace IS the boundary. */
export async function pluginRest<T>(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise<T> {
if (!window.hermesDesktop?.api) {
throw new Error('Hermes desktop bridge unavailable')
}
const suffix = pluginPathSuffix('pluginRest', path)
return hermesApi<T>({
path: `/api/plugins/${pluginId}${suffix}`,
method: opts.method,
body: opts.body,
upload: opts.upload,
timeoutMs: opts.timeoutMs,
...profileScoped()
})
}
/** The plugin WebSocket door β€” the live twin of `pluginRest`, scoped the same
* way: `path` is relative to `/api/plugins/<pluginId>` ('/events' β†’ the
* plugin's own event stream). Token-mode backends auth via the same query
* credential the app's own sockets use; OAuth remotes resolve null (callers
* keep their polling fallback β€” every consumer must have one anyway, since a
* socket can drop). Auto-reconnects with backoff until disposed. */
export function pluginSocket(pluginId: string, path: string, onMessage: (data: unknown) => void): () => void {
const suffix = pluginPathSuffix('pluginSocket', path)
let socket: null | WebSocket = null
let disposed = false
let attempt = 0
const connect = async () => {
const connection = await activeConnection().catch(() => null)
// No bridge / OAuth cookie auth (WS tickets are single-use, core-managed):
// stay on the polling fallback rather than half-working.
if (disposed || !connection || connection.authMode === 'oauth') {
return
}
const base = connection.baseUrl.replace(/^http/, 'ws')
const join = suffix.includes('?') ? '&' : '?'
socket = new WebSocket(
`${base}/api/plugins/${pluginId}${suffix}${join}token=${encodeURIComponent(connection.token)}`
)
socket.onmessage = event => {
attempt = 0
try {
onMessage(JSON.parse(String(event.data)))
} catch {
// Non-JSON frame β€” plugin streams are JSON by contract; skip it.
}
}
socket.onclose = () => {
socket = null
if (!disposed) {
// Full-jitter exponential backoff: same rationale as the gateway
// socket reconnect loops β€” an immediate-retry loop across many
// desktop clients floods the gateway with connection attempts
// during a restart.
window.setTimeout(() => void connect(), reconnectBackoffDelayMs(attempt, { baseDelayMs: 500, capMs: 30_000 }))
attempt += 1
}
}
}
void connect()
return () => {
disposed = true
socket?.close()
}
}