Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
4996448
feat: Bot Screen — per-bot Xfce desktop streamed into Hermes Desktop …
teknium1 Sep 12, 2026
b3e8045
fix(desktop): Bot Screen pane drops its RFB ref once noVNC closes itself
teknium1 Sep 12, 2026
e89b007
fix(desktop): declare @novnc/novnc types in the tracked vite-env.d.ts
teknium1 Sep 12, 2026
255f5c2
feat(bot-screen): Screen portal in routines + sessions, one-click pac…
teknium1 Sep 12, 2026
52c9321
feat(bot-screen): live desktop preview as the hero of a bot's Schedul…
teknium1 Sep 12, 2026
eebd158
feat(bot-screen): Hermes look for the bot's desktop — wallpaper, dark…
teknium1 Sep 12, 2026
f0d1ccf
fix(bot-screen): lease shared across processes, takeover fences in-fl…
teknium1 Sep 12, 2026
5f3710b
fix(desktop): sort the clearSudoRequest import (lint)
teknium1 Sep 12, 2026
fc39bea
fix(bot-screen): browser tools obey the lease, epoch-only fence, drop…
teknium1 Sep 12, 2026
dcba535
fix(bot-screen): events pinned to the bot's connection, stale hero fr…
teknium1 Sep 12, 2026
f89e71e
fix(bot-desktop): lease no longer needs fcntl at import time
teknium1 Sep 13, 2026
36c0489
chore(windows-footguns): flag module-level imports of POSIX-only stdl…
teknium1 Sep 13, 2026
dbd5d60
fix(display): mark the lease listener installed only after it is subs…
teknium1 Sep 13, 2026
ee3507b
fix(browser): fence the bot's browser by provenance, not by cdp_url
teknium1 Sep 13, 2026
b68f67d
fix(computer-use): fence a captured frame before it is persisted or s…
teknium1 Sep 13, 2026
fee40e7
fix(bot-desktop): lease reader fails closed on well-formed JSON of th…
teknium1 Sep 13, 2026
e5800fd
fix(display): display.thumbnail is suppressed while a human holds the…
teknium1 Sep 13, 2026
5dad306
fix(display): display.lease.release without a viewer_id no longer yan…
teknium1 Sep 13, 2026
6d69254
fix(computer-use): wait_for_human returns no_takeover when nobody ans…
teknium1 Sep 13, 2026
0dbc168
fix(bot-screen): a display ticket is not a gateway login on /api/ws
teknium1 Sep 13, 2026
99a1620
fix(bot-screen): bound RFB clipboard buffering
whyyagswhy Sep 12, 2026
eb1799b
refactor(bot-screen): keep only the RFB clipboard header cap
teknium1 Sep 13, 2026
329a160
fix(bot-screen): a viewer who handed back is not evicted by a later t…
teknium1 Sep 13, 2026
46b5c77
test(bot-screen): a 1005 close keeps the human's lease
teknium1 Sep 13, 2026
135c28f
perf(bot-screen): bridge caches the lease input decision
teknium1 Sep 13, 2026
bd9b4d5
fix(bot-screen): serialise the XAUTHORITY swap around thumbnail grabs
teknium1 Sep 13, 2026
732723c
fix(bot-screen): server-minted viewer ids; lease snapshots carry a ha…
teknium1 Sep 13, 2026
9a6126c
fix(bot-screen): release stranded control through CLI stop
whyyagswhy Sep 12, 2026
bd3e726
fix(bot-screen): Fedora per-binary package names, xprop required, bin…
teknium1 Sep 13, 2026
241081a
fix(bot-screen): launcher.pid carries the process birth time, not jus…
teknium1 Sep 13, 2026
a6ed11c
fix(bot-screen): hold the display-allocation and a per-profile start …
teknium1 Sep 13, 2026
0b7e9df
fix(bot-screen): truncate launcher.log on each start
teknium1 Sep 13, 2026
eaece2e
fix(bot-screen): launcher stays the supervisor so a dead Xfce session…
teknium1 Sep 13, 2026
a456aa0
fix(bot-screen): install timeout kills the package manager's group; a…
teknium1 Sep 13, 2026
b3eb315
fix(bot-screen): CLI `screen install` runs through the shared installer
teknium1 Sep 13, 2026
dcc8735
feat(computer-use): display identity helpers for cached-backend rebind
teknium1 Sep 13, 2026
64524cc
fix(bot-screen): cross-process lease changes reach Desktop clients as…
teknium1 Sep 13, 2026
31bbb7b
fix(bot-screen): agent attaches to a human-started dock Browser inste…
teknium1 Sep 13, 2026
8fa8c5e
fix(bot-screen): Xvnc stops broadcasting the screen clipboard to watc…
teknium1 Sep 13, 2026
e7477a9
docs(bot-screen): threat model, lease-file semantics, pane-close vs d…
teknium1 Sep 13, 2026
a7abbfd
fix(bot-screen): hand back on intentional pane closure
whyyagswhy Sep 12, 2026
07ee4e9
fix(bot-screen): keep portal state and previews with their owner
whyyagswhy Sep 12, 2026
f146577
fix(bot-screen): viewer identity is server-minted, "I hold" compares …
teknium1 Sep 13, 2026
cda9e8a
fix(bot-screen): hero says why the preview is hidden while a human ho…
teknium1 Sep 13, 2026
d17f101
fix(bot-screen): keep the bot's pooled socket open across install and…
teknium1 Sep 13, 2026
a0e7829
fix(bot-screen): read the bridge's control-taken verdict from the raw…
teknium1 Sep 13, 2026
2100432
fix(bot-screen): older backends without display.* settle instead of c…
teknium1 Sep 13, 2026
562331f
fix(bot-screen): a slower display.status reply can no longer roll bac…
teknium1 Sep 13, 2026
a659f61
feat(bot-screen): "Hand back (force)" for a lease this window no long…
teknium1 Sep 13, 2026
0ca9e9a
fix(bot-screen): mark the canvas host as a remote screen for the ⌘W r…
teknium1 Sep 13, 2026
090cc95
perf(bot-screen): sidebar group portal keeps one row identity across …
teknium1 Sep 13, 2026
171b5a1
fix(bot-screen): wire lane call sites: atomic install claim, redacted…
teknium1 Sep 13, 2026
b9887ea
fix(desktop): ⌘W on a remote bot screen closes nothing local; profile…
teknium1 Sep 13, 2026
d65af42
test(bot-screen): fixtures follow the integrated contracts (provenanc…
teknium1 Sep 13, 2026
d13f342
test(computer-use): cover native Windows and macOS registry dispatch
TheSmokeDev Sep 13, 2026
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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"@icons-pack/react-simple-icons": "13.11.1",
"@lezer/highlight": "1.2.3",
"@nanostores/react": "1.1.0",
"@novnc/novnc": "1.7.0",
"@nous-research/ui": "0.18.2",
"@streamdown/code": "1.1.1",
"@streamdown/math": "1.0.2",
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/app/chat/close-tab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ const nextSessionTileForWorkspace = vi.fn<() => null | string>(() => null)
const closeSessionTile = vi.fn()
const requestFreshSession = vi.fn()

const closeActiveTerminal = vi.fn()

vi.mock('@/app/right-sidebar/terminal/terminals', () => ({
closeActiveTerminal: () => closeActiveTerminal()
}))

vi.mock('@/components/pane-shell/tree/store', () => ({
closeFocusedSessionTab: () => closeFocusedSessionTab(),
closeFocusedToolTab: () => closeFocusedToolTab()
Expand Down Expand Up @@ -136,6 +142,20 @@ describe('closeWorkspaceTab', () => {
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})

it('a focused remote bot screen swallows ⌘W: no terminal tab, no session tab closes', async () => {
loadedMainOnly()
const combo = await import('@/lib/keybinds/combo')
const spy = vi.spyOn(combo, 'isFocusWithin').mockImplementation(selector => selector === '[data-remote-screen]' || selector === '[data-terminal]')

try {
expect(closeActiveTab(vi.fn())).toBe(true)
expect(closeActiveTerminal).not.toHaveBeenCalled()
expect(requestFreshSession).not.toHaveBeenCalled()
} finally {
spy.mockRestore()
}
})

it('a focused tool panel (terminal / logs) claims ⌘W before main empties', () => {
loadedMainOnly()
closeFocusedToolTab.mockReturnValue(true)
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/app/chat/close-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ export function closeWorkspaceTab(loadSessionIntoWorkspace?: (storedSessionId: s
* with its own tab strip closes ITS tab instead of main's.
*/
export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
// A remote bot screen borrows the terminal's keyboard ownership marker so bare keys reach it; ⌘W
// there belongs to the remote desktop, never to a local terminal tab or the session tab behind it.
if (isFocusWithin('[data-remote-screen]')) {
return true
}

if (isFocusWithin('[data-terminal]')) {
closeActiveTerminal()

Expand Down
51 changes: 50 additions & 1 deletion apps/desktop/src/app/chat/sidebar/gateway-groups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type { useSensors } from '@dnd-kit/core'
import { arrayMove } from '@dnd-kit/sortable'
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useState } from 'react'
import { useMemo, useState } from 'react'

import { type NewSessionSplitHandler, startNewSessionDrag } from '@/app/chat/new-session-drag'
import { type ProfileGroupHeaderContribution, SIDEBAR_PROFILE_GROUP_HEADER_AREA } from '@/app/routes'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Expand All @@ -18,6 +19,8 @@ import {
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { ProfileGlyph } from '@/components/ui/profile-glyph'
import { useContributions } from '@/contrib'
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { useStoreSelector } from '@/lib/use-session-slice'
Expand Down Expand Up @@ -272,6 +275,7 @@ function GatewayProfileGroup({
{open && (
<>
{children}
{group.profile ? <ProfileGroupHeaderSlot connectionId={group.connectionId ?? null} profile={group.profile} /> : null}
{renderRows(sessions.slice(0, visibleCount))}
{hiddenCount > 0 && (
<WorkspaceShowMoreButton
Expand Down Expand Up @@ -315,3 +319,48 @@ function GatewayProfileGroup({
</SidebarRowStack>
)
}

/** Plugin-contributed chrome at the top of one expanded gateway/profile group
* (`sidebar.profileGroup.header`): the Bots plugin mounts its Screen portal
* here so the profile's computer is one click away from its sessions. */
function ProfileGroupHeaderSlot({ connectionId, profile }: { connectionId: null | string; profile: string }) {
const items = useContributions(SIDEBAR_PROFILE_GROUP_HEADER_AREA)

if (!items.length) {
return null
}

return (
<div className="flex flex-col gap-1 px-2 pb-1">
{items.map(item => {
const data = item.data as Partial<ProfileGroupHeaderContribution> | undefined

if (typeof data?.render !== 'function') {
return null
}

return (
<ContribBoundary id={item.id} key={item.id} variant="chip">
<ProfileGroupHeaderItem connectionId={connectionId} profile={profile} render={data.render} />
</ContribBoundary>
)
})}
</div>
)
}

/** One stable render identity per (render, connection, profile): ContribRender mounts whatever
* function it is handed, so an inline closure would remount the contribution on every paint. */
function ProfileGroupHeaderItem({
connectionId,
profile,
render
}: {
connectionId: null | string
profile: string
render: ProfileGroupHeaderContribution['render']
}) {
const Row = useMemo(() => () => render({ connectionId, profile }), [connectionId, profile, render])

return <ContribRender render={Row} />
}
19 changes: 19 additions & 0 deletions apps/desktop/src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,25 @@ export interface SidebarNavContribution {
path: string
}

// ── Contributed profile-group header — the `sidebar.profileGroup.header` area ─
// A RENDER contribution mounted at the top of each gateway/profile group in the
// Sessions sidebar (above its session rows) while the group is expanded. The
// contribution's `data` is a `ProfileGroupHeaderContribution`; core calls
// `render(route)` with the group's connection + profile so one contribution
// serves every group. First consumer: the Bots plugin's Screen portal.

export const SIDEBAR_PROFILE_GROUP_HEADER_AREA = 'sidebar.profileGroup.header'

export interface ProfileGroupRoute {
connectionId: null | string
profile: string
}

/** Payload of a `sidebar.profileGroup.header` data contribution. */
export interface ProfileGroupHeaderContribution {
render: (route: ProfileGroupRoute) => ReactNode
}

// Views that render as a full-screen modal card (OverlayView) over the shell.
// While one is open the app's titlebar control clusters must hide so they don't
// bleed over the overlay (they sit at a higher z-index than the overlay card).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import {
import { $gateway } from '@/store/gateway'
import { setMcpSetupRequest } from '@/store/mcp-setup'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { $activeGatewayProfile } from '@/store/profile'
import {
$vaultCodeRequests,
$vaultSaveLoginRequests,
$vaultUnlockRequests,
clearSudoRequest,
clearVaultCodeRequest,
clearVaultSaveLoginRequest,
clearVaultUnlockRequest,
Expand Down Expand Up @@ -203,6 +205,14 @@ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean {
return true
}

if (event.type === 'sudo.expire' || event.type === 'display.install.sudo.expire') {
// The backend gave up waiting; tear the card down so a late Send cannot go anywhere.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
clearSudoRequest(sessionId ?? undefined, requestId || undefined)

return true
}

if (event.type === 'clarify.expire') {
if (!sessionId) {
return true
Expand Down Expand Up @@ -313,13 +323,20 @@ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean {
return true
}

if (event.type === 'sudo.request') {
// Sudo password capture (tools/terminal_tool.py). Blocked on
// sudo.respond {request_id, password}.
if (event.type === 'sudo.request' || event.type === 'display.install.sudo.request') {
// Sudo password capture (tools/terminal_tool.py), or the Bot Screen package install
// (tui_gateway/methods_display.py) reusing the same masked card. Blocked on
// <method>.respond {request_id, password}.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
const install = event.type === 'display.install.sudo.request'

if (requestId) {
setSudoRequest({ requestId, sessionId: sessionId ?? null })
setSudoRequest({
requestId,
sessionId: sessionId ?? null,
origin: { connectionId: event.connectionId ?? null, profile: event.profile ?? $activeGatewayProfile.get() },
...(install ? { respondMethod: 'display.install.sudo.respond', description: translateNow('prompts.sudoInstallDesc') } : {})
})

if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
Expand Down
18 changes: 12 additions & 6 deletions apps/desktop/src/components/prompt-overlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { useI18n } from '@/i18n'
import { isMissingPendingPromptRequest } from '@/lib/gateway-rpc'
import { triggerHaptic } from '@/lib/haptics'
import { KeyRound, Loader2, Lock, ShieldLock } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
import { $gateway, requestGatewayForAgent } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import {
clearSecretRequest,
Expand Down Expand Up @@ -78,10 +78,16 @@ function SudoDialog({ sessionId }: { sessionId: string | null }) {
setSubmitting(true)

try {
await gateway.request<{ status?: string }>('sudo.respond', {
password: value,
request_id: request.requestId
})
const method = request.respondMethod ?? 'sudo.respond'
const reply = { password: value, request_id: request.requestId }

// Pinned to the socket the request came from: the foreground gateway may be another host.
if (request.origin) {
await requestGatewayForAgent<{ status?: string }>(request.origin.connectionId, request.origin.profile, method, reply)
} else {
await gateway.request<{ status?: string }>(method, reply)
}

triggerHaptic('submit')
clearSudoRequest(request.sessionId, request.requestId)
} catch (error) {
Expand Down Expand Up @@ -126,7 +132,7 @@ function SudoDialog({ sessionId }: { sessionId: string | null }) {
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle icon={Lock}>{copy.sudoTitle}</DialogTitle>
<DialogDescription>{copy.sudoDesc}</DialogDescription>
<DialogDescription>{request.description ?? copy.sudoDesc}</DialogDescription>
</DialogHeader>

<form className="grid gap-3" onSubmit={onSubmit}>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3046,6 +3046,7 @@ export const ar = defineLocale({
secretSendFailed: 'فشل إرسال السر',
sudoTitle: 'مطلوب sudo',
sudoDesc: 'أدخل كلمة المرور لمتابعة الأمر.',
sudoInstallDesc: 'يحتاج Hermes إلى كلمة مرور sudo لتثبيت حزم Bot Screen (TigerVNC + Xfce) على مضيف البوابة. تُرسل إلى ذلك المضيف فقط.',
sudoPlaceholder: 'كلمة المرور',
secretTitle: 'مطلوب سر',
secretDesc: 'أدخل القيمة المطلوبة لمتابعة المهمة.',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4011,6 +4011,7 @@ export const en: Translations = {
secretSendFailed: 'Could not send secret',
sudoTitle: 'Administrator password',
sudoDesc: 'Hermes needs your sudo password to run a privileged command. It is sent only to your local agent.',
sudoInstallDesc: 'Hermes needs your sudo password to install the Bot Screen packages (TigerVNC + Xfce) on the gateway host. It is sent only to that host.',
sudoPlaceholder: 'sudo password',
secretTitle: 'Secret required',
secretDesc: 'Hermes needs a credential to continue.',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3456,6 +3456,7 @@ export const ja = defineLocale({
sudoTitle: '管理者パスワード',
sudoDesc:
'Hermes は特権コマンドを実行するために sudo パスワードが必要です。ローカルエージェントにのみ送信されます。',
sudoInstallDesc: 'Bot Screen のパッケージ(TigerVNC + Xfce)をゲートウェイホストにインストールするため、sudo パスワードが必要です。そのホストにのみ送信されます。',
sudoPlaceholder: 'sudo パスワード',
secretTitle: 'シークレットが必要です',
secretDesc: 'Hermes は続行するための認証情報が必要です。',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3753,6 +3753,7 @@ export const ru = defineLocale({
sudoTitle: 'Пароль администратора',
sudoDesc:
'Hermes нужен ваш пароль sudo, чтобы выполнить команду с повышенными правами. Он отправляется только вашему локальному агенту.',
sudoInstallDesc: 'Hermes нужен ваш пароль sudo, чтобы установить пакеты Bot Screen (TigerVNC + Xfce) на хосте шлюза. Он отправляется только на этот хост.',
sudoPlaceholder: 'пароль sudo',
secretTitle: 'Требуется секрет',
secretDesc: 'Hermes нужны учётные данные, чтобы продолжить.',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3505,6 +3505,7 @@ export interface Translations {
secretSendFailed: string
sudoTitle: string
sudoDesc: string
sudoInstallDesc: string
sudoPlaceholder: string
secretTitle: string
secretDesc: string
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3311,6 +3311,7 @@ export const zhHant = defineLocale({
secretSendFailed: '無法傳送密鑰',
sudoTitle: '管理員密碼',
sudoDesc: 'Hermes 需要您的 sudo 密碼來執行特權指令。它只會傳送給您的本機代理。',
sudoInstallDesc: 'Hermes 需要您的 sudo 密碼,以在閘道主機上安裝 Bot Screen 套件(TigerVNC + Xfce)。它只會傳送到該主機。',
sudoPlaceholder: 'sudo 密碼',
secretTitle: '需要密鑰',
secretDesc: 'Hermes 需要一個憑證才能繼續。',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4124,6 +4124,7 @@ export const zh = defineLocale({
secretSendFailed: '无法发送密钥',
sudoTitle: '管理员密码',
sudoDesc: 'Hermes 需要你的 sudo 密码来运行特权命令。它只会发送给你的本地 agent。',
sudoInstallDesc: 'Hermes 需要你的 sudo 密码,以在网关主机上安装 Bot Screen 软件包(TigerVNC + Xfce)。它只会发送到该主机。',
sudoPlaceholder: 'sudo 密码',
secretTitle: '需要密钥',
secretDesc: 'Hermes 需要一个凭据才能继续。',
Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/src/lib/sibling-ws-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { resolveSiblingWsUrl } from './sibling-ws-url'

// A sibling stream (voice PCM, Bot Screen RFB) must dial the SAME (connection,
// profile) backend chat uses. The bare v1 getConnection pair answers for the
// local primary — the wrong machine when a registry remote rides over a local
// install — so registry routes must go through the *For bridges.
describe('resolveSiblingWsUrl', () => {
const remoteWsUrl = 'wss://gateway.example/api/ws?ticket=fresh'
const localWsUrl = 'ws://127.0.0.1:5151/api/ws?token=local'

let getConnection: ReturnType<typeof vi.fn>
let getConnectionFor: ReturnType<typeof vi.fn>
let getGatewayWsUrl: ReturnType<typeof vi.fn>
let getGatewayWsUrlFor: ReturnType<typeof vi.fn>

beforeEach(() => {
getConnection = vi.fn(async () => ({ authMode: 'token', baseUrl: 'http://127.0.0.1:5151', wsUrl: localWsUrl }))
getConnectionFor = vi.fn(async () => ({ authMode: 'token', baseUrl: 'https://gateway.example', wsUrl: remoteWsUrl }))
getGatewayWsUrl = vi.fn(async () => ({ ok: true, wsUrl: localWsUrl }))
getGatewayWsUrlFor = vi.fn(async () => ({ ok: true, wsUrl: remoteWsUrl }))
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { getConnection, getConnectionFor, getGatewayWsUrl, getGatewayWsUrlFor }
})
})

afterEach(() => {
Reflect.deleteProperty(window, 'hermesDesktop')
})

it('routes a registry-scoped profile through the *For bridges and swaps only the path', async () => {
const url = await resolveSiblingWsUrl({ connectionId: 'gw-tailscale', profile: 'research' }, '/api/display/ws')

expect(url).toBe('wss://gateway.example/api/display/ws?ticket=fresh')
expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'gw-tailscale', profile: 'research' })
expect(getConnection).not.toHaveBeenCalled()
expect(getGatewayWsUrl).not.toHaveBeenCalled()
})

it('strips the spent gateway credential when the sibling route authenticates itself', async () => {
const url = new URL(await resolveSiblingWsUrl({ profile: null }, 'api/display/ws', { stripGatewayCredential: true }))

expect(url.origin + url.pathname).toBe('ws://127.0.0.1:5151/api/display/ws')
expect(url.searchParams.has('token')).toBe(false)
expect(url.searchParams.has('ticket')).toBe(false)
})
})
Loading