From 59010e412c5e3287bd8fdf0155fec2159467509d Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 20:10:03 -0700 Subject: [PATCH 01/15] fix(ssh): an opted-in desktop serves its browser to a managed SSH host's orca CLI again --- config/scripts/locale-ko-key-overrides.json | 2 +- .../desktop-browser-bridge-connection.ts | 126 ++++++++ .../desktop-browser-bridge-executor.test.ts | 267 ++++++++++++++++ .../desktop-browser-bridge-executor.ts | 157 ++++++++++ .../desktop-browser-bridge-manager.test.ts | 166 ++++++++++ .../desktop-browser-bridge-manager.ts | 178 +++++++++++ .../desktop-browser-bridge-renderer-scope.ts | 42 +++ .../ipc/desktop-browser-bridge-reconcile.ts | 14 + src/main/ipc/desktop-browser-bridge-wiring.ts | 153 +++++++++ src/main/ipc/ssh-renderer-broadcast.ts | 3 + src/main/ipc/ssh-target-crud-handlers.ts | 4 + src/main/ipc/ssh.ts | 14 + src/main/orcad/orcad-lifecycle.ts | 4 + .../desktop-browser-bridge-allowlist.test.ts | 49 +++ .../desktop-browser-bridge-dispatch.test.ts | 292 ++++++++++++++++++ .../desktop-browser-bridge-registry.ts | 194 ++++++++++++ .../desktop-browser-bridge-route.ts | 83 +++++ .../remote-cli-browser-scope.ts | 36 +++ src/main/runtime/orca-runtime-get-status.ts | 5 + src/main/runtime/rpc/dispatcher.ts | 8 + .../rpc/methods/desktop-browser-bridge.ts | 79 +++++ src/main/runtime/rpc/methods/index.ts | 2 + .../session-graph/session-graph-authority.ts | 30 ++ .../driver/terminal-driver-resources.ts | 7 +- src/main/startup/main-process-quit.ts | 2 + .../api/ui-bridge-state-and-menu-commands.ts | 13 + src/preload/api/ui-command-event-api.ts | 5 + .../settings/SshHostAdvancedFields.tsx | 2 +- ...vents-agent-status-window-test-fixtures.ts | 1 + .../ipc-events-close-routing-test-harness.ts | 1 + ...ts-terminal-create-window-test-fixtures.ts | 1 + .../browser-request-ipc-bridge.owner.test.ts | 64 +++- ...-request-ipc-bridge.profile-switch.test.ts | 1 + .../ipc-events/browser-request-ipc-bridge.ts | 17 + .../browser-request-workspace-owner.ts | 18 ++ .../useIpcEvents-browser-tab-create.test.ts | 1 + ...eIpcEvents-cli-worktree-activation.test.ts | 2 + ...Events-close-routing-browser-pages.test.ts | 3 + ...seIpcEvents-ssh-disconnect-cleanup.test.ts | 1 + .../hooks/useIpcEvents-updater-status.test.ts | 1 + src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- .../src/web/preload-api/web-ui-api.ts | 2 + src/shared/desktop-browser-bridge-protocol.ts | 125 ++++++++ .../rpc-params-catalog.generated.ts | 6 + .../ssh-orcad-desktop-browser-bridge.spec.ts | 200 ++++++++++++ 50 files changed, 2382 insertions(+), 11 deletions(-) create mode 100644 src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts create mode 100644 src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts create mode 100644 src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts create mode 100644 src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts create mode 100644 src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.ts create mode 100644 src/main/browser/desktop-browser-bridge/desktop-browser-bridge-renderer-scope.ts create mode 100644 src/main/ipc/desktop-browser-bridge-reconcile.ts create mode 100644 src/main/ipc/desktop-browser-bridge-wiring.ts create mode 100644 src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-allowlist.test.ts create mode 100644 src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-dispatch.test.ts create mode 100644 src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts create mode 100644 src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-route.ts create mode 100644 src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts create mode 100644 src/main/runtime/rpc/methods/desktop-browser-bridge.ts create mode 100644 src/shared/desktop-browser-bridge-protocol.ts create mode 100644 tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 6a7956efc646..569dc2024913 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -6264,7 +6264,7 @@ "ko": "이 호스트의 orca CLI가 Orca를 제어하도록 허용" }, "auto.components.settings.SshTargetForm.allowRemoteCliControlHelp": { - "ko": "꺼져 있으면 이 호스트에서 실행한 orca 명령은 이 호스트의 터미널에만 적용됩니다. 켜면 다른 호스트와 이 컴퓨터의 오케스트레이션, 파일 및 연동 기능에도 접근할 수 있습니다." + "ko": "켜면 이 호스트에서 실행한 orca 명령이 이 호스트의 작업 공간에서만, 연결되어 있는 동안에만 이 데스크톱의 Orca 브라우저 탭을 열고 조작할 수 있습니다. 파일, 클립보드, 쿠키는 읽을 수 없습니다. 끄면 이 호스트 자체의 Orca 서버에만 접근합니다." }, "auto.components.settings.AdvancedNetworkSettingsSection.3e431564b5": { "ko": "localhost, 127.0.0.1, *.internal" diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts new file mode 100644 index 000000000000..7c8072214e42 --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts @@ -0,0 +1,126 @@ +/** + * One desktop's attachment to one managed SSH host's orcad: a long-lived subscription over the + * paired, end-to-end encrypted runtime connection. orcad pushes forwarded commands down it, and + * each answer goes back over the same socket, the only one orcad accepts answers from. + */ +import { + DesktopBrowserBridgeEvent, + type DesktopBrowserBridgeRequest, + type DesktopBrowserBridgeResultParams +} from '../../../shared/desktop-browser-bridge-protocol' +import type { RemoteRuntimeSubscription } from '../../../shared/remote-runtime-client' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { DesktopBrowserBridgeOutcome } from './desktop-browser-bridge-executor' + +const RESULT_TIMEOUT_MS = 30_000 + +export type DesktopBrowserBridgeSubscribe = (callbacks: { + onResponse(response: RuntimeRpcResponse): void + onError(error: Error): void + onClose(): void +}) => Promise + +export class DesktopBrowserBridgeConnection { + private subscription: RemoteRuntimeSubscription | null = null + private attachmentId: string | null = null + private closed = false + + constructor( + private readonly options: { + subscribe: DesktopBrowserBridgeSubscribe + execute(request: DesktopBrowserBridgeRequest): Promise + onReady(): void + /** Called once, when the attachment ends for any reason other than close(). */ + onLost(error: Error): void + } + ) {} + + get ready(): boolean { + return this.attachmentId !== null && !this.closed + } + + async start(): Promise { + let subscription: RemoteRuntimeSubscription + try { + subscription = await this.options.subscribe({ + onResponse: (response) => this.handleResponse(response), + onError: (error) => this.lose(error), + onClose: () => this.lose(new Error('desktop_browser_bridge_transport_closed')) + }) + } catch (error) { + this.lose(error instanceof Error ? error : new Error(String(error))) + return + } + if (this.closed) { + subscription.close() + return + } + this.subscription = subscription + } + + close(): void { + if (this.closed) { + return + } + this.closed = true + this.attachmentId = null + const subscription = this.subscription + this.subscription = null + subscription?.close() + } + + private handleResponse(response: RuntimeRpcResponse): void { + if (this.closed) { + return + } + if (!response.ok) { + this.lose(new Error(`${response.error.code}: ${response.error.message}`)) + return + } + const parsed = DesktopBrowserBridgeEvent.safeParse(response.result) + if (!parsed.success) { + // Why lose rather than skip: a malformed frame means the peer is not speaking this protocol. + this.lose(new Error('desktop_browser_bridge_invalid_event')) + return + } + if (parsed.data.type === 'ready') { + if (this.attachmentId === null) { + this.attachmentId = parsed.data.attachmentId + this.options.onReady() + } + return + } + if (this.attachmentId === null) { + this.lose(new Error('desktop_browser_bridge_request_before_ready')) + return + } + void this.answer(this.attachmentId, parsed.data) + } + + private async answer(attachmentId: string, request: DesktopBrowserBridgeRequest): Promise { + const outcome = await this.options.execute(request) + const sendRequest = this.subscription?.sendRequest + if (this.closed || this.attachmentId !== attachmentId || !sendRequest) { + return + } + const params: DesktopBrowserBridgeResultParams = outcome.ok + ? { attachmentId, requestId: request.requestId, ok: true, result: outcome.result } + : { attachmentId, requestId: request.requestId, ok: false, error: outcome.error } + try { + await sendRequest('desktopBrowserBridge.result', params, RESULT_TIMEOUT_MS) + } catch (error) { + console.warn( + '[desktop-browser-bridge] Could not return a result to the host:', + error instanceof Error ? error.message : String(error) + ) + } + } + + private lose(error: Error): void { + if (this.closed) { + return + } + this.close() + this.options.onLost(error) + } +} diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts new file mode 100644 index 000000000000..031af27c0336 --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DESKTOP_BROWSER_BRIDGE_METHODS, + type DesktopBrowserBridgeRequest +} from '../../../shared/desktop-browser-bridge-protocol' +import { resolveRemoteCliBrowserScopeSelector } from '../../runtime/desktop-browser-bridge/remote-cli-browser-scope' +import { + executeDesktopBrowserBridgeRequest, + type DesktopBrowserBridgeAuditEntry, + type DesktopBrowserBridgeExecutorDeps +} from './desktop-browser-bridge-executor' + +const HOST_WORKSPACE = 'repo-host::/srv/app' +const LOCAL_WORKSPACE = 'repo-local::/Users/me/app' +const PAGE_OWNERS: Record = { + 'host-page': HOST_WORKSPACE, + 'local-page': LOCAL_WORKSPACE +} + +function request(overrides: Partial): DesktopBrowserBridgeRequest { + return { + type: 'request', + requestId: 'r1', + method: 'browser.snapshot', + params: {}, + ...overrides + } +} + +function deps(overrides: Partial = {}) { + const audit: DesktopBrowserBridgeAuditEntry[] = [] + const dispatched: { method: string; params: Record; scopeProbe: unknown }[] = [] + const value: DesktopBrowserBridgeExecutorDeps = { + environmentId: 'env-host', + hostLabel: 'sandbox', + authorizeWorkspace: async (workspaceId) => workspaceId === HOST_WORKSPACE, + worktreeIdForPage: (page) => PAGE_OWNERS[page] ?? null, + dispatch: async (method, params) => { + // Inside the dispatch, a selector for any other workspace must be refused. + const scopeProbe = await resolveRemoteCliBrowserScopeSelector(`id:${LOCAL_WORKSPACE}`)?.catch( + (error: Error) => error.message + ) + dispatched.push({ method, params, scopeProbe }) + return { id: 'x', ok: true, result: { done: true }, _meta: { runtimeId: 'desktop' } } + }, + audit: (entry) => audit.push(entry), + ...overrides + } + return { value, audit, dispatched } +} + +describe('executeDesktopBrowserBridgeRequest', () => { + it('runs an allowlisted command pinned to the host workspace and audits it', async () => { + const { value, audit, dispatched } = deps() + + const outcome = await executeDesktopBrowserBridgeRequest( + request({ + method: 'browser.goto', + workspaceId: HOST_WORKSPACE, + params: { url: 'https://example.com/path?token=secret', worktree: `id:${HOST_WORKSPACE}` } + }), + value + ) + + expect(outcome).toEqual({ ok: true, result: { done: true } }) + expect(dispatched).toEqual([ + { + method: 'browser.goto', + params: { url: 'https://example.com/path?token=secret', worktree: `id:${HOST_WORKSPACE}` }, + scopeProbe: 'An SSH host can only reach browser tabs in its own workspaces.' + } + ]) + // The audit names the origin only: query strings can carry credentials. + expect(audit).toEqual([ + expect.objectContaining({ + method: 'browser.goto', + workspaceId: HOST_WORKSPACE, + urlOrigin: 'https://example.com', + outcome: 'ok' + }) + ]) + expect(JSON.stringify(audit)).not.toContain('secret') + }) + + it('refuses a workspace this desktop does not show as the host’s own', async () => { + const { value, audit, dispatched } = deps() + + const outcome = await executeDesktopBrowserBridgeRequest( + request({ workspaceId: LOCAL_WORKSPACE, params: { worktree: `id:${LOCAL_WORKSPACE}` } }), + value + ) + + expect(outcome).toMatchObject({ + ok: false, + error: { code: 'desktop_browser_bridge_out_of_scope' } + }) + expect(dispatched).toEqual([]) + expect(audit[0]).toMatchObject({ outcome: 'refused' }) + }) + + it('refuses a page in another workspace as if it did not exist', async () => { + const { value, dispatched } = deps() + + const pageOnly = await executeDesktopBrowserBridgeRequest( + request({ params: { page: 'local-page' } }), + value + ) + const crossWorkspace = await executeDesktopBrowserBridgeRequest( + request({ + workspaceId: HOST_WORKSPACE, + params: { page: 'local-page', worktree: `id:${HOST_WORKSPACE}` } + }), + value + ) + + expect(pageOnly).toMatchObject({ ok: false, error: { code: 'browser_tab_not_found' } }) + expect(crossWorkspace).toMatchObject({ ok: false, error: { code: 'browser_tab_not_found' } }) + expect(dispatched).toEqual([]) + }) + + it('scopes a page-only command to the page’s workspace once that is authorized', async () => { + const { value, dispatched } = deps() + + await executeDesktopBrowserBridgeRequest( + request({ method: 'browser.click', params: { page: 'host-page', element: 'e1' } }), + value + ) + + expect(dispatched[0]?.params).toEqual({ + page: 'host-page', + element: 'e1', + worktree: `id:${HOST_WORKSPACE}` + }) + }) + + it('refuses a command that names no workspace rather than widening to every tab', async () => { + const { value, dispatched } = deps() + + const outcome = await executeDesktopBrowserBridgeRequest(request({ params: {} }), value) + + expect(outcome).toMatchObject({ + ok: false, + error: { code: 'desktop_browser_bridge_out_of_scope' } + }) + expect(dispatched).toEqual([]) + }) + + it('refuses a worktree param that disagrees with the resolved workspace', async () => { + const { value, dispatched } = deps() + + const outcome = await executeDesktopBrowserBridgeRequest( + request({ workspaceId: HOST_WORKSPACE, params: { worktree: `id:${LOCAL_WORKSPACE}` } }), + value + ) + + expect(outcome).toMatchObject({ + ok: false, + error: { code: 'desktop_browser_bridge_out_of_scope' } + }) + expect(dispatched).toEqual([]) + }) + + it.each([ + 'browser.cookie.get', + 'browser.cookie.set', + 'browser.upload', + 'browser.download', + 'browser.pdf', + 'browser.exec', + 'browser.clipboardRead', + 'browser.clipboardWrite', + 'browser.profileImportFromBrowser', + 'browser.tabSetProfile', + 'browser.certificate.proceed', + 'browser.storage.local.get', + 'browser.setCredentials', + 'browser.clientHost.attach', + 'terminal.send', + 'files.read' + ])('refuses %s even when a host sends it', async (method) => { + expect(DESKTOP_BROWSER_BRIDGE_METHODS.has(method)).toBe(false) + const { value, dispatched } = deps() + + const outcome = await executeDesktopBrowserBridgeRequest( + request({ + method, + workspaceId: HOST_WORKSPACE, + params: { worktree: `id:${HOST_WORKSPACE}` } + }), + value + ) + + expect(outcome).toMatchObject({ + ok: false, + error: { code: 'desktop_browser_bridge_not_forwarded' } + }) + expect(dispatched).toEqual([]) + }) + + it('creates a tab only with the host’s url and workspace, never its profile, group, page id or focus', async () => { + const { value, dispatched } = deps() + + await executeDesktopBrowserBridgeRequest( + request({ + method: 'browser.tabCreate', + workspaceId: HOST_WORKSPACE, + params: { + url: 'https://example.com', + worktree: `id:${HOST_WORKSPACE}`, + page: 'local-page', + profileId: 'work-profile', + targetGroupId: 'local-group', + activate: true, + navigation: 'caller', + placement: { kind: 'client', browserHostClientId: 'x' } + } + }), + value + ) + + expect(dispatched[0]?.params).toEqual({ + url: 'https://example.com', + worktree: `id:${HOST_WORKSPACE}` + }) + }) + + it('returns the desktop’s own error unchanged and audits the failure', async () => { + const { value, audit } = deps({ + dispatch: async () => ({ + id: 'x', + ok: false, + error: { code: 'browser_no_tab', message: 'No browser tab open in this worktree' }, + _meta: { runtimeId: 'desktop' } + }) + }) + + const outcome = await executeDesktopBrowserBridgeRequest( + request({ workspaceId: HOST_WORKSPACE, params: { worktree: `id:${HOST_WORKSPACE}` } }), + value + ) + + expect(outcome).toEqual({ + ok: false, + error: { code: 'browser_no_tab', message: 'No browser tab open in this worktree' } + }) + expect(audit[0]).toMatchObject({ outcome: 'failed', code: 'browser_no_tab' }) + }) + + it('treats a renderer that cannot answer as a refusal', async () => { + const authorizeWorkspace = vi.fn(async () => false) + const { value, dispatched } = deps({ authorizeWorkspace }) + + await executeDesktopBrowserBridgeRequest( + request({ workspaceId: HOST_WORKSPACE, params: { worktree: `id:${HOST_WORKSPACE}` } }), + value + ) + + expect(authorizeWorkspace).toHaveBeenCalledWith(HOST_WORKSPACE) + expect(dispatched).toEqual([]) + }) +}) + +describe('resolveRemoteCliBrowserScopeSelector', () => { + it('does nothing outside a bridged command', () => { + expect(resolveRemoteCliBrowserScopeSelector(`id:${LOCAL_WORKSPACE}`)).toBeNull() + }) +}) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts new file mode 100644 index 000000000000..5319e3741bc5 --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts @@ -0,0 +1,157 @@ +/** + * Runs one browser command a managed SSH host forwarded, on this desktop, inside that host's scope: + * an allowlisted method, against a workspace this desktop shows as the host's own, with every + * workspace selector pinned to it. Every outcome is audit-logged; params are not, since they can + * carry typed secrets. + */ +import { + DESKTOP_BROWSER_BRIDGE_NOT_FORWARDED_CODE, + DESKTOP_BROWSER_BRIDGE_SCOPE_CODE, + isDesktopBrowserBridgeMethod, + type DesktopBrowserBridgeRequest +} from '../../../shared/desktop-browser-bridge-protocol' +import type { RpcResponse } from '../../runtime/rpc/core' +import { runInRemoteCliBrowserScope } from '../../runtime/desktop-browser-bridge/remote-cli-browser-scope' + +export type DesktopBrowserBridgeOutcome = + | { ok: true; result: unknown } + | { ok: false; error: { code: string; message: string } } + +export type DesktopBrowserBridgeAuditEntry = { + environmentId: string + hostLabel: string + method: string + workspaceId: string | null + page: string | null + urlOrigin: string | null + outcome: 'ok' | 'refused' | 'failed' + code?: string +} + +export type DesktopBrowserBridgeExecutorDeps = { + environmentId: string + hostLabel: string + /** True only when this desktop shows `workspaceId` as owned by the environment's own machine. */ + authorizeWorkspace(workspaceId: string): Promise + worktreeIdForPage(browserPageId: string): string | null + dispatch(method: string, params: Record): Promise + audit(entry: DesktopBrowserBridgeAuditEntry): void +} + +// Why: a host may not pick the desktop page id, tab group, focus or browser profile of a new tab. +const TAB_CREATE_STRIPPED_PARAMS = [ + 'page', + 'profileId', + 'placement', + 'targetGroupId', + 'activate', + 'navigation' +] as const + +class BridgeRefusal extends Error { + constructor( + readonly code: string, + message: string + ) { + super(message) + } +} + +export async function executeDesktopBrowserBridgeRequest( + request: DesktopBrowserBridgeRequest, + deps: DesktopBrowserBridgeExecutorDeps +): Promise { + const audit = { + environmentId: deps.environmentId, + hostLabel: deps.hostLabel, + method: request.method, + workspaceId: null as string | null, + page: null as string | null, + urlOrigin: urlOrigin(request.params.url) + } + try { + const { params, workspaceId, page } = await scopeRequest(request, deps) + audit.workspaceId = workspaceId + audit.page = page + const response = await runInRemoteCliBrowserScope(workspaceId, () => + deps.dispatch(request.method, params) + ) + if (!response.ok) { + deps.audit({ ...audit, outcome: 'failed', code: response.error.code }) + return { ok: false, error: { code: response.error.code, message: response.error.message } } + } + deps.audit({ ...audit, outcome: 'ok' }) + return { ok: true, result: response.result } + } catch (error) { + if (error instanceof BridgeRefusal) { + deps.audit({ ...audit, outcome: 'refused', code: error.code }) + return { ok: false, error: { code: error.code, message: error.message } } + } + const message = error instanceof Error ? error.message : String(error) + deps.audit({ ...audit, outcome: 'failed', code: 'runtime_error' }) + return { ok: false, error: { code: 'runtime_error', message } } + } +} + +async function scopeRequest( + request: DesktopBrowserBridgeRequest, + deps: DesktopBrowserBridgeExecutorDeps +): Promise<{ params: Record; workspaceId: string; page: string | null }> { + // Why again here: the desktop is the trust boundary, never what the host claims it checked. + if (!isDesktopBrowserBridgeMethod(request.method)) { + throw new BridgeRefusal( + DESKTOP_BROWSER_BRIDGE_NOT_FORWARDED_CODE, + `${request.method} cannot run on this desktop from an SSH host.` + ) + } + const params: Record = { ...request.params } + if (request.method === 'browser.tabCreate') { + for (const key of TAB_CREATE_STRIPPED_PARAMS) { + delete params[key] + } + } + let workspaceId = request.workspaceId ?? null + if (params.worktree !== undefined && (!workspaceId || params.worktree !== `id:${workspaceId}`)) { + throw outOfScope() + } + const page = typeof params.page === 'string' && params.page.length > 0 ? params.page : null + if (page) { + const pageWorkspaceId = deps.worktreeIdForPage(page) + if (!pageWorkspaceId || (workspaceId && pageWorkspaceId !== workspaceId)) { + throw new BridgeRefusal('browser_tab_not_found', `Browser page ${page} was not found`) + } + workspaceId = pageWorkspaceId + } + if (!workspaceId) { + throw new BridgeRefusal( + DESKTOP_BROWSER_BRIDGE_SCOPE_CODE, + 'Run this command inside a workspace on this host, or pass --worktree, so Orca knows which workspace’s browser tabs it may use.' + ) + } + if (!(await deps.authorizeWorkspace(workspaceId))) { + // Why the same refusal for a foreign page: an unknown and a forbidden page must look alike. + throw page + ? new BridgeRefusal('browser_tab_not_found', `Browser page ${page} was not found`) + : outOfScope() + } + params.worktree = `id:${workspaceId}` + return { params, workspaceId, page } +} + +function outOfScope(): BridgeRefusal { + return new BridgeRefusal( + DESKTOP_BROWSER_BRIDGE_SCOPE_CODE, + 'An SSH host can only reach browser tabs in its own workspaces.' + ) +} + +function urlOrigin(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + try { + return new URL(value).origin + } catch { + return null + } +} diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts new file mode 100644 index 000000000000..10617527527f --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RemoteRuntimeSubscription } from '../../../shared/remote-runtime-client' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { + DesktopBrowserBridgeManager, + type DesktopBrowserBridgeCandidate, + type DesktopBrowserBridgeManagerDeps +} from './desktop-browser-bridge-manager' + +type Callbacks = Parameters>[0] + +const HOST: DesktopBrowserBridgeCandidate = { + environmentId: 'env-a', + targetId: 'ssh-a', + hostLabel: 'sandbox' +} + +function event(result: unknown): RuntimeRpcResponse { + return { id: 'attach', ok: true, result, _meta: { runtimeId: 'orcad' } } +} + +function harness(options: { supports?: boolean } = {}) { + let candidates: DesktopBrowserBridgeCandidate[] = [HOST] + const subscriptions: { + callbacks: Callbacks + close: ReturnType + sendRequest: ReturnType + }[] = [] + const timers: { run: () => void; delayMs: number }[] = [] + const execute = vi.fn(async () => ({ ok: true as const, result: { url: 'https://a.test/' } })) + const subscribe = vi.fn( + () => + async (callbacks: Callbacks): Promise => { + const entry = { + callbacks, + close: vi.fn(), + sendRequest: vi.fn(async () => event({ accepted: true })) + } + subscriptions.push(entry) + return { + requestId: 'attach', + close: entry.close, + sendBinary: () => false, + sendRequest: entry.sendRequest + } + } + ) + const supportsBridge = vi.fn(async () => options.supports !== false) + const manager = new DesktopBrowserBridgeManager({ + listCandidates: () => candidates, + supportsBridge, + subscribe, + execute, + log: () => {}, + setTimer: (run, delayMs) => { + timers.push({ run, delayMs }) + return setTimeout(() => {}, 0) + }, + clearTimer: () => {} + }) + return { + manager, + subscriptions, + timers, + execute, + subscribe, + supportsBridge, + setCandidates: (next: DesktopBrowserBridgeCandidate[]) => { + candidates = next + } + } +} + +describe('DesktopBrowserBridgeManager', () => { + it('attaches an allowed, connected host and answers its requests over the same socket', async () => { + const h = harness() + h.manager.reconcile() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + const socket = h.subscriptions[0]! + + socket.callbacks.onResponse(event({ type: 'ready', attachmentId: 'att-1' })) + expect(h.manager.isAttached('env-a')).toBe(true) + socket.callbacks.onResponse( + event({ + type: 'request', + requestId: 'req-1', + method: 'browser.goto', + workspaceId: 'repo::/srv', + params: { url: 'https://a.test', worktree: 'id:repo::/srv' } + }) + ) + + await vi.waitFor(() => expect(socket.sendRequest).toHaveBeenCalled()) + expect(h.execute).toHaveBeenCalledWith(HOST, expect.objectContaining({ requestId: 'req-1' })) + expect(socket.sendRequest).toHaveBeenCalledWith( + 'desktopBrowserBridge.result', + { attachmentId: 'att-1', requestId: 'req-1', ok: true, result: { url: 'https://a.test/' } }, + expect.any(Number) + ) + }) + + it('never attaches to a server that does not advertise the bridge (an older orcad)', async () => { + const h = harness({ supports: false }) + h.manager.reconcile() + await vi.waitFor(() => expect(h.supportsBridge).toHaveBeenCalled()) + + expect(h.subscribe).not.toHaveBeenCalled() + expect(h.timers).toEqual([expect.objectContaining({ delayMs: 5 * 60_000 })]) + }) + + it('detaches at once when the setting turns off or the host disconnects', async () => { + const h = harness() + h.manager.reconcile() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + h.subscriptions[0]!.callbacks.onResponse(event({ type: 'ready', attachmentId: 'att-1' })) + + h.setCandidates([]) + h.manager.reconcile() + + expect(h.subscriptions[0]!.close).toHaveBeenCalled() + expect(h.manager.isAttached('env-a')).toBe(false) + }) + + it('drops a request that arrives before readiness and reattaches with backoff', async () => { + const h = harness() + h.manager.reconcile() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + + h.subscriptions[0]!.callbacks.onResponse( + event({ type: 'request', requestId: 'r', method: 'browser.goto', params: {} }) + ) + + expect(h.execute).not.toHaveBeenCalled() + expect(h.subscriptions[0]!.close).toHaveBeenCalled() + expect(h.timers).toEqual([expect.objectContaining({ delayMs: 2_000 })]) + h.timers[0]!.run() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(2)) + }) + + it('refuses a forwarded method outside the allowlist at the protocol layer', async () => { + const h = harness() + h.manager.reconcile() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + h.subscriptions[0]!.callbacks.onResponse(event({ type: 'ready', attachmentId: 'att-1' })) + + h.subscriptions[0]!.callbacks.onResponse( + event({ type: 'request', requestId: 'r', method: 'browser.cookie.get', params: {} }) + ) + + expect(h.execute).not.toHaveBeenCalled() + expect(h.subscriptions[0]!.close).toHaveBeenCalled() + }) + + it('does not retry a host that was removed while waiting', async () => { + const h = harness() + h.manager.reconcile() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + h.subscriptions[0]!.callbacks.onClose() + h.setCandidates([]) + + h.timers[0]!.run() + + await Promise.resolve() + expect(h.subscriptions).toHaveLength(1) + }) +}) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.ts new file mode 100644 index 000000000000..65842b5f2adf --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.ts @@ -0,0 +1,178 @@ +/** + * Keeps this desktop attached to exactly the managed SSH hosts that should drive its browser: those + * with "Allow this host's orca CLI to control Orca" on, connected now, and whose server advertises + * the bridge. Anything else is detached at once, so turning the setting off or losing the host + * revokes the host's reach immediately. + */ +import type { DesktopBrowserBridgeRequest } from '../../../shared/desktop-browser-bridge-protocol' +import { + DesktopBrowserBridgeConnection, + type DesktopBrowserBridgeSubscribe +} from './desktop-browser-bridge-connection' +import type { DesktopBrowserBridgeOutcome } from './desktop-browser-bridge-executor' + +export type DesktopBrowserBridgeCandidate = { + environmentId: string + targetId: string + hostLabel: string +} + +export type DesktopBrowserBridgeManagerDeps = { + /** The hosts that may attach now: setting on, SSH connected, managed environment linked. */ + listCandidates(): DesktopBrowserBridgeCandidate[] + supportsBridge(environmentId: string): Promise + subscribe(environmentId: string): DesktopBrowserBridgeSubscribe + execute( + candidate: DesktopBrowserBridgeCandidate, + request: DesktopBrowserBridgeRequest + ): Promise + log(message: string): void + setTimer?(run: () => void, delayMs: number): ReturnType + clearTimer?(timer: ReturnType): void +} + +const MIN_RETRY_MS = 2_000 +const MAX_RETRY_MS = 60_000 +const UNSUPPORTED_RETRY_MS = 5 * 60_000 + +type Entry = { + candidate: DesktopBrowserBridgeCandidate + connection: DesktopBrowserBridgeConnection | null + retryTimer: ReturnType | null + failures: number + starting: boolean +} + +export class DesktopBrowserBridgeManager { + private readonly entries = new Map() + private disposed = false + + constructor(private readonly deps: DesktopBrowserBridgeManagerDeps) {} + + reconcile(): void { + if (this.disposed) { + return + } + const wanted = new Map() + for (const candidate of this.deps.listCandidates()) { + wanted.set(candidate.environmentId, candidate) + } + for (const [environmentId, entry] of this.entries) { + const next = wanted.get(environmentId) + if (!next || next.targetId !== entry.candidate.targetId) { + this.detach(environmentId, 'host is no longer allowed or connected') + } else { + entry.candidate = next + } + } + for (const [environmentId, candidate] of wanted) { + if (!this.entries.has(environmentId)) { + const entry: Entry = { + candidate, + connection: null, + retryTimer: null, + failures: 0, + starting: false + } + this.entries.set(environmentId, entry) + void this.start(environmentId, entry) + } + } + } + + isAttached(environmentId: string): boolean { + return this.entries.get(environmentId)?.connection?.ready === true + } + + dispose(): void { + this.disposed = true + for (const environmentId of this.entries.keys()) { + this.detach(environmentId, 'desktop is shutting down') + } + } + + private async start(environmentId: string, entry: Entry): Promise { + if (entry.starting || this.entries.get(environmentId) !== entry) { + return + } + entry.starting = true + let supported = false + try { + supported = await this.deps.supportsBridge(environmentId) + } catch { + supported = false + } finally { + entry.starting = false + } + if (this.entries.get(environmentId) !== entry) { + return + } + if (!supported) { + this.scheduleRetry(environmentId, entry, UNSUPPORTED_RETRY_MS) + return + } + const connection = new DesktopBrowserBridgeConnection({ + subscribe: this.deps.subscribe(environmentId), + execute: (request) => this.deps.execute(entry.candidate, request), + onReady: () => { + entry.failures = 0 + this.deps.log(`attached: host=${entry.candidate.hostLabel} env=${environmentId}`) + }, + onLost: (error) => { + if (entry.connection !== connection) { + return + } + entry.connection = null + entry.failures += 1 + this.deps.log( + `lost: host=${entry.candidate.hostLabel} env=${environmentId} (${error.message})` + ) + this.scheduleRetry( + environmentId, + entry, + Math.min(MAX_RETRY_MS, MIN_RETRY_MS * 2 ** Math.min(entry.failures - 1, 5)) + ) + } + }) + entry.connection = connection + await connection.start() + } + + private scheduleRetry(environmentId: string, entry: Entry, delayMs: number): void { + if (this.disposed || this.entries.get(environmentId) !== entry || entry.retryTimer) { + return + } + const setTimer = this.deps.setTimer ?? ((run, ms) => setTimeout(run, ms)) + entry.retryTimer = setTimer(() => { + entry.retryTimer = null + // Why re-read: the setting or connection may have changed while waiting. + const stillWanted = this.deps + .listCandidates() + .some((candidate) => candidate.environmentId === environmentId) + if (!stillWanted) { + this.detach(environmentId, 'host is no longer allowed or connected') + return + } + void this.start(environmentId, entry) + }, delayMs) + entry.retryTimer.unref?.() + } + + private detach(environmentId: string, reason: string): void { + const entry = this.entries.get(environmentId) + if (!entry) { + return + } + this.entries.delete(environmentId) + if (entry.retryTimer) { + ;(this.deps.clearTimer ?? clearTimeout)(entry.retryTimer) + entry.retryTimer = null + } + const connection = entry.connection + entry.connection = null + if (connection) { + connection.close() + this.deps.log(`detached: host=${entry.candidate.hostLabel} env=${environmentId} (${reason})`) + } + } +} diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-renderer-scope.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-renderer-scope.ts new file mode 100644 index 000000000000..b612f88671e8 --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-renderer-scope.ts @@ -0,0 +1,42 @@ +import { randomUUID } from 'node:crypto' +import { ipcMain, type BrowserWindow } from 'electron' + +const SCOPE_REPLY_TIMEOUT_MS = 5_000 + +/** + * Asks the renderer, which owns the workspace-to-host map, whether `environmentId`'s own machine + * owns `workspaceId`. No window, a timeout or a malformed reply all answer no. + */ +export function rendererAuthorizesRemoteCliWorkspace( + getWindow: () => BrowserWindow | null, + environmentId: string, + workspaceId: string +): Promise { + const win = getWindow() + if (!win || win.isDestroyed()) { + return Promise.resolve(false) + } + const requestId = randomUUID() + return new Promise((resolve) => { + const finish = (allowed: boolean): void => { + clearTimeout(timer) + ipcMain.removeListener('browser:remoteCliScopeReply', handler) + resolve(allowed) + } + const handler = (event: Electron.IpcMainEvent, reply: unknown): void => { + if ( + event.sender !== win.webContents || + !reply || + typeof reply !== 'object' || + !('requestId' in reply) || + reply.requestId !== requestId + ) { + return + } + finish('allowed' in reply && reply.allowed === true) + } + const timer = setTimeout(() => finish(false), SCOPE_REPLY_TIMEOUT_MS) + ipcMain.on('browser:remoteCliScopeReply', handler) + win.webContents.send('browser:requestRemoteCliScope', { requestId, environmentId, workspaceId }) + }) +} diff --git a/src/main/ipc/desktop-browser-bridge-reconcile.ts b/src/main/ipc/desktop-browser-bridge-reconcile.ts new file mode 100644 index 000000000000..b9eab4456e16 --- /dev/null +++ b/src/main/ipc/desktop-browser-bridge-reconcile.ts @@ -0,0 +1,14 @@ +/** A dependency-free hook so SSH state and settings paths can ask the bridge to re-check its hosts. */ +let reconcile: (() => void) | null = null + +export function setDesktopBrowserBridgeReconciler(next: (() => void) | null): void { + reconcile = next +} + +export function requestDesktopBrowserBridgeReconcile(): void { + try { + reconcile?.() + } catch (error) { + console.warn('[desktop-browser-bridge] reconcile failed:', error) + } +} diff --git a/src/main/ipc/desktop-browser-bridge-wiring.ts b/src/main/ipc/desktop-browser-bridge-wiring.ts new file mode 100644 index 000000000000..5639e7aa3a20 --- /dev/null +++ b/src/main/ipc/desktop-browser-bridge-wiring.ts @@ -0,0 +1,153 @@ +/** + * Production wiring for the desktop browser bridge: which managed SSH hosts may attach, how to + * reach their orcad over the paired encrypted connection, and how a forwarded command runs here. + */ +import { randomUUID } from 'node:crypto' +import type { BrowserWindow } from 'electron' +import { + DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY, + isDesktopBrowserBridgeMethod +} from '../../shared/desktop-browser-bridge-protocol' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' +import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { + getOrcadSshDeployment, + getPreferredPairedRuntimeConnection +} from '../../shared/runtime-environments' +import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import { browserManager } from '../browser/browser-manager' +import { + DesktopBrowserBridgeManager, + type DesktopBrowserBridgeCandidate +} from '../browser/desktop-browser-bridge/desktop-browser-bridge-manager' +import { executeDesktopBrowserBridgeRequest } from '../browser/desktop-browser-bridge/desktop-browser-bridge-executor' +import { rendererAuthorizesRemoteCliWorkspace } from '../browser/desktop-browser-bridge/desktop-browser-bridge-renderer-scope' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { ALL_RPC_METHODS } from '../runtime/rpc/methods' +import { ensureOrcadManagedTunnel } from '../ssh/orcad-managed-tunnel' +import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' +import type { SshConnectionManager } from '../ssh/ssh-connection-manager' +import { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe' +import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-connectivity-handlers' +import { setDesktopBrowserBridgeReconciler } from './desktop-browser-bridge-reconcile' + +const ATTACH_TIMEOUT_MS = 15_000 +const BRIDGE_CLIENT_ID = randomUUID() + +let manager: DesktopBrowserBridgeManager | null = null + +function log(message: string): void { + console.info(`[desktop-browser-bridge] ${message}`) +} + +export function listDesktopBrowserBridgeCandidates( + userDataPath: string, + connections: Pick | null +): DesktopBrowserBridgeCandidate[] { + const candidates: DesktopBrowserBridgeCandidate[] = [] + for (const target of getSshTargetRegistryStore()?.listTargets() ?? []) { + // Why strict true: the setting is opt-in, and an absent value is off. + if (target.allowRemoteCliControl !== true) { + continue + } + if (connections?.getState(target.id)?.status !== 'connected') { + continue + } + const environmentId = getManagedOrcadFenceEnvironmentId(target) + if (!environmentId || isRuntimeEnvironmentManuallyDisconnected(environmentId)) { + continue + } + try { + const environment = resolveEnvironment(userDataPath, environmentId) + if (getOrcadSshDeployment(environment)?.sshTargetId !== target.id) { + continue + } + } catch { + continue + } + candidates.push({ environmentId, targetId: target.id, hostLabel: target.label }) + } + return candidates +} + +export function installDesktopBrowserBridge(options: { + userDataPath: string + runtime: OrcaRuntimeService + getMainWindow: () => BrowserWindow | null + getConnectionManager: () => Pick | null +}): void { + if (manager) { + manager.reconcile() + return + } + const { userDataPath, runtime, getMainWindow } = options + let dispatcher: RpcDispatcher | null = null + const getDispatcher = (): RpcDispatcher => { + dispatcher ??= new RpcDispatcher({ + runtime, + // Why a registry of only these methods: even a bug upstream cannot reach anything else. + methods: ALL_RPC_METHODS.filter((method) => isDesktopBrowserBridgeMethod(method.name)), + callerScope: { kind: 'runtime-paired', grants: [] } + }) + return dispatcher + } + manager = new DesktopBrowserBridgeManager({ + listCandidates: () => + listDesktopBrowserBridgeCandidates(userDataPath, options.getConnectionManager()), + supportsBridge: async (environmentId) => { + const status = await getRuntimeEnvironmentStatus(userDataPath, environmentId, undefined, { + observeOnly: true + }) + return ( + status.ok === true && + (status.result.capabilities ?? []).includes(DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY) + ) + }, + subscribe: (environmentId) => async (callbacks) => { + await ensureOrcadManagedTunnel(userDataPath, environmentId) + const connection = getPreferredPairedRuntimeConnection( + resolveEnvironment(userDataPath, environmentId) + ) + return subscribeRemoteRuntimeRequest( + connection, + 'desktopBrowserBridge.attach', + { bridgeClientId: BRIDGE_CLIENT_ID }, + ATTACH_TIMEOUT_MS, + { + onResponse: callbacks.onResponse, + onError: (error) => callbacks.onError(error), + onClose: callbacks.onClose + }, + { + clientCapabilities: [ + ...ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY + ] + } + ) + }, + execute: (candidate, request) => + executeDesktopBrowserBridgeRequest(request, { + environmentId: candidate.environmentId, + hostLabel: candidate.hostLabel, + authorizeWorkspace: (workspaceId) => + rendererAuthorizesRemoteCliWorkspace(getMainWindow, candidate.environmentId, workspaceId), + worktreeIdForPage: (browserPageId) => + browserManager.getWorktreeIdForTab(browserPageId) ?? null, + dispatch: (method, params) => + getDispatcher().dispatch({ id: randomUUID(), authToken: '', method, params }), + audit: (entry) => log(`audit ${JSON.stringify(entry)}`) + }), + log + }) + setDesktopBrowserBridgeReconciler(() => manager?.reconcile()) + manager.reconcile() +} + +export function disposeDesktopBrowserBridge(): void { + setDesktopBrowserBridgeReconciler(null) + manager?.dispose() + manager = null +} diff --git a/src/main/ipc/ssh-renderer-broadcast.ts b/src/main/ipc/ssh-renderer-broadcast.ts index 4d2f9f613b25..63dcf7e5524c 100644 --- a/src/main/ipc/ssh-renderer-broadcast.ts +++ b/src/main/ipc/ssh-renderer-broadcast.ts @@ -18,6 +18,7 @@ import { persistedStore, portForwardManager } from './ssh-ipc-context' +import { requestDesktopBrowserBridgeReconcile } from './desktop-browser-bridge-reconcile' export function broadcastSshState( getMainWindow: () => BrowserWindow | null, @@ -37,6 +38,8 @@ export function broadcastSshState( } // Why: paired remote clients have no ssh:state-changed IPC; without this their terminals keep a stale reconnect overlay. currentRuntime?.notifySshStateChanged?.(targetId, enrichedState) + // Why: a host that connects or drops attaches or detaches its desktop browser bridge now. + requestDesktopBrowserBridgeReconcile() } function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState { diff --git a/src/main/ipc/ssh-target-crud-handlers.ts b/src/main/ipc/ssh-target-crud-handlers.ts index 935972ad7d8f..b22ddace50c4 100644 --- a/src/main/ipc/ssh-target-crud-handlers.ts +++ b/src/main/ipc/ssh-target-crud-handlers.ts @@ -25,6 +25,7 @@ import { runTargetLifecycle } from './ssh-target-lifecycle-queue' import { fingerprintRuntimeSshTarget } from '../ssh/runtime-ssh-access' import { removeRegisteredSshTarget } from './ssh-session-teardown' import { notifyReposChanged } from './repos/repos-changed-notification' +import { requestDesktopBrowserBridgeReconcile } from './desktop-browser-bridge-reconcile' // Why: add/import can re-adopt workspaces orphaned on a removed target id (see ssh-target-readoption); the renderer must refresh its repo list to surface them. function takeRepoReadoptions(): SshRepoReadoption[] { @@ -116,6 +117,8 @@ function updateSshTarget(id: string, updates: SshTargetUpdateInput): SshTarget | id, omitRendererSshTargetGeneration(updates) ) + // Why first: turning "Allow this host's orca CLI to control Orca" off must revoke the bridge at once. + requestDesktopBrowserBridgeReconcile() const environmentId = getManagedOrcadFenceEnvironmentId(updated ?? undefined) if (environmentId && updated) { // Why: the tunnel and the SSH transport under it were built from the old fields, so both go @@ -138,6 +141,7 @@ async function removeSshTarget(id: string): Promise { assertNotRuntimeOwned(id, 'removed') assertNotManagedServerHost(id) await removeRegisteredSshTarget(id) + requestDesktopBrowserBridgeReconcile() } export function registerSshTargetCrudHandlers(): void { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index cbe1aaf79d29..4feca314d6e5 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -55,6 +55,10 @@ import { reconcileManagedOrcadSshTargets } from '../ssh/orcad-retained-source' import { installOrcadMigrationScrollbackRetention } from '../ssh/orcad-migration-scrollback-retention-wiring' import { getAppEnvironment } from '../../shared/app-environment' import { registerLegacyRelayBridgeIpc } from '../ssh/legacy-relay-bridge/legacy-relay-bridge-ipc' +import { + disposeDesktopBrowserBridge, + installDesktopBrowserBridge +} from './desktop-browser-bridge-wiring' const SSH_IPC_CHANNELS = [ 'ssh:listTargets', @@ -132,6 +136,15 @@ export function registerSshHandlers( connection: (targetId) => connectionManager?.getConnection(targetId) ?? null }) + if (runtime) { + installDesktopBrowserBridge({ + userDataPath: getAppEnvironment().getPath('userData'), + runtime, + getMainWindow: getCurrentMainWindow, + getConnectionManager: () => connectionManager + }) + } + return { connectionManager: connectionManager!, sshStore: getSshTargetRegistryStore() as SshConnectionStore @@ -156,6 +169,7 @@ export async function resetSshHandlerStateForTests(): Promise { quitTeardownStartGate.resetForTests() resetSshShutdownDrain() + disposeDesktopBrowserBridge() await connectionManager?.disconnectAll() disposeOrcadManagedTunnels() portForwardManager?.dispose() diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index 6dff1279dec0..844e3101190f 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -1,4 +1,5 @@ import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' +import { setDesktopBrowserBridgeHostEnabled } from '../runtime/desktop-browser-bridge/desktop-browser-bridge-registry' import { startOrcadBrowserProvider } from './orcad-browser-startup' import { OrcadRuntimeLifetime, type OrcadRuntimeCleanup } from './orcad-runtime-lifetime' import type { OrcadManagedStopInstance } from '../../shared/orcad-stop-request' @@ -143,6 +144,8 @@ export async function startOrcadWithHost( admission = acquireProfileStateRuntimeAdmission(userDataPath) // Why not awaited: a desktop sidecar's authorization UI must not hold RPC readiness hostage. browserProvider = startOrcadBrowserProvider({ userDataPath }) + // Why: orcad has no desktop browser, so an opted-in desktop may serve this host's CLI. + setDesktopBrowserBridgeHostEnabled(true) return { ...(await start(registerCleanup)), instance } }, async (runtimeCleanupSucceeded) => { @@ -159,6 +162,7 @@ export async function startOrcadWithHost( }) host.add(() => runQuitHandlers()) host.add(() => setRuntimeBrowserCommandsFactory(null)) + host.add(() => setDesktopBrowserBridgeHostEnabled(false)) host.add(() => browserProvider?.stop()) await host.stop() } diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-allowlist.test.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-allowlist.test.ts new file mode 100644 index 000000000000..c7ee3a81c547 --- /dev/null +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-allowlist.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { DESKTOP_BROWSER_BRIDGE_METHODS } from '../../../shared/desktop-browser-bridge-protocol' +import { ALL_RPC_METHODS } from '../rpc/methods' + +describe('desktop browser bridge allowlist', () => { + const registered = new Map(ALL_RPC_METHODS.map((method) => [method.name, method])) + + it('names only real one-shot browser methods in the workspace tier', () => { + for (const name of DESKTOP_BROWSER_BRIDGE_METHODS) { + const method = registered.get(name) + expect(method, name).toBeDefined() + expect(name.startsWith('browser.'), name).toBe(true) + expect(method?.permission, name).toBe('workspace') + expect('stream' in (method ?? {}), name).toBe(false) + } + }) + + it('leaves out every browser method that touches desktop files, secrets or browser identity', () => { + const excluded = [...registered.keys()].filter( + (name) => name.startsWith('browser.') && !DESKTOP_BROWSER_BRIDGE_METHODS.has(name) + ) + expect(excluded).toEqual( + expect.arrayContaining([ + 'browser.upload', + 'browser.download', + 'browser.pdf', + 'browser.exec', + 'browser.cookie.get', + 'browser.cookie.set', + 'browser.cookie.delete', + 'browser.clipboardRead', + 'browser.clipboardWrite', + 'browser.storage.local.get', + 'browser.profileImportFromBrowser', + 'browser.profileCreate', + 'browser.profileDelete', + 'browser.tabSetProfile', + 'browser.identity.set', + 'browser.setCredentials', + 'browser.setHeaders', + 'browser.intercept.enable', + 'browser.certificate.proceed', + 'browser.screencast', + 'browser.openUrl', + 'browser.clientHost.attach' + ]) + ) + }) +}) diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-dispatch.test.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-dispatch.test.ts new file mode 100644 index 000000000000..4eb85aa53846 --- /dev/null +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-dispatch.test.ts @@ -0,0 +1,292 @@ +import '../rpc/unused-default-rpc-methods.test-fixture' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY, + DESKTOP_BROWSER_BRIDGE_SETTING_LABEL +} from '../../../shared/desktop-browser-bridge-protocol' +import type { OrcaRuntimeService } from '../orca-runtime' +import { defineMethod } from '../rpc/core' +import { RpcDispatcher } from '../rpc/dispatcher' +import { DESKTOP_BROWSER_BRIDGE_RPC_METHODS } from '../rpc/methods/desktop-browser-bridge' +import { BrowserTarget } from '../../../shared/rpc-contract/rpc-param-primitives' +import { Goto } from '../../../shared/rpc-contract/browser-params' +import { + getDesktopBrowserBridgeRegistry, + setDesktopBrowserBridgeHostEnabled +} from './desktop-browser-bridge-registry' +import { routeDesktopBrowserBridgeRpc } from './desktop-browser-bridge-route' + +const localGoto = vi.fn(async () => ({ url: 'local', title: '' })) +const localCookie = vi.fn(async () => ({ cookies: [] })) + +const METHODS = [ + ...DESKTOP_BROWSER_BRIDGE_RPC_METHODS, + defineMethod({ + name: 'browser.goto', + permission: 'workspace', + params: Goto, + handler: () => localGoto() + }), + defineMethod({ + name: 'browser.cookie.get', + permission: 'workspace', + params: BrowserTarget, + handler: () => localCookie() + }) +] + +function orcadRuntime(options: { localBrowser?: boolean } = {}) { + const cleanups = new Map void>() + const runtime = { + getRuntimeId: () => 'orcad-runtime', + getStartedAt: () => 1, + registerSubscriptionCleanup: (id: string, cleanup: () => void) => cleanups.set(id, cleanup), + routeDesktopBrowserBridgeRpc: ( + method: string, + params: unknown, + context: Parameters[2] + ) => + routeDesktopBrowserBridgeRpc({ + method, + params, + caller: context.caller, + hostEnabled: true, + registry: getDesktopBrowserBridgeRegistry(runtime), + localBrowserAvailable: options.localBrowser === true, + resolveWorkspace: async (selector) => ({ + id: selector === 'path:/srv/app' ? 'repo-1::/srv/app' : selector.replace(/^id:/, '') + }) + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only the members stubbed above. + return { runtime: runtime as unknown as OrcaRuntimeService, cleanups } +} + +const DESKTOP = { + connectionId: 'desktop-socket', + clientKind: 'runtime' as const, + pairedDeviceId: 'desktop-device', + clientCapabilities: [DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY] +} + +async function attachDesktop(dispatcher: RpcDispatcher, options = DESKTOP) { + const events: Record[] = [] + const done = dispatcher.dispatchStreaming( + { + id: 'attach', + authToken: 'bound-by-websocket', + method: 'desktopBrowserBridge.attach', + params: { bridgeClientId: 'desktop-a' } + }, + (reply) => { + const parsed = JSON.parse(reply) + events.push(parsed.ok ? parsed.result : { error: parsed.error }) + }, + options + ) + await vi.waitFor(() => expect(events.length).toBeGreaterThan(0)) + return { events, done } +} + +function cliCall(dispatcher: RpcDispatcher, method: string, params: Record) { + // No transport options: the host's own `orca` CLI over its runtime socket. + return dispatcher.dispatch({ id: `cli-${method}`, authToken: 'owner', method, params }) +} + +afterEach(() => { + setDesktopBrowserBridgeHostEnabled(false) + localGoto.mockClear() + localCookie.mockClear() +}) + +describe('desktop browser bridge on orcad', () => { + it('forwards an allowlisted CLI command to the attached desktop and returns its answer', async () => { + setDesktopBrowserBridgeHostEnabled(true) + const { runtime } = orcadRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + const { events } = await attachDesktop(dispatcher) + const ready = events[0] as { type: string; attachmentId: string } + expect(ready.type).toBe('ready') + + const call = cliCall(dispatcher, 'browser.goto', { + url: 'https://example.com', + worktree: 'path:/srv/app' + }) + await vi.waitFor(() => expect(events).toHaveLength(2)) + const forwarded = events[1] as { + requestId: string + method: string + workspaceId: string + params: Record + } + // orcad resolves its own selector and pins the forwarded call to that workspace id. + expect(forwarded).toMatchObject({ + type: 'request', + method: 'browser.goto', + workspaceId: 'repo-1::/srv/app', + params: { url: 'https://example.com', worktree: 'id:repo-1::/srv/app' } + }) + + // An answer from any other socket is ignored. + const spoofed = await dispatcher.dispatch( + { + id: 'spoof', + authToken: 'bound', + method: 'desktopBrowserBridge.result', + params: { + attachmentId: ready.attachmentId, + requestId: forwarded.requestId, + ok: true, + result: { url: 'evil' } + } + }, + { ...DESKTOP, connectionId: 'other-socket' } + ) + expect(spoofed).toMatchObject({ ok: true, result: { accepted: false } }) + + const accepted = await dispatcher.dispatch( + { + id: 'answer', + authToken: 'bound', + method: 'desktopBrowserBridge.result', + params: { + attachmentId: ready.attachmentId, + requestId: forwarded.requestId, + ok: true, + result: { url: 'https://example.com/', title: 'Example' } + } + }, + DESKTOP + ) + expect(accepted).toMatchObject({ ok: true, result: { accepted: true } }) + await expect(call).resolves.toMatchObject({ + ok: true, + result: { url: 'https://example.com/', title: 'Example' } + }) + expect(localGoto).not.toHaveBeenCalled() + }) + + it('never forwards a command outside the allowlist', async () => { + setDesktopBrowserBridgeHostEnabled(true) + const { runtime } = orcadRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + const { events } = await attachDesktop(dispatcher) + + const response = await cliCall(dispatcher, 'browser.cookie.get', { worktree: 'path:/srv/app' }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'desktop_browser_bridge_not_forwarded' } + }) + expect(events).toHaveLength(1) + expect(localCookie).not.toHaveBeenCalled() + }) + + it('names the setting when no desktop is attached (setting off or an older desktop)', async () => { + setDesktopBrowserBridgeHostEnabled(true) + const { runtime } = orcadRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + + const response = await cliCall(dispatcher, 'browser.goto', { url: 'https://example.com' }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'desktop_browser_bridge_unavailable' } + }) + expect(response.ok ? '' : response.error.message).toContain( + DESKTOP_BROWSER_BRIDGE_SETTING_LABEL + ) + }) + + it('refuses an attach from a desktop that did not negotiate the capability', async () => { + setDesktopBrowserBridgeHostEnabled(true) + const { runtime } = orcadRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + + const { events } = await attachDesktop(dispatcher, { ...DESKTOP, clientCapabilities: [] }) + + expect(events[0]).toMatchObject({ + error: { message: 'desktop_browser_bridge_capability_required' } + }) + expect(getDesktopBrowserBridgeRegistry(runtime).hasAttachment()).toBe(false) + }) + + it('refuses an attach from a phone or an unauthenticated socket', async () => { + setDesktopBrowserBridgeHostEnabled(true) + const { runtime } = orcadRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + + const { events } = await attachDesktop(dispatcher, { + ...DESKTOP, + pairedDeviceId: '' + }) + + expect(events[0]).toMatchObject({ + error: { message: 'authenticated_desktop_browser_bridge_required' } + }) + }) + + it('a desktop runtime never serves the bridge', async () => { + const { runtime } = orcadRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + + const { events } = await attachDesktop(dispatcher) + + expect(events[0]).toMatchObject({ error: { message: 'desktop_browser_bridge_not_served' } }) + }) + + it('detaching fails the waiting command and the next one names the setting', async () => { + setDesktopBrowserBridgeHostEnabled(true) + const { runtime, cleanups } = orcadRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + const { events, done } = await attachDesktop(dispatcher) + const call = cliCall(dispatcher, 'browser.goto', { + url: 'https://example.com', + worktree: 'path:/srv/app' + }) + await vi.waitFor(() => expect(events).toHaveLength(2)) + + for (const cleanup of cleanups.values()) { + cleanup() + } + await done + + await expect(call).resolves.toMatchObject({ + ok: false, + error: { code: 'desktop_browser_bridge_unavailable' } + }) + await expect( + cliCall(dispatcher, 'browser.goto', { url: 'https://example.com' }) + ).resolves.toMatchObject({ ok: false, error: { code: 'desktop_browser_bridge_unavailable' } }) + }) + + it('leaves a host with its own headless browser on it while no desktop is attached', async () => { + setDesktopBrowserBridgeHostEnabled(true) + const { runtime } = orcadRuntime({ localBrowser: true }) + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + + await expect( + cliCall(dispatcher, 'browser.goto', { url: 'https://example.com' }) + ).resolves.toMatchObject({ ok: true, result: { url: 'local' } }) + }) + + it('does not forward calls from a paired client, only the host CLI', async () => { + setDesktopBrowserBridgeHostEnabled(true) + const { runtime } = orcadRuntime({ localBrowser: true }) + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + const { events } = await attachDesktop(dispatcher) + + const response = await dispatcher.dispatch( + { + id: 'paired', + authToken: 'bound', + method: 'browser.goto', + params: { url: 'https://a.test' } + }, + DESKTOP + ) + + expect(response).toMatchObject({ ok: true, result: { url: 'local' } }) + expect(events).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts new file mode 100644 index 000000000000..6535ba42d75b --- /dev/null +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts @@ -0,0 +1,194 @@ +/** + * orcad's side of the desktop browser bridge: the desktops attached to it and the forwarded + * requests waiting on their answers. The newest attachment serves; an older one takes over only + * when the newer one detaches. + */ +import { randomUUID } from 'node:crypto' +import { BrowserError } from '../../browser/browser-error' +import { + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_MESSAGE, + type DesktopBrowserBridgeEvent, + type DesktopBrowserBridgeResultParams +} from '../../../shared/desktop-browser-bridge-protocol' + +const MAX_PENDING_PER_ATTACHMENT = 64 + +type Attachment = { + attachmentId: string + connectionId: string + pairedDeviceId: string + emit: (event: DesktopBrowserBridgeEvent) => void +} + +type PendingRequest = { + attachmentId: string + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} + +export type DesktopBrowserBridgeForward = { + method: string + params: Record + workspaceId?: string +} + +export class DesktopBrowserBridgeRegistry { + private readonly attachments: Attachment[] = [] + private readonly pending = new Map() + + attach(input: Omit): { attachmentId: string; detach(): void } { + const attachment: Attachment = { ...input, attachmentId: randomUUID() } + this.attachments.push(attachment) + return { + attachmentId: attachment.attachmentId, + detach: () => this.detach(attachment.attachmentId) + } + } + + hasAttachment(): boolean { + return this.attachments.length > 0 + } + + forward( + request: DesktopBrowserBridgeForward, + options: { timeoutMs: number; signal?: AbortSignal } + ): Promise { + const attachment = this.attachments.at(-1) + if (!attachment) { + return Promise.reject(unavailableError()) + } + if (this.pendingCount(attachment.attachmentId) >= MAX_PENDING_PER_ATTACHMENT) { + return Promise.reject( + new BrowserError('browser_busy', 'Too many browser commands are waiting on the desktop.') + ) + } + const requestId = randomUUID() + return new Promise((resolve, reject) => { + const settle = (): void => { + clearTimeout(timer) + this.pending.delete(requestId) + options.signal?.removeEventListener('abort', onAbort) + } + const onAbort = (): void => { + settle() + reject(new BrowserError('browser_aborted', 'The browser command was cancelled.')) + } + const timer = setTimeout(() => { + settle() + reject( + new BrowserError('browser_timeout', 'The desktop did not answer the browser command.') + ) + }, options.timeoutMs) + this.pending.set(requestId, { + attachmentId: attachment.attachmentId, + resolve: (value) => { + settle() + resolve(value) + }, + reject: (error) => { + settle() + reject(error) + }, + timer + }) + if (options.signal?.aborted) { + onAbort() + return + } + options.signal?.addEventListener('abort', onAbort, { once: true }) + try { + attachment.emit({ + type: 'request', + requestId, + method: request.method, + params: request.params, + ...(request.workspaceId ? { workspaceId: request.workspaceId } : {}) + }) + } catch { + this.pending.get(requestId)?.reject(unavailableError()) + } + }) + } + + /** Accepts an answer only from the socket and device the request was sent to. */ + settle( + caller: { connectionId: string; pairedDeviceId: string }, + result: DesktopBrowserBridgeResultParams + ): boolean { + const pending = this.pending.get(result.requestId) + const attachment = this.attachments.find((entry) => entry.attachmentId === result.attachmentId) + if ( + !pending || + !attachment || + pending.attachmentId !== attachment.attachmentId || + attachment.connectionId !== caller.connectionId || + attachment.pairedDeviceId !== caller.pairedDeviceId + ) { + return false + } + if (result.ok) { + pending.resolve(result.result) + } else { + pending.reject(new BrowserError(result.error.code, result.error.message)) + } + return true + } + + private detach(attachmentId: string): void { + const index = this.attachments.findIndex((entry) => entry.attachmentId === attachmentId) + if (index === -1) { + return + } + this.attachments.splice(index, 1) + for (const pending of this.pending.values()) { + if (pending.attachmentId === attachmentId) { + pending.reject( + new BrowserError( + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, + 'The Orca desktop stopped sharing its browser before it answered.' + ) + ) + } + } + } + + private pendingCount(attachmentId: string): number { + let count = 0 + for (const pending of this.pending.values()) { + if (pending.attachmentId === attachmentId) { + count += 1 + } + } + return count + } +} + +function unavailableError(): BrowserError { + return new BrowserError( + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_MESSAGE + ) +} + +const registries = new WeakMap() +let hostEnabled = false + +/** Only orcad serves the bridge; a desktop runtime never accepts an attach or forwards a call. */ +export function setDesktopBrowserBridgeHostEnabled(enabled: boolean): void { + hostEnabled = enabled +} + +export function isDesktopBrowserBridgeHostEnabled(): boolean { + return hostEnabled +} + +export function getDesktopBrowserBridgeRegistry(runtime: object): DesktopBrowserBridgeRegistry { + let registry = registries.get(runtime) + if (!registry) { + registry = new DesktopBrowserBridgeRegistry() + registries.set(runtime, registry) + } + return registry +} diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-route.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-route.ts new file mode 100644 index 000000000000..e9886b6a1bc5 --- /dev/null +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-route.ts @@ -0,0 +1,83 @@ +/** + * Decides, on orcad, whether a browser RPC from this host's own `orca` CLI goes to an attached + * desktop. Only the local CLI is forwarded: a paired client calling orcad is never bounced back + * to a desktop. + */ +import { BrowserError } from '../../browser/browser-error' +import { + DESKTOP_BROWSER_BRIDGE_NOT_FORWARDED_CODE, + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_MESSAGE, + isDesktopBrowserBridgeMethod +} from '../../../shared/desktop-browser-bridge-protocol' +import type { RpcCallerIdentity } from '../rpc/rpc-caller-identity' +import type { DesktopBrowserBridgeRegistry } from './desktop-browser-bridge-registry' + +export type DesktopBrowserBridgeRoute = { handled: false } | { handled: true; result: unknown } + +const DEFAULT_TIMEOUT_MS = 60_000 +const WAIT_MARGIN_MS = 30_000 +const MAX_TIMEOUT_MS = 10 * 60_000 + +export async function routeDesktopBrowserBridgeRpc(options: { + method: string + params: unknown + caller: RpcCallerIdentity | undefined + hostEnabled: boolean + registry: DesktopBrowserBridgeRegistry + localBrowserAvailable: boolean + resolveWorkspace(selector: string): Promise<{ id: string }> + signal?: AbortSignal +}): Promise { + const { method } = options + if ( + !options.hostEnabled || + options.caller?.kind !== 'local-cli' || + !method.startsWith('browser.') + ) { + return { handled: false } + } + const forwarded = isDesktopBrowserBridgeMethod(method) + if (!options.registry.hasAttachment()) { + if (options.localBrowserAvailable || !forwarded) { + return { handled: false } + } + throw new BrowserError( + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_MESSAGE + ) + } + if (!forwarded) { + if (options.localBrowserAvailable) { + return { handled: false } + } + throw new BrowserError( + DESKTOP_BROWSER_BRIDGE_NOT_FORWARDED_CODE, + `${method} is not available from an SSH host. The desktop browser accepts navigation, page reading and input commands only.` + ) + } + const params: Record = isRecord(options.params) ? { ...options.params } : {} + let workspaceId: string | undefined + // Why resolve here: only orcad knows this host's selectors (path:, branch:, active). + if (typeof params.worktree === 'string' && params.worktree.length > 0) { + workspaceId = (await options.resolveWorkspace(params.worktree)).id + params.worktree = `id:${workspaceId}` + } else { + delete params.worktree + } + const result = await options.registry.forward( + { method, params, ...(workspaceId ? { workspaceId } : {}) }, + { timeoutMs: forwardTimeoutMs(method, params), signal: options.signal } + ) + return { handled: true, result } +} + +function forwardTimeoutMs(method: string, params: Record): number { + const requested = typeof params.timeout === 'number' ? params.timeout : 0 + const base = method === 'browser.wait' ? requested + WAIT_MARGIN_MS : DEFAULT_TIMEOUT_MS + return Math.min(Math.max(base, DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS) +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} diff --git a/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts b/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts new file mode 100644 index 000000000000..8ef3b29206ab --- /dev/null +++ b/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts @@ -0,0 +1,36 @@ +/** + * The workspace a forwarded SSH-host command may touch on this desktop. While a bridged command + * runs, every workspace selector the browser layer resolves must name exactly that workspace; any + * other selector is refused, so a host can never widen its reach to the desktop's own worktrees. + */ +import { AsyncLocalStorage } from 'node:async_hooks' +import { BrowserError } from '../../browser/browser-error' +import { DESKTOP_BROWSER_BRIDGE_SCOPE_CODE } from '../../../shared/desktop-browser-bridge-protocol' + +const scope = new AsyncLocalStorage<{ workspaceId: string }>() + +export function runInRemoteCliBrowserScope( + workspaceId: string, + run: () => Promise +): Promise { + return scope.run({ workspaceId }, run) +} + +/** Null outside a bridged command; inside one, the scoped workspace or a refusal. */ +export function resolveRemoteCliBrowserScopeSelector( + selector: string +): Promise<{ id: string }> | null { + const active = scope.getStore() + if (!active) { + return null + } + if (selector === `id:${active.workspaceId}`) { + return Promise.resolve({ id: active.workspaceId }) + } + return Promise.reject( + new BrowserError( + DESKTOP_BROWSER_BRIDGE_SCOPE_CODE, + 'An SSH host can only reach browser tabs in its own workspaces.' + ) + ) +} diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index 27414703158f..e45f0da734f0 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -51,6 +51,8 @@ import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidato import { MACHINE_NAME_PUBLISH_WAIT_MS } from './runtime-machine-name' import { getManagedServerActions } from './managed-server-actions-registry' import type { WorktreeSortOrderUpdate } from '../../shared/worktree/sort-order-update' +import { isDesktopBrowserBridgeHostEnabled } from './desktop-browser-bridge/desktop-browser-bridge-registry' +import { DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY } from '../../shared/desktop-browser-bridge-protocol' type RuntimeStatusHost = { getAvailableAuthoritativeWindow(): unknown @@ -137,6 +139,9 @@ export abstract class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntime if (getManagedServerActions()) { capabilities.push(MANAGED_SERVER_RUNTIME_CAPABILITY) } + if (isDesktopBrowserBridgeHostEnabled()) { + capabilities.push(DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY) + } // Why not a static capability: dev trees and `orca serve` installs may carry no // out/mobile-web, and advertising a bundle this install cannot produce would promise a // download that only ever answers mobile_web_bundle_unavailable. diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 6911684def37..2214361d61b3 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -187,6 +187,14 @@ export class RpcDispatcher { ) return clientHostedBrowser.result } + const desktopBrowser = await (runtime.routeDesktopBrowserBridgeRpc?.( + request.method, + params, + context + ) ?? { handled: false as const }) + if (desktopBrowser.handled) { + return desktopBrowser.result + } const compatibility = await legacyOrchestration.tryHandle(request, params, context.signal) if (compatibility.handled) { diff --git a/src/main/runtime/rpc/methods/desktop-browser-bridge.ts b/src/main/runtime/rpc/methods/desktop-browser-bridge.ts new file mode 100644 index 000000000000..0014cc3ef33b --- /dev/null +++ b/src/main/runtime/rpc/methods/desktop-browser-bridge.ts @@ -0,0 +1,79 @@ +import { + DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY, + DesktopBrowserBridgeAttachParams, + DesktopBrowserBridgeResultParams +} from '../../../../shared/desktop-browser-bridge-protocol' +import { + getDesktopBrowserBridgeRegistry, + isDesktopBrowserBridgeHostEnabled +} from '../../desktop-browser-bridge/desktop-browser-bridge-registry' +import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' + +function requireBridgeCaller(context: RpcContext): { + connectionId: string + pairedDeviceId: string +} { + if (!isDesktopBrowserBridgeHostEnabled()) { + throw new Error('desktop_browser_bridge_not_served') + } + const { clientKind, connectionId, pairedDeviceId, clientCapabilities } = context + // Why: only an authenticated, end-to-end encrypted paired desktop socket may receive the host's commands. + if (clientKind !== 'runtime' || !connectionId || !pairedDeviceId) { + throw new Error('authenticated_desktop_browser_bridge_required') + } + if (!clientCapabilities?.includes(DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY)) { + throw new Error('desktop_browser_bridge_capability_required') + } + return { connectionId, pairedDeviceId } +} + +export const DESKTOP_BROWSER_BRIDGE_RPC_METHODS = [ + defineStreamingMethod({ + name: 'desktopBrowserBridge.attach', + permission: 'workspace', + params: DesktopBrowserBridgeAttachParams, + handler: async (params, context, emit) => { + const caller = requireBridgeCaller(context) + const { runtime, signal } = context + const handle = getDesktopBrowserBridgeRegistry(runtime).attach({ ...caller, emit }) + let release = (): void => {} + const released = new Promise((resolve) => { + release = resolve + }) + let cleaned = false + const cleanup = (): void => { + if (cleaned) { + return + } + cleaned = true + handle.detach() + release() + } + try { + runtime.registerSubscriptionCleanup( + `desktop-browser-bridge:${params.bridgeClientId}:${handle.attachmentId}`, + cleanup, + caller.connectionId + ) + signal?.addEventListener('abort', cleanup, { once: true }) + if (signal?.aborted) { + return + } + emit({ type: 'ready', attachmentId: handle.attachmentId }) + await released + } finally { + signal?.removeEventListener('abort', cleanup) + cleanup() + } + } + }), + defineMethod({ + name: 'desktopBrowserBridge.result', + permission: 'workspace', + params: DesktopBrowserBridgeResultParams, + handler: (params, context) => { + const caller = requireBridgeCaller(context) + return { accepted: getDesktopBrowserBridgeRegistry(context.runtime).settle(caller, params) } + } + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index a19ea198c71d..83bb67b49c35 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -13,6 +13,7 @@ import { BROWSER_IDENTITY_METHODS } from './browser-identity-rpc' import { BROWSER_EXTRA_METHODS } from './browser-extras' import { BROWSER_SCREENCAST_METHODS } from './browser-screencast' import { BROWSER_CLIENT_HOST_METHODS } from './browser-client-host' +import { DESKTOP_BROWSER_BRIDGE_RPC_METHODS } from './desktop-browser-bridge' import { BROWSER_CLIENT_FILE_CHANNEL_METHODS } from './browser-client-file-channel' import { BROWSER_NETWORK_TUNNEL_METHODS } from './browser-network-tunnel' import { ORCHESTRATION_METHODS } from './orchestration' @@ -90,6 +91,7 @@ export const ALL_RPC_METHODS = [ ...BROWSER_SCREENCAST_METHODS, ...BROWSER_EXTRA_METHODS, ...BROWSER_CLIENT_HOST_METHODS, + ...DESKTOP_BROWSER_BRIDGE_RPC_METHODS, ...BROWSER_CLIENT_FILE_CHANNEL_METHODS, ...BROWSER_NETWORK_TUNNEL_METHODS, ...ORCHESTRATION_METHODS, diff --git a/src/main/runtime/session-graph/session-graph-authority.ts b/src/main/runtime/session-graph/session-graph-authority.ts index 4edc65f9019f..c2f651a607d7 100644 --- a/src/main/runtime/session-graph/session-graph-authority.ts +++ b/src/main/runtime/session-graph/session-graph-authority.ts @@ -16,6 +16,17 @@ import type { ClientHostedBrowserRpcRoute } from '../runtime-browser-client-auto import { routeRuntimeBrowserClientAutomation } from '../runtime-browser-client-automation' import { getRuntimeBrowserPageRegistry } from '../runtime-browser-page-registry' import { getBrowserHostLeaseRegistry } from '../browser-host-lease-registry-instance' +import { + routeDesktopBrowserBridgeRpc, + type DesktopBrowserBridgeRoute +} from '../desktop-browser-bridge/desktop-browser-bridge-route' +import { + getDesktopBrowserBridgeRegistry, + isDesktopBrowserBridgeHostEnabled +} from '../desktop-browser-bridge/desktop-browser-bridge-registry' +import { runtimeBrowserCommandsFactoryIsAvailable } from '../runtime-browser-commands-factory' +import { resolveRemoteCliBrowserScopeSelector } from '../desktop-browser-bridge/remote-cli-browser-scope' +import type { RpcContext } from '../rpc/core' export abstract class OrcaRuntimeWithTransitionGraphReloadToTerminalState extends OrcaRuntimeWithHasTerminalsForWorktree { protected abstract resolveBrowserNetworkExecutionHostForWorktree( @@ -202,6 +213,25 @@ export abstract class OrcaRuntimeWithTransitionGraphReloadToTerminalState extend params, pages: getRuntimeBrowserPageRegistry(this), leases: getBrowserHostLeaseRegistry(this), + resolveWorkspace: (selector) => + resolveRemoteCliBrowserScopeSelector(selector) ?? this.resolveBrowserWorkspace(selector) + }) + } + + /** On orcad, sends this host's CLI browser commands to the desktop that opted in to serve them. */ + routeDesktopBrowserBridgeRpc( + method: string, + params: unknown, + context: Pick + ): Promise { + return routeDesktopBrowserBridgeRpc({ + method, + params, + caller: context.caller, + signal: context.signal, + hostEnabled: isDesktopBrowserBridgeHostEnabled(), + registry: getDesktopBrowserBridgeRegistry(this), + localBrowserAvailable: runtimeBrowserCommandsFactoryIsAvailable(), resolveWorkspace: (selector) => this.resolveBrowserWorkspace(selector) }) } diff --git a/src/main/runtime/terminal/driver/terminal-driver-resources.ts b/src/main/runtime/terminal/driver/terminal-driver-resources.ts index f81e21f3f5ee..7b3d1ce14c97 100644 --- a/src/main/runtime/terminal/driver/terminal-driver-resources.ts +++ b/src/main/runtime/terminal/driver/terminal-driver-resources.ts @@ -1,4 +1,5 @@ import type { RuntimeServiceCommandSurface } from '../../runtime-service-command-surface' +import { resolveRemoteCliBrowserScopeSelector } from '../../desktop-browser-bridge/remote-cli-browser-scope' import type { ResolvedWorktree } from '../../runtime-worktree-path-identity' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' @@ -162,8 +163,10 @@ export abstract class OrcaRuntimeWithTerminalDrivers extends OrcaRuntimeWithFitO }, browserHost: { getAgentBrowserBridge: () => this.agentBrowserBridge, - resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), - resolveBrowserWorkspace: (selector) => this.resolveBrowserWorkspace(selector), + resolveWorktreeSelector: (selector) => + resolveRemoteCliBrowserScopeSelector(selector) ?? this.resolveWorktreeSelector(selector), + resolveBrowserWorkspace: (selector) => + resolveRemoteCliBrowserScopeSelector(selector) ?? this.resolveBrowserWorkspace(selector), getBrowserHostLeaseRegistry: () => getBrowserHostLeaseRegistry(this), getRuntimeBrowserPageRegistry: () => getRuntimeBrowserPageRegistry(this), resolveBrowserNetworkExecutionHost: (worktree) => diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index b857999e804b..4397bfc22e1a 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -13,6 +13,7 @@ import { setStructuredAgentSessionTeardownTrigger } from '../runtime/structured- import { awaitRuntimeFileWatcherUnsubscribes } from '../runtime/files/watch/file-watcher-leases' import { clearRuntimeMetadataIfOwned } from '../runtime/runtime-metadata' import { shutdownPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' +import { disposeDesktopBrowserBridge } from '../ipc/desktop-browser-bridge-wiring' import { browserManager } from '../browser/browser-manager' import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' import { stopCodexAccountSessionBridges } from '../codex/codex-account-session-bridge' @@ -234,6 +235,7 @@ function installWillQuitHandler(): void { state.openCodeUsage?.flush(), state.museUsage?.flush() ]).then(() => {}) + disposeDesktopBrowserBridge() const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts() const skillUploadShutdown = state.runtime?.disposeSkillUploadSessions() ?? Promise.resolve() // Why: capture pid/runtimeId synchronously (before any await) so a later teardown path can't null them out mid-chain. diff --git a/src/preload/api/ui-bridge-state-and-menu-commands.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts index f58b51334a11..362fe0851892 100644 --- a/src/preload/api/ui-bridge-state-and-menu-commands.ts +++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts @@ -177,5 +177,18 @@ export const uiStateAndMenuCommandsApi = { }, replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }): void => { ipcRenderer.send('browser:tabCreateReply', reply) + }, + onRequestRemoteCliBrowserScope: ( + callback: (data: { requestId: string; environmentId: string; workspaceId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { requestId: string; environmentId: string; workspaceId: string } + ) => callback(data) + ipcRenderer.on('browser:requestRemoteCliScope', listener) + return () => ipcRenderer.removeListener('browser:requestRemoteCliScope', listener) + }, + replyRemoteCliBrowserScope: (reply: { requestId: string; allowed: boolean }): void => { + ipcRenderer.send('browser:remoteCliScopeReply', reply) } } satisfies Partial diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 89dc1ba22b18..20500813e2de 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -116,6 +116,11 @@ export type UiCommandEventApi = { }) => void ) => () => void replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }) => void + /** Main asks whether a paired environment owns a workspace before running that host's CLI command. */ + onRequestRemoteCliBrowserScope: ( + callback: (data: { requestId: string; environmentId: string; workspaceId: string }) => void + ) => () => void + replyRemoteCliBrowserScope: (reply: { requestId: string; allowed: boolean }) => void onRequestTabSetProfile: ( callback: (data: { requestId: string diff --git a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx index 33cc5ac593e1..fcb5393dc720 100644 --- a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx +++ b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx @@ -119,7 +119,7 @@ export function SshHostAdvancedFields({

{translate( 'auto.components.settings.SshTargetForm.allowRemoteCliControlHelp', - 'When off, orca commands run on this host reach only its own terminals. Turn on to let them reach other hosts, orchestration, files, and integrations on this computer.' + "When on, orca commands run on this host can open and drive Orca browser tabs on this desktop, only in this host's workspaces and only while it is connected. They cannot read your files, clipboard or cookies. When off, they reach only this host's own Orca server." )}

diff --git a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts index f07adabb9f1b..06c6c990bed3 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts @@ -71,6 +71,7 @@ export function buildWindowApi(args: { onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, + onRequestRemoteCliBrowserScope: () => () => {}, replyTabCreate: () => {}, onRequestTabClose: () => () => {}, replyTabClose: () => {}, diff --git a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts index 396cd3c8a2b1..478981d4489d 100644 --- a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts @@ -221,6 +221,7 @@ export async function useIpcEventsForCloseRouting({ onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, + onRequestRemoteCliBrowserScope: () => () => {}, replyTabCreate: () => {}, onRequestTabClose: (listener: RequestTabCloseListener) => { if (requestTabCloseListenerRef) { diff --git a/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts index 8efc766bedf1..776aac53b90d 100644 --- a/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts @@ -129,6 +129,7 @@ export function buildTerminalCreateWindow(args: { onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, + onRequestRemoteCliBrowserScope: () => () => {}, replyTabCreate: () => {}, onRequestTabClose: () => () => {}, replyTabClose: vi.fn(), diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts index 915bfcd60426..010791d35a8f 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts @@ -3,17 +3,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' type CreateRequest = { requestId: string; worktreeId?: string; url: string } type CloseRequest = { requestId: string; tabId?: string | null; worktreeId?: string } +type ScopeRequest = { requestId: string; environmentId: string; workspaceId: string } const mocks = vi.hoisted(() => { const listeners: { createListener: ((data: CreateRequest) => void) | null closeListener: ((data: CloseRequest) => void) | null - } = { createListener: null, closeListener: null } + scopeListener: ((data: ScopeRequest) => void) | null + } = { createListener: null, closeListener: null, scopeListener: null } return { ...listeners, getState: vi.fn(), replyTabCreate: vi.fn(), replyTabClose: vi.fn(), + replyRemoteCliBrowserScope: vi.fn(), createBrowserTab: vi.fn(), closeBrowserTab: vi.fn() } @@ -85,6 +88,11 @@ describe('browser requests from this desktop follow the workspace owner (#22275) return () => {} }, replyTabCreate: mocks.replyTabCreate, + onRequestRemoteCliBrowserScope: (listener: typeof mocks.scopeListener) => { + mocks.scopeListener = listener + return () => {} + }, + replyRemoteCliBrowserScope: mocks.replyRemoteCliBrowserScope, onRequestTabSetProfile: () => () => {}, replyTabSetProfile: vi.fn(), onRequestTabClose: (listener: typeof mocks.closeListener) => { @@ -133,3 +141,57 @@ describe('browser requests from this desktop follow the workspace owner (#22275) }) }) }) + +describe('a managed SSH host reaches only its own workspaces on this desktop (#29145)', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ui: { + onRequestTabCreate: () => () => {}, + replyTabCreate: vi.fn(), + onRequestRemoteCliBrowserScope: (listener: typeof mocks.scopeListener) => { + mocks.scopeListener = listener + return () => {} + }, + replyRemoteCliBrowserScope: mocks.replyRemoteCliBrowserScope, + onRequestTabSetProfile: () => () => {}, + replyTabSetProfile: vi.fn(), + onRequestTabClose: () => () => {}, + replyTabClose: vi.fn() + } + } + }) + registerBrowserRequestIpcBridge([]) + }) + + it('allows the owning environment and refuses every other one and local workspaces', () => { + mocks.getState.mockReturnValue(storeState(LOCAL_WT)) + + mocks.scopeListener?.({ requestId: 's1', environmentId: 'env-remote', workspaceId: REMOTE_WT }) + mocks.scopeListener?.({ requestId: 's2', environmentId: 'env-other', workspaceId: REMOTE_WT }) + mocks.scopeListener?.({ requestId: 's3', environmentId: 'env-remote', workspaceId: LOCAL_WT }) + mocks.scopeListener?.({ requestId: 's4', environmentId: 'env-remote', workspaceId: 'nope::/x' }) + + expect(mocks.replyRemoteCliBrowserScope.mock.calls.map(([reply]) => reply)).toEqual([ + { requestId: 's1', allowed: true }, + { requestId: 's2', allowed: false }, + { requestId: 's3', allowed: false }, + { requestId: 's4', allowed: false } + ]) + }) + + it('answers no when the store cannot resolve the owner', () => { + mocks.getState.mockImplementation(() => { + throw new Error('store unavailable') + }) + + mocks.scopeListener?.({ requestId: 's5', environmentId: 'env-remote', workspaceId: REMOTE_WT }) + + expect(mocks.replyRemoteCliBrowserScope).toHaveBeenCalledWith({ + requestId: 's5', + allowed: false + }) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts index a2454797ee6a..3887766de036 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts @@ -61,6 +61,7 @@ describe('browser profile request teardown', () => { value: { ui: { onRequestTabCreate: () => () => {}, + onRequestRemoteCliBrowserScope: () => () => {}, replyTabCreate: vi.fn(), onRequestTabSetProfile: (listener: typeof mocks.profileListener) => { mocks.profileListener = listener diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts index 55ba1d906e77..09a703f749d9 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts @@ -9,6 +9,7 @@ import { useAppStore } from '../../store' import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' import { findBrowserTabWorktreeId, + isBrowserWorkspaceOwnedByEnvironment, isBrowserWorkspaceOwnedByPairedServer } from './browser-request-workspace-owner' @@ -22,6 +23,22 @@ function pairedServerWorkspaceError(): string { // Why owner, not the Active Server setting: these requests come from this desktop's own runtime, // which only names workspaces it runs, so focus on a paired server must not refuse them (#22275). export function registerBrowserRequestIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onRequestRemoteCliBrowserScope((data) => { + let allowed = false + try { + allowed = isBrowserWorkspaceOwnedByEnvironment( + useAppStore.getState(), + data.workspaceId, + data.environmentId + ) + } catch { + // Why: an owner this window cannot prove is a refusal, never a pass. + allowed = false + } + window.api.ui.replyRemoteCliBrowserScope({ requestId: data.requestId, allowed }) + }) + ) unsubs.push( window.api.ui.onRequestTabCreate((data) => { try { diff --git a/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts b/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts index 78a819fbeae3..212b50aad9da 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts @@ -18,6 +18,24 @@ export function isBrowserWorkspaceOwnedByPairedServer( ) } +/** + * Whether `environmentId`'s own machine is the single owner of `worktreeId`. Gates the browser + * commands a managed SSH host forwards, so a host reaches only its own workspaces' tabs. + */ +export function isBrowserWorkspaceOwnedByEnvironment( + state: WorktreeOperationRouteState, + worktreeId: string, + environmentId: string +): boolean { + const owner = resolveOwner(state, { workspaceId: worktreeId }) + return ( + owner.kind === 'resolved' && + owner.owner.endpoint.kind === 'environment' && + owner.owner.endpoint.environmentId === environmentId && + owner.owner.at === 'local' + ) +} + /** The workspace holding a browser tab, addressed by its tab (workspace) id or one of its page ids. */ export function findBrowserTabWorktreeId( state: Pick, diff --git a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts index 4bb359b55f83..817572c7e7bf 100644 --- a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts @@ -190,6 +190,7 @@ describe('useIpcEvents browser tab create routing', () => { return () => {} }, replyTabCreate, + onRequestRemoteCliBrowserScope: () => () => {}, onRequestTabClose: () => () => {}, replyTabClose: () => {}, onRequestTabSetProfile: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts index 0c4bd82c89e3..594d240b434d 100644 --- a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts @@ -163,6 +163,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, + onRequestRemoteCliBrowserScope: () => () => {}, replyTabCreate: () => {}, onRequestTabClose: () => () => {}, replyTabClose: () => {}, @@ -432,6 +433,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, + onRequestRemoteCliBrowserScope: () => () => {}, replyTabCreate: () => {}, onRequestTabClose: () => () => {}, replyTabClose: () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts index 23f07f7feec2..1430c68f7802 100644 --- a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts @@ -160,6 +160,7 @@ describe('useIpcEvents browser tab close routing', () => { onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, + onRequestRemoteCliBrowserScope: () => () => {}, replyTabCreate: () => {}, onRequestTabClose: ( listener: (data: { @@ -389,6 +390,7 @@ describe('useIpcEvents browser tab close routing', () => { onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, + onRequestRemoteCliBrowserScope: () => () => {}, replyTabCreate: () => {}, onRequestTabClose: ( listener: (data: { @@ -613,6 +615,7 @@ describe('useIpcEvents browser tab close routing', () => { onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, + onRequestRemoteCliBrowserScope: () => () => {}, replyTabCreate: () => {}, onRequestTabClose: ( listener: (data: { diff --git a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts index ccbb80019c4a..ec4ffa707093 100644 --- a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts @@ -168,6 +168,7 @@ describe('useIpcEvents updater integration', () => { onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, + onRequestRemoteCliBrowserScope: () => () => {}, replyTabCreate: () => {}, onRequestTabClose: () => () => {}, replyTabClose: () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts index 8858c9553493..d6d61104f3a2 100644 --- a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts @@ -135,6 +135,7 @@ describe('useIpcEvents updater integration', () => { onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, + onRequestRemoteCliBrowserScope: () => () => {}, replyTabCreate: () => {}, onRequestTabClose: () => () => {}, replyTabClose: () => {}, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a4336614aca1..61ba42626b2e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8985,7 +8985,7 @@ "8c922dffba": "Reuse SSH connection for faster setup", "53e9aabfc0": "Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions.", "allowRemoteCliControl": "Allow this host's orca CLI to control Orca", - "allowRemoteCliControlHelp": "When off, orca commands run on this host reach only its own terminals. Turn on to let them reach other hosts, orchestration, files, and integrations on this computer.", + "allowRemoteCliControlHelp": "When on, orca commands run on this host can open and drive Orca browser tabs on this desktop, only in this host's workspaces and only while it is connected. They cannot read your files, clipboard or cookies. When off, they reach only this host's own Orca server.", "editTitle": "Edit SSH host", "addTitle": "Add SSH host", "editDescription": "Update connection details for this machine. Changes apply on next connect.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f50a5e281304..cdfdd43b8eaa 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8712,7 +8712,7 @@ "addDescription": "Agrega una máquina persistente en la que puedas iniciar sesión por SSH.", "editingPrefix": "Editando", "allowRemoteCliControl": "Permitir que la orca CLI de este host controle Orca", - "allowRemoteCliControlHelp": "Cuando está desactivado, los comandos orca ejecutados en este host solo llegan a sus propios terminales. Actívalo para que lleguen a otros hosts, la orquestación, los archivos y las integraciones de este equipo." + "allowRemoteCliControlHelp": "Cuando está activado, los comandos orca ejecutados en este host pueden abrir y controlar pestañas del navegador de Orca en este escritorio, solo en los espacios de trabajo de este host y solo mientras esté conectado. No pueden leer tus archivos, tu portapapeles ni tus cookies. Cuando está desactivado, solo llegan al propio servidor de Orca de este host." }, "TasksPane": { "f71d8a9dd3": "Proveedores de tareas", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index a4148b7ff19e..680e93e00ffe 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -8862,7 +8862,7 @@ "addDescription": "Ajoutez une machine permanente sur laquelle vous pouvez vous connecter en SSH.", "editingPrefix": "Modification", "allowRemoteCliControl": "Autoriser l'orca CLI de cet hôte à contrôler Orca", - "allowRemoteCliControlHelp": "Lorsque cette option est désactivée, les commandes orca exécutées sur cet hôte n'atteignent que ses propres terminaux. Activez-la pour qu'elles atteignent d'autres hôtes, l'orchestration, les fichiers et les intégrations de cet ordinateur." + "allowRemoteCliControlHelp": "Lorsque cette option est activée, les commandes orca exécutées sur cet hôte peuvent ouvrir et piloter des onglets du navigateur Orca sur ce bureau, uniquement dans les espaces de travail de cet hôte et uniquement tant qu'il est connecté. Elles ne peuvent pas lire vos fichiers, votre presse-papiers ni vos cookies. Lorsqu'elle est désactivée, elles n'atteignent que le serveur Orca de cet hôte." }, "TasksPane": { "f71d8a9dd3": "Fournisseurs de tâches", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3c0f9428779d..42e92b04332b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8738,7 +8738,7 @@ "addDescription": "SSH でログインできる永続的なマシンを追加します。", "editingPrefix": "編集中", "allowRemoteCliControl": "このホストの orca CLI による Orca の操作を許可する", - "allowRemoteCliControlHelp": "オフの場合、このホストで実行される orca コマンドは、このホスト自身のターミナルにのみアクセスできます。オンにすると、このコンピュータ上の他のホスト、オーケストレーション、ファイル、連携にもアクセスできるようになります。" + "allowRemoteCliControlHelp": "オンにすると、このホストで実行される orca コマンドは、このホストのワークスペース内に限り、接続中のみ、このデスクトップの Orca ブラウザのタブを開いて操作できます。ファイル、クリップボード、Cookie は読み取れません。オフの場合、このホスト自身の Orca サーバーにのみアクセスします。" }, "TasksPane": { "f71d8a9dd3": "タスクプロバイダー", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 652d0dc074dd..730f710be107 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8701,7 +8701,7 @@ "addDescription": "SSH로 로그인할 수 있는 영구 머신을 추가합니다.", "editingPrefix": "편집 중", "allowRemoteCliControl": "이 호스트의 orca CLI가 Orca를 제어하도록 허용", - "allowRemoteCliControlHelp": "꺼져 있으면 이 호스트에서 실행한 orca 명령은 이 호스트의 터미널에만 적용됩니다. 켜면 다른 호스트와 이 컴퓨터의 오케스트레이션, 파일 및 연동 기능에도 접근할 수 있습니다." + "allowRemoteCliControlHelp": "켜면 이 호스트에서 실행한 orca 명령이 이 호스트의 작업 공간에서만, 연결되어 있는 동안에만 이 데스크톱의 Orca 브라우저 탭을 열고 조작할 수 있습니다. 파일, 클립보드, 쿠키는 읽을 수 없습니다. 끄면 이 호스트 자체의 Orca 서버에만 접근합니다." }, "TasksPane": { "f71d8a9dd3": "작업 제공자", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ab5c567a0236..0a6e29679199 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8701,7 +8701,7 @@ "addDescription": "添加一台可通过 SSH 登录的持久机器。", "editingPrefix": "正在编辑", "allowRemoteCliControl": "允许此主机的 orca CLI 控制 Orca", - "allowRemoteCliControlHelp": "关闭时,在此主机上运行的 orca 命令只能访问其自身的终端。开启后,可让它们访问此计算机上的其他主机、编排、文件和集成。" + "allowRemoteCliControlHelp": "开启后,在此主机上运行的 orca 命令可以打开并操作此桌面上的 Orca 浏览器标签页,但仅限此主机的工作区,且仅在主机连接期间。它们无法读取你的文件、剪贴板或 Cookie。关闭时,它们只能访问此主机自身的 Orca 服务器。" }, "TasksPane": { "f71d8a9dd3": "任务提供者", diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index 080be40723c1..4315e9232067 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -233,6 +233,8 @@ export function createWebUiApi(): NonNullable['ui']> { onNewSimulatorTab: () => noopUnsubscribe, onRequestTabCreate: () => noopUnsubscribe, replyTabCreate: () => {}, + onRequestRemoteCliBrowserScope: () => noopUnsubscribe, + replyRemoteCliBrowserScope: () => {}, onRequestTabSetProfile: () => noopUnsubscribe, replyTabSetProfile: () => {}, onRequestTabClose: () => noopUnsubscribe, diff --git a/src/shared/desktop-browser-bridge-protocol.ts b/src/shared/desktop-browser-bridge-protocol.ts new file mode 100644 index 000000000000..89dd8002ef29 --- /dev/null +++ b/src/shared/desktop-browser-bridge-protocol.ts @@ -0,0 +1,125 @@ +/** + * The desktop browser bridge: a managed SSH host's orcad forwards browser commands from the host's + * own `orca` CLI to a desktop that opted in with "Allow this host's orca CLI to control Orca". + * + * Both sides gate on DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY: orcad advertises it in status, and + * the desktop declares it on the socket it attaches with. A peer without it never attaches, so the + * CLI gets DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE instead of a silent hang. + */ +import { z } from 'zod' + +export const DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY = 'browser.desktopBridge.v1' + +export const DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE = 'desktop_browser_bridge_unavailable' +export const DESKTOP_BROWSER_BRIDGE_NOT_FORWARDED_CODE = 'desktop_browser_bridge_not_forwarded' +export const DESKTOP_BROWSER_BRIDGE_SCOPE_CODE = 'desktop_browser_bridge_out_of_scope' + +export const DESKTOP_BROWSER_BRIDGE_SETTING_LABEL = "Allow this host's orca CLI to control Orca" + +export const DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_MESSAGE = `No Orca desktop is sharing its browser with this host. On your desktop, open this host's SSH settings, turn on "${DESKTOP_BROWSER_BRIDGE_SETTING_LABEL}", and keep the host connected. The desktop needs an Orca version that supports it.` + +/** + * The only commands a host may run on the desktop browser. Narrower than the relay bridge before + * 1.4.225, which ran any CLI command on the desktop: nothing here reads or writes desktop files + * (upload, download, pdf), reads the desktop clipboard or cookie jar, runs raw agent-browser + * commands, changes browser profiles or identity, or bypasses certificate errors. + */ +export const DESKTOP_BROWSER_BRIDGE_METHODS: ReadonlySet = new Set([ + 'browser.tabList', + 'browser.tabCurrent', + 'browser.tabShow', + 'browser.tabSwitch', + 'browser.tabCreate', + 'browser.tabClose', + 'browser.goto', + 'browser.back', + 'browser.forward', + 'browser.reload', + 'browser.snapshot', + 'browser.screenshot', + 'browser.fullScreenshot', + 'browser.get', + 'browser.is', + 'browser.find', + 'browser.wait', + 'browser.eval', + 'browser.console', + 'browser.network', + 'browser.click', + 'browser.dblclick', + 'browser.fill', + 'browser.type', + 'browser.select', + 'browser.check', + 'browser.focus', + 'browser.clear', + 'browser.selectAll', + 'browser.hover', + 'browser.drag', + 'browser.scroll', + 'browser.scrollIntoView', + 'browser.highlight', + 'browser.keypress', + 'browser.keyboardInsertText', + 'browser.mouseMove', + 'browser.mouseDown', + 'browser.mouseUp', + 'browser.mouseClick', + 'browser.mouseWheel', + 'browser.viewport', + 'browser.dialogAccept', + 'browser.dialogDismiss' +]) + +export function isDesktopBrowserBridgeMethod(method: string): boolean { + return DESKTOP_BROWSER_BRIDGE_METHODS.has(method) +} + +const Identity = z.string().min(1).max(256) +const MAX_FORWARDED_PARAMS_BYTES = 1024 * 1024 + +export const DesktopBrowserBridgeAttachParams = z.object({ + bridgeClientId: Identity +}) + +export const DesktopBrowserBridgeRequest = z.object({ + type: z.literal('request'), + requestId: Identity, + method: z.string().refine(isDesktopBrowserBridgeMethod, 'method is not forwarded'), + /** The workspace orcad resolved the caller's selector to; absent when it named none. */ + workspaceId: Identity.optional(), + params: z + .record(z.string(), z.unknown()) + .refine( + (value) => JSON.stringify(value).length <= MAX_FORWARDED_PARAMS_BYTES, + 'params exceed the forwarded size budget' + ) +}) +export type DesktopBrowserBridgeRequest = z.infer + +export const DesktopBrowserBridgeEvent = z.discriminatedUnion('type', [ + z.object({ type: z.literal('ready'), attachmentId: Identity }), + DesktopBrowserBridgeRequest +]) +export type DesktopBrowserBridgeEvent = z.infer + +const BridgeError = z.object({ + code: z.string().min(1).max(128), + message: z.string().max(4096) +}) + +export const DesktopBrowserBridgeResultParams = z.discriminatedUnion('ok', [ + z.object({ + attachmentId: Identity, + requestId: Identity, + ok: z.literal(true), + result: z.unknown() + }), + z.object({ + attachmentId: Identity, + requestId: Identity, + ok: z.literal(false), + error: BridgeError + }) +]) +export type DesktopBrowserBridgeResultParams = z.infer diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 0bf7954aab1c..778c0eb4d352 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -19,6 +19,10 @@ import { } from '../browser-client-host-protocol' import { BrowserClientPageMetadataParams } from '../browser-client-page-metadata-protocol' import { DelegatedMobileDeviceSyncParamsSchema } from '../delegated-mobile-device-contract' +import { + DesktopBrowserBridgeAttachParams, + DesktopBrowserBridgeResultParams +} from '../desktop-browser-bridge-protocol' import { RegisterDeviceKeyParamsSchema } from '../device-key-registration-contract' import { HostAccountLaunchEnvParams } from '../host-account-launch-env' import { @@ -830,6 +834,8 @@ export const RPC_PARAMS_BY_METHOD = { 'computer.scroll': ScrollOfComputerSchemasParams, 'computer.setValue': SetValue, 'computer.typeText': TypeText, + 'desktopBrowserBridge.attach': DesktopBrowserBridgeAttachParams, + 'desktopBrowserBridge.result': DesktopBrowserBridgeResultParams, 'diagnostics.memory': null, 'emulator.attach': AttachParamsOfEmulatorParams, 'emulator.availability': EmulatorAvailabilityParams, diff --git a/tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts b/tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts new file mode 100644 index 000000000000..9c107fecb89f --- /dev/null +++ b/tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts @@ -0,0 +1,200 @@ +/** + * #29145: an agent on a managed SSH host drives this desktop's Orca browser through the host's own + * `orca` CLI, only while "Allow this host's orca CLI to control Orca" is on. + * + * Off, the CLI is refused with an error that names the setting. On, `orca tab create` opens a + * desktop tab in the host's workspace (its traffic leaves through the host, which alone resolves + * the fixture origin) and `orca goto` navigates it. Off again, the host is refused at once. + */ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { createRestartSession } from './helpers/orca-restart' +import { waitForSessionReady } from './helpers/store' +import { + cleanupDockerSshHostTarget, + DOCKER_SSH_HOST_REMOTE_REPO_PATH, + execDockerSshHostTargetCommand, + startDockerSshHostTarget, + type DockerSshHostTarget +} from './helpers/docker-ssh-host-target' +import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' +import { + readSshRemoteOnlyRequests, + SSH_REMOTE_ONLY_ORIGIN, + startSshRemoteOnlyBrowserFixture +} from './helpers/ssh-remote-only-browser-fixture' +import { convertAndRetain } from './helpers/orcad-convert-flow' +import { + activateManagedWorkspace, + expectPaneText, + managedEnvironmentId, + openTerminalTab, + ORCAD_TEMPLATE, + skipUnlessManagedDockerHost, + typeInTerminal +} from './helpers/orcad-managed-workspace' +import { DESKTOP_BROWSER_BRIDGE_SETTING_LABEL } from '../../src/shared/desktop-browser-bridge-protocol' + +skipUnlessManagedDockerHost() + +type CliResult = { + ok: boolean + result?: Record + error?: { code: string; message: string } +} + +let step = 0 + +/** Runs an `orca` command in the host's terminal, as an agent there would, and reads its JSON. */ +async function hostCli( + page: Page, + target: DockerSshHostTarget, + tabId: string, + command: string +): Promise { + step += 1 + const out = `/tmp/bridge-cli-${step}.json` + const marker = `BRIDGE_${step}_DONE` + // Split so the typed line never contains the marker the wait looks for. + await typeInTerminal( + page, + tabId, + `${command} --json > ${out} 2>&1; printf '%s_%s\\n' BRIDGE_${step} DONE` + ) + await expectPaneText(page, tabId, marker, 90_000) + return JSON.parse(execDockerSshHostTargetCommand(target, `cat ${out}`)) as CliResult +} + +/** Retries until the desktop has attached its bridge, since the attach follows the setting change. */ +async function hostCliUntilOk( + page: Page, + target: DockerSshHostTarget, + tabId: string, + command: string +): Promise { + let last: CliResult | null = null + await expect + .poll( + async () => { + last = await hostCli(page, target, tabId, command) + return last.ok + }, + { timeout: 120_000, intervals: [2_000], message: 'the host CLI never reached the desktop' } + ) + .toBe(true) + return last! +} + +function setRemoteCliControl(page: Page, targetId: string, enabled: boolean): Promise { + return page.evaluate( + ({ targetId, enabled }) => + window.api.ssh.updateTarget({ id: targetId, updates: { allowRemoteCliControl: enabled } }), + { targetId, enabled } + ) +} + +function browserTabUrls(page: Page, worktreeId: string): Promise { + return page.evaluate((id) => { + const state = window.__store?.getState() + return (state?.browserTabsByWorktree[id] ?? []).flatMap((tab) => + (state?.browserPagesByWorkspace[tab.id] ?? []).map((entry) => entry.url) + ) + }, worktreeId) +} + +test('a managed SSH host drives the desktop browser only while its CLI control setting is on', async (// oxlint-disable-next-line no-empty-pattern -- Owns app launch. +{}, testInfo) => { + test.setTimeout(8 * 60_000) + const target = startDockerSshHostTarget(testInfo) + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: ORCAD_TEMPLATE! }) + let app: ElectronApplication | null = null + try { + startSshRemoteOnlyBrowserFixture(target) + // The first launch only creates the profile the SSH host is saved into. + const first = await session.launch() + app = first.app + await waitForSessionReady(first.page) + await session.close(first.app) + app = null + const seeded = seedRelayEraProfile( + session.userDataDir, + { + label: `desktop browser bridge E2E ${Date.now()}`, + host: target.host, + port: target.port, + username: 'root', + identityFile: target.identityFile, + identitiesOnly: true, + relayGracePeriodSeconds: 1 + }, + { repoPath: DOCKER_SSH_HOST_REMOTE_REPO_PATH, folderPath: '/tmp' } + ) + const launched = await session.launch() + app = launched.app + const page = launched.page + await waitForSessionReady(page) + await convertAndRetain(page, session.userDataDir, seeded) + const environmentId = await managedEnvironmentId(page, seeded.targetId) + await activateManagedWorkspace(page, seeded.worktreeId, environmentId) + const tabId = await openTerminalTab(page, seeded.worktreeId) + + // Default off: refused with the setting's name, never "browser unavailable". + const refused = await hostCli(page, target, tabId, 'orca tab list') + expect(refused.ok).toBe(false) + expect(refused.error?.code).toBe('desktop_browser_bridge_unavailable') + expect(refused.error?.message).toContain(DESKTOP_BROWSER_BRIDGE_SETTING_LABEL) + + await setRemoteCliControl(page, seeded.targetId, true) + const createdUrl = `${SSH_REMOTE_ONLY_ORIGIN}/echo/bridge-created` + const created = await hostCliUntilOk(page, target, tabId, `orca tab create --url ${createdUrl}`) + const browserPageId = String(created.result?.browserPageId ?? '') + expect(browserPageId).not.toBe('') + await expect + .poll(() => browserTabUrls(page, seeded.worktreeId), { timeout: 30_000 }) + .toContain(createdUrl) + // Only the SSH host resolves this origin, so the request proves the desktop page egressed there. + await expect + .poll(() => readSshRemoteOnlyRequests(target).map((request) => request.path), { + timeout: 60_000, + message: 'the desktop tab the host opened never loaded through the host' + }) + .toContain('/echo/bridge-created') + + const navigatedUrl = `${SSH_REMOTE_ONLY_ORIGIN}/echo/bridge-navigated` + const navigated = await hostCli( + page, + target, + tabId, + `orca goto --url ${navigatedUrl} --page ${browserPageId}` + ) + expect(navigated.ok).toBe(true) + await expect + .poll(() => readSshRemoteOnlyRequests(target).map((request) => request.path), { + timeout: 60_000, + message: 'the host never navigated the desktop tab' + }) + .toContain('/echo/bridge-navigated') + const listed = await hostCli(page, target, tabId, 'orca tab list') + expect(listed.ok).toBe(true) + expect(JSON.stringify(listed.result)).toContain(browserPageId) + await page.screenshot({ path: testInfo.outputPath('desktop-browser-bridge-on.png') }) + + // Off again: the bridge detaches at once and the host is refused. + await setRemoteCliControl(page, seeded.targetId, false) + await expect + .poll(async () => (await hostCli(page, target, tabId, 'orca tab list')).error?.code ?? 'ok', { + timeout: 60_000, + intervals: [1_000] + }) + .toBe('desktop_browser_bridge_unavailable') + } finally { + try { + if (app) { + await session.close(app) + } + await session.dispose() + } finally { + cleanupDockerSshHostTarget(target) + } + } +}) From 0616d51dc349db78bc4e270219e879b971162d98 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 20:26:08 -0700 Subject: [PATCH 02/15] fix(ssh): scope bridged pages through the renderer, render them as desktop pages, and route the e2e --- config/scripts/ci-e2e-job-selection.test.mjs | 10 +++ config/scripts/pr-e2e-source-routing.mjs | 9 +++ .../desktop-browser-bridge-executor.test.ts | 66 +++++++++++++++++-- .../desktop-browser-bridge-executor.ts | 37 ++++++----- .../desktop-browser-bridge-manager.test.ts | 11 ++++ .../desktop-browser-bridge-manager.ts | 32 +++++---- .../desktop-browser-bridge-renderer-scope.ts | 30 +++++---- src/main/ipc/desktop-browser-bridge-wiring.ts | 18 +++-- .../desktop-browser-bridge-allowlist.test.ts | 5 +- .../desktop-browser-bridge-dispatch.test.ts | 14 ++-- .../api/ui-bridge-state-and-menu-commands.ts | 14 ++-- src/preload/api/ui-command-event-api.ts | 8 ++- .../browser-request-ipc-bridge.owner.test.ts | 51 ++++++++++++-- .../ipc-events/browser-request-ipc-bridge.ts | 19 +++--- .../browser-request-workspace-owner.ts | 23 +++++++ src/shared/desktop-browser-bridge-protocol.ts | 17 ++++- .../ssh-orcad-desktop-browser-bridge.spec.ts | 58 +++++++++------- 17 files changed, 311 insertions(+), 111 deletions(-) diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 7d522b951d95..9a217768bca2 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -50,6 +50,16 @@ it.each([ expect(selectPrE2eSpecs([path])).toContain('tests/e2e/ssh-orcad-terminal-browser-link.spec.ts') }) +it.each([ + 'src/shared/desktop-browser-bridge-protocol.ts', + 'src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-route.ts', + 'src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts', + 'src/main/ipc/desktop-browser-bridge-wiring.ts', + 'src/main/runtime/rpc/methods/desktop-browser-bridge.ts' +])('routes the managed desktop browser bridge oracle from %s', (path) => { + expect(selectPrE2eSpecs([path])).toContain('tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts') +}) + const workflow = parse(readFileSync('.github/workflows/e2e.yml', 'utf8')) const prWorkflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const classify = (specs) => classifyE2eJobs(JSON.stringify(specs)) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index ba15230049d0..ecfc528a1135 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -179,6 +179,15 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) }, + { + id: 'ssh.orcad-desktop-browser-bridge', + specs: ['tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts'], + matches: (file) => + isProductSource(file) && + /^src\/(?:shared\/desktop-browser-bridge-protocol\.ts$|main\/(?:runtime\/desktop-browser-bridge\/|browser\/desktop-browser-bridge\/|ipc\/desktop-browser-bridge-|runtime\/rpc\/methods\/desktop-browser-bridge\.ts$))/.test( + file + ) + }, { id: 'ssh.orcad-browser-drop-owner', specs: ['tests/e2e/ssh-orcad-browser-drop-owner.spec.ts'], diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts index 031af27c0336..aecd921f5e42 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts @@ -12,10 +12,33 @@ import { const HOST_WORKSPACE = 'repo-host::/srv/app' const LOCAL_WORKSPACE = 'repo-local::/Users/me/app' +// What the renderer knows: every page record, mounted or not. const PAGE_OWNERS: Record = { 'host-page': HOST_WORKSPACE, + 'unmounted-host-page': HOST_WORKSPACE, 'local-page': LOCAL_WORKSPACE } +// What main's browser registry knows: only pages whose webview has mounted. +const MOUNTED_PAGE_OWNERS: Record = { + 'host-page': HOST_WORKSPACE, + 'local-page': LOCAL_WORKSPACE +} + +/** The renderer's answer: the page's or named workspace, if this host owns it. */ +async function rendererScope(scope: { + workspaceId: string | null + page: string | null +}): Promise { + const pageWorkspace = scope.page ? (PAGE_OWNERS[scope.page] ?? null) : null + if (scope.page && !pageWorkspace) { + return null + } + if (scope.workspaceId && pageWorkspace && scope.workspaceId !== pageWorkspace) { + return null + } + const workspaceId = pageWorkspace ?? scope.workspaceId + return workspaceId === HOST_WORKSPACE ? workspaceId : null +} function request(overrides: Partial): DesktopBrowserBridgeRequest { return { @@ -33,8 +56,8 @@ function deps(overrides: Partial = {}) { const value: DesktopBrowserBridgeExecutorDeps = { environmentId: 'env-host', hostLabel: 'sandbox', - authorizeWorkspace: async (workspaceId) => workspaceId === HOST_WORKSPACE, - worktreeIdForPage: (page) => PAGE_OWNERS[page] ?? null, + resolveWorkspace: rendererScope, + worktreeIdForPage: (page) => MOUNTED_PAGE_OWNERS[page] ?? null, dispatch: async (method, params) => { // Inside the dispatch, a selector for any other workspace must be refused. const scopeProbe = await resolveRemoteCliBrowserScopeSelector(`id:${LOCAL_WORKSPACE}`)?.catch( @@ -247,15 +270,48 @@ describe('executeDesktopBrowserBridgeRequest', () => { }) it('treats a renderer that cannot answer as a refusal', async () => { - const authorizeWorkspace = vi.fn(async () => false) - const { value, dispatched } = deps({ authorizeWorkspace }) + const resolveWorkspace = vi.fn(async () => null) + const { value, dispatched } = deps({ resolveWorkspace }) await executeDesktopBrowserBridgeRequest( request({ workspaceId: HOST_WORKSPACE, params: { worktree: `id:${HOST_WORKSPACE}` } }), value ) - expect(authorizeWorkspace).toHaveBeenCalledWith(HOST_WORKSPACE) + expect(resolveWorkspace).toHaveBeenCalledWith({ workspaceId: HOST_WORKSPACE, page: null }) + expect(dispatched).toEqual([]) + }) + + it('drives a host page the renderer knows before its webview has mounted', async () => { + const { value, dispatched } = deps() + + const outcome = await executeDesktopBrowserBridgeRequest( + request({ + method: 'browser.goto', + params: { page: 'unmounted-host-page', url: 'https://a.test' } + }), + value + ) + + expect(outcome).toEqual({ ok: true, result: { done: true } }) + expect(dispatched[0]?.params).toEqual({ + page: 'unmounted-host-page', + url: 'https://a.test', + worktree: `id:${HOST_WORKSPACE}` + }) + }) + + it('refuses a page whose mounted webview registered under another workspace', async () => { + const { value, dispatched } = deps({ + worktreeIdForPage: (page) => (page === 'host-page' ? LOCAL_WORKSPACE : null) + }) + + const outcome = await executeDesktopBrowserBridgeRequest( + request({ params: { page: 'host-page' } }), + value + ) + + expect(outcome).toMatchObject({ ok: false, error: { code: 'browser_tab_not_found' } }) expect(dispatched).toEqual([]) }) }) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts index 5319e3741bc5..a5391452d1ab 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts @@ -31,8 +31,15 @@ export type DesktopBrowserBridgeAuditEntry = { export type DesktopBrowserBridgeExecutorDeps = { environmentId: string hostLabel: string - /** True only when this desktop shows `workspaceId` as owned by the environment's own machine. */ - authorizeWorkspace(workspaceId: string): Promise + /** + * The workspace this desktop shows as owned by the environment's own machine for the named page + * and/or workspace, or null. A page that has not mounted yet is known only to the renderer. + */ + resolveWorkspace(scope: { + workspaceId: string | null + page: string | null + }): Promise + /** The workspace a mounted page registered under, if it has mounted. */ worktreeIdForPage(browserPageId: string): string | null dispatch(method: string, params: Record): Promise audit(entry: DesktopBrowserBridgeAuditEntry): void @@ -61,12 +68,12 @@ export async function executeDesktopBrowserBridgeRequest( request: DesktopBrowserBridgeRequest, deps: DesktopBrowserBridgeExecutorDeps ): Promise { - const audit = { + const audit: Omit = { environmentId: deps.environmentId, hostLabel: deps.hostLabel, method: request.method, - workspaceId: null as string | null, - page: null as string | null, + workspaceId: null, + page: null, urlOrigin: urlOrigin(request.params.url) } try { @@ -110,25 +117,23 @@ async function scopeRequest( delete params[key] } } - let workspaceId = request.workspaceId ?? null - if (params.worktree !== undefined && (!workspaceId || params.worktree !== `id:${workspaceId}`)) { + const requestedWorkspaceId = request.workspaceId ?? null + if ( + params.worktree !== undefined && + (!requestedWorkspaceId || params.worktree !== `id:${requestedWorkspaceId}`) + ) { throw outOfScope() } const page = typeof params.page === 'string' && params.page.length > 0 ? params.page : null - if (page) { - const pageWorkspaceId = deps.worktreeIdForPage(page) - if (!pageWorkspaceId || (workspaceId && pageWorkspaceId !== workspaceId)) { - throw new BridgeRefusal('browser_tab_not_found', `Browser page ${page} was not found`) - } - workspaceId = pageWorkspaceId - } - if (!workspaceId) { + if (!requestedWorkspaceId && !page) { throw new BridgeRefusal( DESKTOP_BROWSER_BRIDGE_SCOPE_CODE, 'Run this command inside a workspace on this host, or pass --worktree, so Orca knows which workspace’s browser tabs it may use.' ) } - if (!(await deps.authorizeWorkspace(workspaceId))) { + const workspaceId = await deps.resolveWorkspace({ workspaceId: requestedWorkspaceId, page }) + const mountedWorkspaceId = page ? deps.worktreeIdForPage(page) : null + if (!workspaceId || (mountedWorkspaceId && mountedWorkspaceId !== workspaceId)) { // Why the same refusal for a foreign page: an unknown and a forbidden page must look alike. throw page ? new BridgeRefusal('browser_tab_not_found', `Browser page ${page} was not found`) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts index 10617527527f..d62fccd2af13 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts @@ -108,6 +108,17 @@ describe('DesktopBrowserBridgeManager', () => { expect(h.timers).toEqual([expect.objectContaining({ delayMs: 5 * 60_000 })]) }) + it('retries soon when the server could not answer, instead of treating it as old', async () => { + const h = harness() + h.supportsBridge.mockRejectedValueOnce(new Error('runtime_unavailable')) + h.manager.reconcile() + await vi.waitFor(() => expect(h.timers).toHaveLength(1)) + + expect(h.timers[0]!.delayMs).toBe(2_000) + h.timers[0]!.run() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + }) + it('detaches at once when the setting turns off or the host disconnects', async () => { const h = harness() h.manager.reconcile() diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.ts index 65842b5f2adf..10524cdcacc7 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.ts @@ -20,6 +20,7 @@ export type DesktopBrowserBridgeCandidate = { export type DesktopBrowserBridgeManagerDeps = { /** The hosts that may attach now: setting on, SSH connected, managed environment linked. */ listCandidates(): DesktopBrowserBridgeCandidate[] + /** False when the server answers without the capability; throws when it could not answer. */ supportsBridge(environmentId: string): Promise subscribe(environmentId: string): DesktopBrowserBridgeSubscribe execute( @@ -96,11 +97,15 @@ export class DesktopBrowserBridgeManager { return } entry.starting = true - let supported = false + let supported: boolean try { supported = await this.deps.supportsBridge(environmentId) - } catch { - supported = false + } catch (error) { + // Why not "unsupported": a server that did not answer yet (tunnel rebuilding) says nothing about its version. + if (this.entries.get(environmentId) === entry) { + this.retryAfterFailure(environmentId, entry, error) + } + return } finally { entry.starting = false } @@ -123,21 +128,24 @@ export class DesktopBrowserBridgeManager { return } entry.connection = null - entry.failures += 1 - this.deps.log( - `lost: host=${entry.candidate.hostLabel} env=${environmentId} (${error.message})` - ) - this.scheduleRetry( - environmentId, - entry, - Math.min(MAX_RETRY_MS, MIN_RETRY_MS * 2 ** Math.min(entry.failures - 1, 5)) - ) + this.retryAfterFailure(environmentId, entry, error) } }) entry.connection = connection await connection.start() } + private retryAfterFailure(environmentId: string, entry: Entry, error: unknown): void { + entry.failures += 1 + const message = error instanceof Error ? error.message : String(error) + this.deps.log(`lost: host=${entry.candidate.hostLabel} env=${environmentId} (${message})`) + this.scheduleRetry( + environmentId, + entry, + Math.min(MAX_RETRY_MS, MIN_RETRY_MS * 2 ** Math.min(entry.failures - 1, 5)) + ) + } + private scheduleRetry(environmentId: string, entry: Entry, delayMs: number): void { if (this.disposed || this.entries.get(environmentId) !== entry || entry.retryTimer) { return diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-renderer-scope.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-renderer-scope.ts index b612f88671e8..8a8b00b0076c 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-renderer-scope.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-renderer-scope.ts @@ -4,24 +4,24 @@ import { ipcMain, type BrowserWindow } from 'electron' const SCOPE_REPLY_TIMEOUT_MS = 5_000 /** - * Asks the renderer, which owns the workspace-to-host map, whether `environmentId`'s own machine - * owns `workspaceId`. No window, a timeout or a malformed reply all answer no. + * Asks the renderer, which owns the workspace-to-host map and every page record (mounted or not), + * which workspace of `environmentId`'s own machine a command may use. No window, a timeout or a + * malformed reply all answer null. */ -export function rendererAuthorizesRemoteCliWorkspace( +export function rendererResolveRemoteCliWorkspace( getWindow: () => BrowserWindow | null, - environmentId: string, - workspaceId: string -): Promise { + query: { environmentId: string; workspaceId?: string; pageId?: string } +): Promise { const win = getWindow() if (!win || win.isDestroyed()) { - return Promise.resolve(false) + return Promise.resolve(null) } const requestId = randomUUID() - return new Promise((resolve) => { - const finish = (allowed: boolean): void => { + return new Promise((resolve) => { + const finish = (workspaceId: string | null): void => { clearTimeout(timer) ipcMain.removeListener('browser:remoteCliScopeReply', handler) - resolve(allowed) + resolve(workspaceId) } const handler = (event: Electron.IpcMainEvent, reply: unknown): void => { if ( @@ -33,10 +33,14 @@ export function rendererAuthorizesRemoteCliWorkspace( ) { return } - finish('allowed' in reply && reply.allowed === true) + finish( + 'workspaceId' in reply && typeof reply.workspaceId === 'string' && reply.workspaceId + ? reply.workspaceId + : null + ) } - const timer = setTimeout(() => finish(false), SCOPE_REPLY_TIMEOUT_MS) + const timer = setTimeout(() => finish(null), SCOPE_REPLY_TIMEOUT_MS) ipcMain.on('browser:remoteCliScopeReply', handler) - win.webContents.send('browser:requestRemoteCliScope', { requestId, environmentId, workspaceId }) + win.webContents.send('browser:requestRemoteCliScope', { requestId, ...query }) }) } diff --git a/src/main/ipc/desktop-browser-bridge-wiring.ts b/src/main/ipc/desktop-browser-bridge-wiring.ts index 5639e7aa3a20..5697b50cc434 100644 --- a/src/main/ipc/desktop-browser-bridge-wiring.ts +++ b/src/main/ipc/desktop-browser-bridge-wiring.ts @@ -22,7 +22,7 @@ import { type DesktopBrowserBridgeCandidate } from '../browser/desktop-browser-bridge/desktop-browser-bridge-manager' import { executeDesktopBrowserBridgeRequest } from '../browser/desktop-browser-bridge/desktop-browser-bridge-executor' -import { rendererAuthorizesRemoteCliWorkspace } from '../browser/desktop-browser-bridge/desktop-browser-bridge-renderer-scope' +import { rendererResolveRemoteCliWorkspace } from '../browser/desktop-browser-bridge/desktop-browser-bridge-renderer-scope' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { RpcDispatcher } from '../runtime/rpc/dispatcher' import { ALL_RPC_METHODS } from '../runtime/rpc/methods' @@ -100,10 +100,10 @@ export function installDesktopBrowserBridge(options: { const status = await getRuntimeEnvironmentStatus(userDataPath, environmentId, undefined, { observeOnly: true }) - return ( - status.ok === true && - (status.result.capabilities ?? []).includes(DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY) - ) + if (!status.ok) { + throw new Error(status.error.code) + } + return (status.result.capabilities ?? []).includes(DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY) }, subscribe: (environmentId) => async (callbacks) => { await ensureOrcadManagedTunnel(userDataPath, environmentId) @@ -132,8 +132,12 @@ export function installDesktopBrowserBridge(options: { executeDesktopBrowserBridgeRequest(request, { environmentId: candidate.environmentId, hostLabel: candidate.hostLabel, - authorizeWorkspace: (workspaceId) => - rendererAuthorizesRemoteCliWorkspace(getMainWindow, candidate.environmentId, workspaceId), + resolveWorkspace: (scope) => + rendererResolveRemoteCliWorkspace(getMainWindow, { + environmentId: candidate.environmentId, + ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), + ...(scope.page ? { pageId: scope.page } : {}) + }), worktreeIdForPage: (browserPageId) => browserManager.getWorktreeIdForTab(browserPageId) ?? null, dispatch: (method, params) => diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-allowlist.test.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-allowlist.test.ts index c7ee3a81c547..81e4de526d0f 100644 --- a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-allowlist.test.ts +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-allowlist.test.ts @@ -3,7 +3,9 @@ import { DESKTOP_BROWSER_BRIDGE_METHODS } from '../../../shared/desktop-browser- import { ALL_RPC_METHODS } from '../rpc/methods' describe('desktop browser bridge allowlist', () => { - const registered = new Map(ALL_RPC_METHODS.map((method) => [method.name, method])) + const registered = new Map( + ALL_RPC_METHODS.map((method) => [method.name, method]) + ) it('names only real one-shot browser methods in the workspace tier', () => { for (const name of DESKTOP_BROWSER_BRIDGE_METHODS) { @@ -28,6 +30,7 @@ describe('desktop browser bridge allowlist', () => { 'browser.cookie.get', 'browser.cookie.set', 'browser.cookie.delete', + 'browser.network', 'browser.clipboardRead', 'browser.clipboardWrite', 'browser.storage.local.get', diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-dispatch.test.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-dispatch.test.ts index 4eb85aa53846..79725b7dcbe5 100644 --- a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-dispatch.test.ts +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-dispatch.test.ts @@ -1,5 +1,6 @@ import '../rpc/unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' import { DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY, DESKTOP_BROWSER_BRIDGE_SETTING_LABEL @@ -16,6 +17,9 @@ import { } from './desktop-browser-bridge-registry' import { routeDesktopBrowserBridgeRpc } from './desktop-browser-bridge-route' +const ReadyEvent = z.object({ type: z.literal('ready'), attachmentId: z.string() }) +const ForwardedEvent = z.object({ requestId: z.string() }).passthrough() + const localGoto = vi.fn(async () => ({ url: 'local', title: '' })) const localCookie = vi.fn(async () => ({ cookies: [] })) @@ -105,20 +109,14 @@ describe('desktop browser bridge on orcad', () => { const { runtime } = orcadRuntime() const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) const { events } = await attachDesktop(dispatcher) - const ready = events[0] as { type: string; attachmentId: string } - expect(ready.type).toBe('ready') + const ready = ReadyEvent.parse(events[0]) const call = cliCall(dispatcher, 'browser.goto', { url: 'https://example.com', worktree: 'path:/srv/app' }) await vi.waitFor(() => expect(events).toHaveLength(2)) - const forwarded = events[1] as { - requestId: string - method: string - workspaceId: string - params: Record - } + const forwarded = ForwardedEvent.parse(events[1]) // orcad resolves its own selector and pins the forwarded call to that workspace id. expect(forwarded).toMatchObject({ type: 'request', diff --git a/src/preload/api/ui-bridge-state-and-menu-commands.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts index 362fe0851892..c4da525cecc3 100644 --- a/src/preload/api/ui-bridge-state-and-menu-commands.ts +++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts @@ -3,6 +3,10 @@ import { ipcRenderer } from 'electron' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { KeybindingActionId } from '../../shared/keybindings' import type { PreloadApi } from '../api-types' +import type { + RemoteCliBrowserScopeQuery, + RemoteCliBrowserScopeReply +} from '../../shared/desktop-browser-bridge-protocol' export const uiStateAndMenuCommandsApi = { getJiraQuickFilters: () => ipcRenderer.invoke('ui:getJiraQuickFilters'), @@ -179,16 +183,14 @@ export const uiStateAndMenuCommandsApi = { ipcRenderer.send('browser:tabCreateReply', reply) }, onRequestRemoteCliBrowserScope: ( - callback: (data: { requestId: string; environmentId: string; workspaceId: string }) => void + callback: (data: RemoteCliBrowserScopeQuery) => void ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { requestId: string; environmentId: string; workspaceId: string } - ) => callback(data) + const listener = (_event: Electron.IpcRendererEvent, data: RemoteCliBrowserScopeQuery) => + callback(data) ipcRenderer.on('browser:requestRemoteCliScope', listener) return () => ipcRenderer.removeListener('browser:requestRemoteCliScope', listener) }, - replyRemoteCliBrowserScope: (reply: { requestId: string; allowed: boolean }): void => { + replyRemoteCliBrowserScope: (reply: RemoteCliBrowserScopeReply): void => { ipcRenderer.send('browser:remoteCliScopeReply', reply) } } satisfies Partial diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 20500813e2de..839f3c271228 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -56,6 +56,10 @@ import type { WorktreeSleepRequest, WorktreeSleepResponse } from '../../shared/worktree-sleep-request' +import type { + RemoteCliBrowserScopeQuery, + RemoteCliBrowserScopeReply +} from '../../shared/desktop-browser-bridge-protocol' export type CloseActiveTabPayload = { sourceId: string } @@ -118,9 +122,9 @@ export type UiCommandEventApi = { replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }) => void /** Main asks whether a paired environment owns a workspace before running that host's CLI command. */ onRequestRemoteCliBrowserScope: ( - callback: (data: { requestId: string; environmentId: string; workspaceId: string }) => void + callback: (data: RemoteCliBrowserScopeQuery) => void ) => () => void - replyRemoteCliBrowserScope: (reply: { requestId: string; allowed: boolean }) => void + replyRemoteCliBrowserScope: (reply: RemoteCliBrowserScopeReply) => void onRequestTabSetProfile: ( callback: (data: { requestId: string diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts index 010791d35a8f..a441b7476a8b 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts @@ -3,7 +3,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' type CreateRequest = { requestId: string; worktreeId?: string; url: string } type CloseRequest = { requestId: string; tabId?: string | null; worktreeId?: string } -type ScopeRequest = { requestId: string; environmentId: string; workspaceId: string } +type ScopeRequest = { + requestId: string + environmentId: string + workspaceId?: string + pageId?: string +} const mocks = vi.hoisted(() => { const listeners: { @@ -115,6 +120,19 @@ describe('browser requests from this desktop follow the workspace owner (#22275) expect(mocks.replyTabCreate).toHaveBeenCalledWith({ requestId: 'r1', browserPageId: 'ws-new' }) }) + it("renders a page named in a managed host's workspace on this desktop (#29145)", () => { + mocks.getState.mockReturnValue(storeState(LOCAL_WT)) + + mocks.createListener?.({ requestId: 'r3', worktreeId: REMOTE_WT, url: 'https://a.test' }) + + // A missing runtime id would be inferred from the workspace and stream the server's page instead. + expect(mocks.createBrowserTab).toHaveBeenCalledWith( + REMOTE_WT, + 'https://a.test', + expect.objectContaining({ browserRuntimeEnvironmentId: null }) + ) + }) + it("refuses to fall back to a paired server's active workspace", () => { mocks.getState.mockReturnValue(storeState(REMOTE_WT)) @@ -175,10 +193,31 @@ describe('a managed SSH host reaches only its own workspaces on this desktop (#2 mocks.scopeListener?.({ requestId: 's4', environmentId: 'env-remote', workspaceId: 'nope::/x' }) expect(mocks.replyRemoteCliBrowserScope.mock.calls.map(([reply]) => reply)).toEqual([ - { requestId: 's1', allowed: true }, - { requestId: 's2', allowed: false }, - { requestId: 's3', allowed: false }, - { requestId: 's4', allowed: false } + { requestId: 's1', workspaceId: REMOTE_WT }, + { requestId: 's2', workspaceId: null }, + { requestId: 's3', workspaceId: null }, + { requestId: 's4', workspaceId: null } + ]) + }) + + it('resolves a page to its workspace, and refuses a page of another workspace', () => { + mocks.getState.mockReturnValue(storeState(LOCAL_WT)) + + mocks.scopeListener?.({ requestId: 'p1', environmentId: 'env-remote', pageId: 'page-remote' }) + mocks.scopeListener?.({ requestId: 'p2', environmentId: 'env-remote', pageId: 'page-local' }) + mocks.scopeListener?.({ + requestId: 'p3', + environmentId: 'env-remote', + pageId: 'page-local', + workspaceId: REMOTE_WT + }) + mocks.scopeListener?.({ requestId: 'p4', environmentId: 'env-remote', pageId: 'missing' }) + + expect(mocks.replyRemoteCliBrowserScope.mock.calls.map(([reply]) => reply)).toEqual([ + { requestId: 'p1', workspaceId: REMOTE_WT }, + { requestId: 'p2', workspaceId: null }, + { requestId: 'p3', workspaceId: null }, + { requestId: 'p4', workspaceId: null } ]) }) @@ -191,7 +230,7 @@ describe('a managed SSH host reaches only its own workspaces on this desktop (#2 expect(mocks.replyRemoteCliBrowserScope).toHaveBeenCalledWith({ requestId: 's5', - allowed: false + workspaceId: null }) }) }) diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts index 09a703f749d9..d184fac67153 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts @@ -9,8 +9,8 @@ import { useAppStore } from '../../store' import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' import { findBrowserTabWorktreeId, - isBrowserWorkspaceOwnedByEnvironment, - isBrowserWorkspaceOwnedByPairedServer + isBrowserWorkspaceOwnedByPairedServer, + resolveRemoteCliBrowserWorkspace } from './browser-request-workspace-owner' function pairedServerWorkspaceError(): string { @@ -25,18 +25,14 @@ function pairedServerWorkspaceError(): string { export function registerBrowserRequestIpcBridge(unsubs: (() => void)[]): void { unsubs.push( window.api.ui.onRequestRemoteCliBrowserScope((data) => { - let allowed = false + let workspaceId: string | null = null try { - allowed = isBrowserWorkspaceOwnedByEnvironment( - useAppStore.getState(), - data.workspaceId, - data.environmentId - ) + workspaceId = resolveRemoteCliBrowserWorkspace(useAppStore.getState(), data) } catch { // Why: an owner this window cannot prove is a refusal, never a pass. - allowed = false + workspaceId = null } - window.api.ui.replyRemoteCliBrowserScope({ requestId: data.requestId, allowed }) + window.api.ui.replyRemoteCliBrowserScope({ requestId: data.requestId, workspaceId }) }) ) unsubs.push( @@ -77,6 +73,9 @@ export function registerBrowserRequestIpcBridge(unsubs: (() => void)[]): void { targetGroupId: data.activate ? undefined : activeBrowserUnifiedTab?.groupId, sessionProfileId: data.sessionProfileId, sessionPartition: data.sessionPartition, + // Why null, not inferred: this desktop's runtime only ever asks for a page this desktop + // renders, including one in a managed SSH host's workspace (as the tab bar's + does). + browserRuntimeEnvironmentId: null, activate: data.activate === true }) // Why: registerGuest fires with the page ID, not the workspace ID; return it so waitForTabRegistration can correlate. diff --git a/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts b/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts index 212b50aad9da..23ed01aa9529 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts @@ -36,6 +36,29 @@ export function isBrowserWorkspaceOwnedByEnvironment( ) } +/** + * The workspace a managed SSH host's forwarded command may use: the named page's workspace (it may + * not have mounted yet, so main cannot know it), or the named workspace, owned by that host. + */ +export function resolveRemoteCliBrowserWorkspace( + state: WorktreeOperationRouteState & + Pick, + query: { environmentId: string; workspaceId?: string; pageId?: string } +): string | null { + const pageWorkspaceId = query.pageId ? findBrowserTabWorktreeId(state, query.pageId) : null + if (query.pageId && !pageWorkspaceId) { + return null + } + if (query.workspaceId && pageWorkspaceId && query.workspaceId !== pageWorkspaceId) { + return null + } + const workspaceId = pageWorkspaceId ?? query.workspaceId ?? null + return workspaceId && + isBrowserWorkspaceOwnedByEnvironment(state, workspaceId, query.environmentId) + ? workspaceId + : null +} + /** The workspace holding a browser tab, addressed by its tab (workspace) id or one of its page ids. */ export function findBrowserTabWorktreeId( state: Pick, diff --git a/src/shared/desktop-browser-bridge-protocol.ts b/src/shared/desktop-browser-bridge-protocol.ts index 89dd8002ef29..96372c4d1f4e 100644 --- a/src/shared/desktop-browser-bridge-protocol.ts +++ b/src/shared/desktop-browser-bridge-protocol.ts @@ -21,8 +21,9 @@ export const DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_MESSAGE = `No Orca desktop is sh /** * The only commands a host may run on the desktop browser. Narrower than the relay bridge before * 1.4.225, which ran any CLI command on the desktop: nothing here reads or writes desktop files - * (upload, download, pdf), reads the desktop clipboard or cookie jar, runs raw agent-browser - * commands, changes browser profiles or identity, or bypasses certificate errors. + * (upload, download, pdf), reads the desktop clipboard, cookie jar or request headers (network), + * runs raw agent-browser commands, changes browser profiles or identity, or bypasses certificate + * errors. */ export const DESKTOP_BROWSER_BRIDGE_METHODS: ReadonlySet = new Set([ 'browser.tabList', @@ -44,7 +45,6 @@ export const DESKTOP_BROWSER_BRIDGE_METHODS: ReadonlySet = new Set([ 'browser.wait', 'browser.eval', 'browser.console', - 'browser.network', 'browser.click', 'browser.dblclick', 'browser.fill', @@ -71,6 +71,17 @@ export const DESKTOP_BROWSER_BRIDGE_METHODS: ReadonlySet = new Set([ 'browser.dialogDismiss' ]) +/** Main asks the renderer, which owns the workspace-to-host map, what a host may reach. */ +export type RemoteCliBrowserScopeQuery = { + requestId: string + environmentId: string + workspaceId?: string + pageId?: string +} + +/** The workspace the host may use for this command, or null to refuse it. */ +export type RemoteCliBrowserScopeReply = { requestId: string; workspaceId: string | null } + export function isDesktopBrowserBridgeMethod(method: string): boolean { return DESKTOP_BROWSER_BRIDGE_METHODS.has(method) } diff --git a/tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts b/tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts index 9c107fecb89f..e8234948c9f3 100644 --- a/tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts +++ b/tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts @@ -7,7 +7,8 @@ * the fixture origin) and `orca goto` navigates it. Off again, the host is refused at once. */ import type { ElectronApplication, Page } from '@stablyai/playwright-test' -import { expect, test } from './helpers/orca-app' +import { z } from 'zod' +import { expect, forwardElectronProcessLogs, test } from './helpers/orca-app' import { createRestartSession } from './helpers/orca-restart' import { waitForSessionReady } from './helpers/store' import { @@ -37,11 +38,12 @@ import { DESKTOP_BROWSER_BRIDGE_SETTING_LABEL } from '../../src/shared/desktop-b skipUnlessManagedDockerHost() -type CliResult = { - ok: boolean - result?: Record - error?: { code: string; message: string } -} +const CliResult = z.object({ + ok: z.boolean(), + result: z.record(z.string(), z.unknown()).optional(), + error: z.object({ code: z.string(), message: z.string() }).optional() +}) +type CliResult = z.infer let step = 0 @@ -62,7 +64,7 @@ async function hostCli( `${command} --json > ${out} 2>&1; printf '%s_%s\\n' BRIDGE_${step} DONE` ) await expectPaneText(page, tabId, marker, 90_000) - return JSON.parse(execDockerSshHostTargetCommand(target, `cat ${out}`)) as CliResult + return CliResult.parse(JSON.parse(execDockerSshHostTargetCommand(target, `cat ${out}`))) } /** Retries until the desktop has attached its bridge, since the attach follows the setting change. */ @@ -72,17 +74,22 @@ async function hostCliUntilOk( tabId: string, command: string ): Promise { - let last: CliResult | null = null - await expect - .poll( - async () => { - last = await hostCli(page, target, tabId, command) - return last.ok - }, - { timeout: 120_000, intervals: [2_000], message: 'the host CLI never reached the desktop' } - ) - .toBe(true) - return last! + const results: CliResult[] = [] + try { + await expect + .poll( + async () => { + const result = await hostCli(page, target, tabId, command) + results.push(result) + return result.ok + }, + { timeout: 120_000, intervals: [2_000], message: 'the host CLI never reached the desktop' } + ) + .toBe(true) + } catch (error) { + throw new Error(`${String(error)}\nlast CLI result: ${JSON.stringify(results.at(-1))}`) + } + return results.at(-1)! } function setRemoteCliControl(page: Page, targetId: string, enabled: boolean): Promise { @@ -131,6 +138,7 @@ test('a managed SSH host drives the desktop browser only while its CLI control s ) const launched = await session.launch() app = launched.app + forwardElectronProcessLogs(app, testInfo) const page = launched.page await waitForSessionReady(page) await convertAndRetain(page, session.userDataDir, seeded) @@ -159,21 +167,27 @@ test('a managed SSH host drives the desktop browser only while its CLI control s message: 'the desktop tab the host opened never loaded through the host' }) .toContain('/echo/bridge-created') - + // Like an agent: navigate the tab it opened, then read it back. const navigatedUrl = `${SSH_REMOTE_ONLY_ORIGIN}/echo/bridge-navigated` - const navigated = await hostCli( + await hostCliUntilOk( page, target, tabId, `orca goto --url ${navigatedUrl} --page ${browserPageId}` ) - expect(navigated.ok).toBe(true) await expect .poll(() => readSshRemoteOnlyRequests(target).map((request) => request.path), { timeout: 60_000, - message: 'the host never navigated the desktop tab' + message: 'the desktop tab the host drove never loaded through the host' }) .toContain('/echo/bridge-navigated') + const snapshot = await hostCliUntilOk( + page, + target, + tabId, + `orca snapshot --page ${browserPageId}` + ) + expect(JSON.stringify(snapshot.result)).toContain('cookie:') const listed = await hostCli(page, target, tabId, 'orca tab list') expect(listed.ok).toBe(true) expect(JSON.stringify(listed.result)).toContain(browserPageId) From cbe1ffe563608b6e8825369166729811879d5e9d Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 20:39:51 -0700 Subject: [PATCH 03/15] fix(ssh): bridge refuses file URLs and clipboard reads, revokes on disconnect, closes desktop tabs --- ...browser-session-partition-policies.test.ts | 37 +++++- .../browser-session-partition-policies.ts | 15 ++- .../desktop-browser-bridge-connection.ts | 9 +- .../desktop-browser-bridge-executor.test.ts | 117 +++++++++++++++++- .../desktop-browser-bridge-executor.ts | 66 +++++++++- .../desktop-browser-bridge-manager.test.ts | 44 ++++++- .../desktop-browser-bridge-manager.ts | 11 +- .../remote-cli-driven-workspaces.ts | 25 ++++ .../ipc/desktop-browser-bridge-reconcile.ts | 24 +++- src/main/ipc/desktop-browser-bridge-wiring.ts | 47 ++++--- .../runtime-environment-manual-disconnect.ts | 8 ++ src/main/ipc/runtime-environments.ts | 2 + .../browser-request-ipc-bridge.owner.test.ts | 16 +++ .../ipc-events/browser-request-ipc-bridge.ts | 7 +- .../browser-request-workspace-owner.ts | 19 +++ .../ssh-orcad-desktop-browser-bridge.spec.ts | 13 ++ 16 files changed, 424 insertions(+), 36 deletions(-) create mode 100644 src/main/browser/desktop-browser-bridge/remote-cli-driven-workspaces.ts diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts index 4c86e1067f33..0928fb1e70b5 100644 --- a/src/main/browser/browser-session-partition-policies.test.ts +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -5,7 +5,8 @@ const mocks = vi.hoisted(() => ({ handleGuestWillDownload: vi.fn(), noticeDocPreviewDownloadBlocked: vi.fn(), clearBrowserWebAuthnAccessHandlers: vi.fn(), - installBrowserWebAuthnAccessHandlers: vi.fn() + installBrowserWebAuthnAccessHandlers: vi.fn(), + isRemoteCliDrivenGuest: vi.fn((_webContentsId?: number) => false) })) type WillDownloadListener = ( @@ -66,6 +67,9 @@ vi.mock('electron', () => ({ } } })) +vi.mock('./desktop-browser-bridge/remote-cli-driven-workspaces', () => ({ + isRemoteCliDrivenGuest: mocks.isRemoteCliDrivenGuest +})) vi.mock('./browser-manager', () => ({ browserManager: { handleGuestWillDownload: mocks.handleGuestWillDownload, @@ -195,6 +199,37 @@ describe('partition permission policy', () => { expect(mocks.clearBrowserWebAuthnAccessHandlers).not.toHaveBeenCalled() }) + it('denies clipboard reads only to pages an SSH host has driven (#29145)', async () => { + mocks.isRemoteCliDrivenGuest.mockImplementation((id?: number) => id === 7) + const install = await loadInstaller() + install(profileFor('persist:browsing-2')) + const sess = sessionsByPartition.get('persist:browsing-2') + if (!sess) { + throw new Error('Expected the browsing session') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installer registered exactly this handler on the fake session. + const requestHandler = sess.setPermissionRequestHandler.mock.calls[0]?.[0] as ( + webContents: { id: number; getURL: () => string }, + permission: string, + callback: (allowed: boolean) => void + ) => void + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installer registered exactly this handler on the fake session. + const checkHandler = sess.setPermissionCheckHandler.mock.calls[0]?.[0] as ( + webContents: { id: number }, + permission: string + ) => boolean + const guest = (id: number) => ({ id, getURL: () => 'https://a.test/' }) + + const decisions: boolean[] = [] + requestHandler(guest(7), 'clipboard-read', (allowed) => decisions.push(allowed)) + requestHandler(guest(8), 'clipboard-read', (allowed) => decisions.push(allowed)) + requestHandler(guest(7), 'clipboard-sanitized-write', (allowed) => decisions.push(allowed)) + + expect(decisions).toEqual([false, true, true]) + expect(checkHandler({ id: 7 }, 'clipboard-read')).toBe(false) + expect(checkHandler({ id: 8 }, 'clipboard-read')).toBe(true) + }) + it('denies every request and check on a strict partition without WebAuthn handlers', async () => { const install = await loadInstaller() install(profileFor('orca-doc-preview'), { permissions: 'deny' }) diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index d55dbc30d78c..a38f185cae6c 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -17,6 +17,7 @@ import { installBrowserWebAuthnAccessHandlers } from './browser-webauthn-access' import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice' +import { isRemoteCliDrivenGuest } from './desktop-browser-bridge/remote-cli-driven-workspaces' // Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart. const configuredPartitions = new Set() @@ -48,6 +49,11 @@ function configureBrowserSessionUserAgentPolicy(sess: Session, installExceptions ) } +// Why: an SSH host scripting a page must not read the desktop clipboard through it. +function deniesRemoteCliDrivenClipboardRead(permission: string, webContentsId?: number): boolean { + return permission === 'clipboard-read' && isRemoteCliDrivenGuest(webContentsId) +} + /** Drop only the installer memo; retired-session guards remain fail-closed. */ export function forgetBrowserSessionPartitionConfiguration(partition: string): void { configuredPartitions.delete(partition) @@ -159,7 +165,9 @@ export async function installBrowserSessionPartitionPolicies( ) return } - const allowed = isAutoGrantedBrowserSessionPermission(permission) + const allowed = + isAutoGrantedBrowserSessionPermission(permission) && + !deniesRemoteCliDrivenClipboardRead(permission, webContents?.id) if (!allowed) { const rawUrl = resolvePermissionNoticeUrl(webContents, details) browserManager.notifyPermissionDenied({ @@ -170,7 +178,10 @@ export async function installBrowserSessionPartitionPolicies( } callback(allowed) }) - sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => { + sess.setPermissionCheckHandler((webContents, permission, _origin, details) => { + if (deniesRemoteCliDrivenClipboardRead(permission, webContents?.id)) { + return false + } if (permission === 'media') { return hasSystemMediaAccess(details?.mediaType) } diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts index 7c8072214e42..59b0bd09a689 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts @@ -24,11 +24,15 @@ export class DesktopBrowserBridgeConnection { private subscription: RemoteRuntimeSubscription | null = null private attachmentId: string | null = null private closed = false + private readonly revoked = new AbortController() constructor( private readonly options: { subscribe: DesktopBrowserBridgeSubscribe - execute(request: DesktopBrowserBridgeRequest): Promise + execute( + request: DesktopBrowserBridgeRequest, + signal: AbortSignal + ): Promise onReady(): void /** Called once, when the attachment ends for any reason other than close(). */ onLost(error: Error): void @@ -63,6 +67,7 @@ export class DesktopBrowserBridgeConnection { return } this.closed = true + this.revoked.abort() this.attachmentId = null const subscription = this.subscription this.subscription = null @@ -98,7 +103,7 @@ export class DesktopBrowserBridgeConnection { } private async answer(attachmentId: string, request: DesktopBrowserBridgeRequest): Promise { - const outcome = await this.options.execute(request) + const outcome = await this.options.execute(request, this.revoked.signal) const sendRequest = this.subscription?.sendRequest if (this.closed || this.attachmentId !== attachmentId || !sendRequest) { return diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts index aecd921f5e42..98eb16a8e828 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts @@ -52,12 +52,14 @@ function request(overrides: Partial): DesktopBrowse function deps(overrides: Partial = {}) { const audit: DesktopBrowserBridgeAuditEntry[] = [] + const driven: string[] = [] const dispatched: { method: string; params: Record; scopeProbe: unknown }[] = [] const value: DesktopBrowserBridgeExecutorDeps = { environmentId: 'env-host', hostLabel: 'sandbox', resolveWorkspace: rendererScope, worktreeIdForPage: (page) => MOUNTED_PAGE_OWNERS[page] ?? null, + markWorkspaceDriven: (workspaceId) => driven.push(workspaceId), dispatch: async (method, params) => { // Inside the dispatch, a selector for any other workspace must be refused. const scopeProbe = await resolveRemoteCliBrowserScopeSelector(`id:${LOCAL_WORKSPACE}`)?.catch( @@ -69,7 +71,7 @@ function deps(overrides: Partial = {}) { audit: (entry) => audit.push(entry), ...overrides } - return { value, audit, dispatched } + return { value, audit, dispatched, driven } } describe('executeDesktopBrowserBridgeRequest', () => { @@ -314,6 +316,119 @@ describe('executeDesktopBrowserBridgeRequest', () => { expect(outcome).toMatchObject({ ok: false, error: { code: 'browser_tab_not_found' } }) expect(dispatched).toEqual([]) }) + + it('marks the workspace it drives so its pages lose programmatic clipboard reads', async () => { + const { value, driven } = deps() + + await executeDesktopBrowserBridgeRequest( + request({ workspaceId: HOST_WORKSPACE, params: { worktree: `id:${HOST_WORKSPACE}` } }), + value + ) + + expect(driven).toEqual([HOST_WORKSPACE]) + }) + + it.each([ + 'file:///etc/passwd', + '/etc/passwd', + 'localhost:3000', + 'javascript:alert(1)', + 'chrome://settings', + 'data:text/html,hi' + ])('refuses to navigate or open %s', async (url) => { + const { value, dispatched } = deps() + + for (const method of ['browser.goto', 'browser.tabCreate']) { + const outcome = await executeDesktopBrowserBridgeRequest( + request({ + method, + workspaceId: HOST_WORKSPACE, + params: { url, worktree: `id:${HOST_WORKSPACE}` } + }), + value + ) + expect(outcome).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + } + expect(dispatched).toEqual([]) + }) + + it('opens http(s) URLs and a blank tab', async () => { + const { value, dispatched } = deps() + + for (const params of [{ url: 'http://localhost:3000/a' }, { url: 'https://a.test' }, {}]) { + await executeDesktopBrowserBridgeRequest( + request({ + method: 'browser.tabCreate', + workspaceId: HOST_WORKSPACE, + params: { ...params, worktree: `id:${HOST_WORKSPACE}` } + }), + value + ) + } + + expect(dispatched).toHaveLength(3) + }) + + it.each(['Control+v', 'Meta+V', 'cmd + v', 'Shift+Insert', 'Control+Shift+V'])( + 'refuses the paste chord %s', + async (key) => { + const { value, dispatched } = deps() + + const outcome = await executeDesktopBrowserBridgeRequest( + request({ + method: 'browser.keypress', + workspaceId: HOST_WORKSPACE, + params: { key, worktree: `id:${HOST_WORKSPACE}` } + }), + value + ) + + expect(outcome).toMatchObject({ + ok: false, + error: { code: 'desktop_browser_bridge_not_forwarded' } + }) + expect(dispatched).toEqual([]) + } + ) + + it('lets ordinary keys through', async () => { + const { value, dispatched } = deps() + + for (const key of ['Enter', 'v', 'Control+a', 'Shift+Tab']) { + await executeDesktopBrowserBridgeRequest( + request({ + method: 'browser.keypress', + workspaceId: HOST_WORKSPACE, + params: { key, worktree: `id:${HOST_WORKSPACE}` } + }), + value + ) + } + + expect(dispatched).toHaveLength(4) + }) + + it('never runs a command whose bridge was revoked while its scope was being checked', async () => { + const revoked = new AbortController() + const { value, dispatched } = deps({ + resolveWorkspace: async (scope) => { + revoked.abort() + return rendererScope(scope) + } + }) + + const outcome = await executeDesktopBrowserBridgeRequest( + request({ workspaceId: HOST_WORKSPACE, params: { worktree: `id:${HOST_WORKSPACE}` } }), + value, + revoked.signal + ) + + expect(outcome).toMatchObject({ + ok: false, + error: { code: 'desktop_browser_bridge_unavailable' } + }) + expect(dispatched).toEqual([]) + }) }) describe('resolveRemoteCliBrowserScopeSelector', () => { diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts index a5391452d1ab..a8fe5e8f9640 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts @@ -7,6 +7,7 @@ import { DESKTOP_BROWSER_BRIDGE_NOT_FORWARDED_CODE, DESKTOP_BROWSER_BRIDGE_SCOPE_CODE, + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, isDesktopBrowserBridgeMethod, type DesktopBrowserBridgeRequest } from '../../../shared/desktop-browser-bridge-protocol' @@ -41,6 +42,8 @@ export type DesktopBrowserBridgeExecutorDeps = { }): Promise /** The workspace a mounted page registered under, if it has mounted. */ worktreeIdForPage(browserPageId: string): string | null + /** Records that a host drives this workspace's pages, so they lose programmatic clipboard reads. */ + markWorkspaceDriven(workspaceId: string): void dispatch(method: string, params: Record): Promise audit(entry: DesktopBrowserBridgeAuditEntry): void } @@ -66,7 +69,9 @@ class BridgeRefusal extends Error { export async function executeDesktopBrowserBridgeRequest( request: DesktopBrowserBridgeRequest, - deps: DesktopBrowserBridgeExecutorDeps + deps: DesktopBrowserBridgeExecutorDeps, + /** Aborted when the bridge detaches; a command not yet dispatched must then never run. */ + signal?: AbortSignal ): Promise { const audit: Omit = { environmentId: deps.environmentId, @@ -80,6 +85,13 @@ export async function executeDesktopBrowserBridgeRequest( const { params, workspaceId, page } = await scopeRequest(request, deps) audit.workspaceId = workspaceId audit.page = page + if (signal?.aborted) { + throw new BridgeRefusal( + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, + 'The Orca desktop stopped sharing its browser before this command ran.' + ) + } + deps.markWorkspaceDriven(workspaceId) const response = await runInRemoteCliBrowserScope(workspaceId, () => deps.dispatch(request.method, params) ) @@ -117,6 +129,22 @@ async function scopeRequest( delete params[key] } } + if ( + (request.method === 'browser.goto' || request.method === 'browser.tabCreate') && + !isWebNavigationUrl(params.url, request.method === 'browser.tabCreate') + ) { + // Why: a bare path normalizes to file:// and loadURL skips the guest navigation guard. + throw new BridgeRefusal( + 'invalid_argument', + 'An SSH host can open only http:// or https:// URLs in the desktop browser. Pass the full URL.' + ) + } + if (request.method === 'browser.keypress' && isPasteChord(params.key)) { + throw new BridgeRefusal( + DESKTOP_BROWSER_BRIDGE_NOT_FORWARDED_CODE, + 'An SSH host cannot paste from the desktop clipboard. Use orca type or orca fill instead.' + ) + } const requestedWorkspaceId = request.workspaceId ?? null if ( params.worktree !== undefined && @@ -143,6 +171,42 @@ async function scopeRequest( return { params, workspaceId, page } } +function isWebNavigationUrl(value: unknown, optional: boolean): boolean { + if (value === undefined && optional) { + return true + } + if (typeof value !== 'string') { + return false + } + if (value === 'about:blank') { + return true + } + try { + const protocol = new URL(value).protocol + return protocol === 'http:' || protocol === 'https:' + } catch { + return false + } +} + +const PASTE_MODIFIERS = new Set(['control', 'ctrl', 'meta', 'cmd', 'command', 'super']) + +function isPasteChord(value: unknown): boolean { + if (typeof value !== 'string') { + return false + } + const parts = value + .toLowerCase() + .split('+') + .map((part) => part.trim()) + const key = parts.at(-1) + const modifiers = parts.slice(0, -1) + return ( + (key === 'v' && modifiers.some((modifier) => PASTE_MODIFIERS.has(modifier))) || + (key === 'insert' && modifiers.includes('shift')) + ) +} + function outOfScope(): BridgeRefusal { return new BridgeRefusal( DESKTOP_BROWSER_BRIDGE_SCOPE_CODE, diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts index d62fccd2af13..8f75493680b5 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts @@ -27,7 +27,10 @@ function harness(options: { supports?: boolean } = {}) { sendRequest: ReturnType }[] = [] const timers: { run: () => void; delayMs: number }[] = [] - const execute = vi.fn(async () => ({ ok: true as const, result: { url: 'https://a.test/' } })) + const execute = vi.fn(async () => ({ + ok: true, + result: { url: 'https://a.test/' } + })) const subscribe = vi.fn( () => async (callbacks: Callbacks): Promise => { @@ -91,7 +94,11 @@ describe('DesktopBrowserBridgeManager', () => { ) await vi.waitFor(() => expect(socket.sendRequest).toHaveBeenCalled()) - expect(h.execute).toHaveBeenCalledWith(HOST, expect.objectContaining({ requestId: 'req-1' })) + expect(h.execute).toHaveBeenCalledWith( + HOST, + expect.objectContaining({ requestId: 'req-1' }), + expect.any(AbortSignal) + ) expect(socket.sendRequest).toHaveBeenCalledWith( 'desktopBrowserBridge.result', { attachmentId: 'att-1', requestId: 'req-1', ok: true, result: { url: 'https://a.test/' } }, @@ -174,4 +181,37 @@ describe('DesktopBrowserBridgeManager', () => { await Promise.resolve() expect(h.subscriptions).toHaveLength(1) }) + + it('revokes a command still running when the host detaches', async () => { + const h = harness() + let seen: AbortSignal | null = null + h.execute.mockImplementationOnce(async (_candidate, _request, signal) => { + seen = signal + return { ok: true, result: {} } + }) + h.manager.reconcile() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + const socket = h.subscriptions[0]! + socket.callbacks.onResponse(event({ type: 'ready', attachmentId: 'att-1' })) + socket.callbacks.onResponse( + event({ type: 'request', requestId: 'r', method: 'browser.goto', params: {} }) + ) + await vi.waitFor(() => expect(seen).not.toBeNull()) + + h.setCandidates([]) + h.manager.reconcile() + + expect(seen!.aborted).toBe(true) + }) + + it('resetting an environment drops its socket and attaches again on a fresh one', async () => { + const h = harness() + h.manager.reconcile() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + + h.manager.resetEnvironment('env-a') + + expect(h.subscriptions[0]!.close).toHaveBeenCalled() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(2)) + }) }) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.ts index 10524cdcacc7..a3df0e21df4a 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.ts @@ -25,7 +25,8 @@ export type DesktopBrowserBridgeManagerDeps = { subscribe(environmentId: string): DesktopBrowserBridgeSubscribe execute( candidate: DesktopBrowserBridgeCandidate, - request: DesktopBrowserBridgeRequest + request: DesktopBrowserBridgeRequest, + signal: AbortSignal ): Promise log(message: string): void setTimer?(run: () => void, delayMs: number): ReturnType @@ -81,6 +82,12 @@ export class DesktopBrowserBridgeManager { } } + /** Drops an attachment whose transport was retired (re-pair, disconnect) and re-checks at once. */ + resetEnvironment(environmentId: string): void { + this.detach(environmentId, 'environment transport was retired') + this.reconcile() + } + isAttached(environmentId: string): boolean { return this.entries.get(environmentId)?.connection?.ready === true } @@ -118,7 +125,7 @@ export class DesktopBrowserBridgeManager { } const connection = new DesktopBrowserBridgeConnection({ subscribe: this.deps.subscribe(environmentId), - execute: (request) => this.deps.execute(entry.candidate, request), + execute: (request, signal) => this.deps.execute(entry.candidate, request, signal), onReady: () => { entry.failures = 0 this.deps.log(`attached: host=${entry.candidate.hostLabel} env=${environmentId}`) diff --git a/src/main/browser/desktop-browser-bridge/remote-cli-driven-workspaces.ts b/src/main/browser/desktop-browser-bridge/remote-cli-driven-workspaces.ts new file mode 100644 index 000000000000..084c08daa1fe --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/remote-cli-driven-workspaces.ts @@ -0,0 +1,25 @@ +/** + * Workspaces whose desktop pages an SSH host has driven through the bridge. Their pages lose the + * auto-granted programmatic clipboard read for the rest of this run, so a host's script cannot + * read the desktop clipboard through a page it controls. Typing and paste by the user still work. + */ +import { browserManager } from '../browser-manager' + +const drivenWorkspaceIds = new Set() + +export function markRemoteCliDrivenWorkspace(workspaceId: string): void { + drivenWorkspaceIds.add(workspaceId) +} + +export function isRemoteCliDrivenGuest(webContentsId: number | undefined): boolean { + if (webContentsId === undefined || drivenWorkspaceIds.size === 0) { + return false + } + const pageId = browserManager.getTabIdForWebContentsId(webContentsId) + const workspaceId = pageId ? browserManager.getWorktreeIdForTab(pageId) : undefined + return workspaceId !== undefined && drivenWorkspaceIds.has(workspaceId) +} + +export function resetRemoteCliDrivenWorkspacesForTests(): void { + drivenWorkspaceIds.clear() +} diff --git a/src/main/ipc/desktop-browser-bridge-reconcile.ts b/src/main/ipc/desktop-browser-bridge-reconcile.ts index b9eab4456e16..dad23159885f 100644 --- a/src/main/ipc/desktop-browser-bridge-reconcile.ts +++ b/src/main/ipc/desktop-browser-bridge-reconcile.ts @@ -1,14 +1,28 @@ -/** A dependency-free hook so SSH state and settings paths can ask the bridge to re-check its hosts. */ -let reconcile: (() => void) | null = null +/** Dependency-free hooks so SSH, settings and transport paths can steer the desktop browser bridge. */ +type DesktopBrowserBridgeControl = { + reconcile(): void + resetEnvironment(environmentId: string): void +} + +let control: DesktopBrowserBridgeControl | null = null -export function setDesktopBrowserBridgeReconciler(next: (() => void) | null): void { - reconcile = next +export function setDesktopBrowserBridgeControl(next: DesktopBrowserBridgeControl | null): void { + control = next } export function requestDesktopBrowserBridgeReconcile(): void { try { - reconcile?.() + control?.reconcile() } catch (error) { console.warn('[desktop-browser-bridge] reconcile failed:', error) } } + +/** A retired transport must not leave a bridge socket that still authenticates as the old peer. */ +export function resetDesktopBrowserBridgeEnvironment(environmentId: string): void { + try { + control?.resetEnvironment(environmentId) + } catch (error) { + console.warn('[desktop-browser-bridge] reset failed:', error) + } +} diff --git a/src/main/ipc/desktop-browser-bridge-wiring.ts b/src/main/ipc/desktop-browser-bridge-wiring.ts index 5697b50cc434..6e3fb0de5c8a 100644 --- a/src/main/ipc/desktop-browser-bridge-wiring.ts +++ b/src/main/ipc/desktop-browser-bridge-wiring.ts @@ -31,7 +31,8 @@ import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import type { SshConnectionManager } from '../ssh/ssh-connection-manager' import { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-connectivity-handlers' -import { setDesktopBrowserBridgeReconciler } from './desktop-browser-bridge-reconcile' +import { setDesktopBrowserBridgeControl } from './desktop-browser-bridge-reconcile' +import { markRemoteCliDrivenWorkspace } from '../browser/desktop-browser-bridge/remote-cli-driven-workspaces' const ATTACH_TIMEOUT_MS = 15_000 const BRIDGE_CLIENT_ID = randomUUID() @@ -128,30 +129,38 @@ export function installDesktopBrowserBridge(options: { } ) }, - execute: (candidate, request) => - executeDesktopBrowserBridgeRequest(request, { - environmentId: candidate.environmentId, - hostLabel: candidate.hostLabel, - resolveWorkspace: (scope) => - rendererResolveRemoteCliWorkspace(getMainWindow, { - environmentId: candidate.environmentId, - ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), - ...(scope.page ? { pageId: scope.page } : {}) - }), - worktreeIdForPage: (browserPageId) => - browserManager.getWorktreeIdForTab(browserPageId) ?? null, - dispatch: (method, params) => - getDispatcher().dispatch({ id: randomUUID(), authToken: '', method, params }), - audit: (entry) => log(`audit ${JSON.stringify(entry)}`) - }), + execute: (candidate, request, signal) => + executeDesktopBrowserBridgeRequest( + request, + { + environmentId: candidate.environmentId, + hostLabel: candidate.hostLabel, + resolveWorkspace: (scope) => + rendererResolveRemoteCliWorkspace(getMainWindow, { + environmentId: candidate.environmentId, + ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), + ...(scope.page ? { pageId: scope.page } : {}) + }), + markWorkspaceDriven: markRemoteCliDrivenWorkspace, + worktreeIdForPage: (browserPageId) => + browserManager.getWorktreeIdForTab(browserPageId) ?? null, + dispatch: (method, params) => + getDispatcher().dispatch({ id: randomUUID(), authToken: '', method, params }), + audit: (entry) => log(`audit ${JSON.stringify(entry)}`) + }, + signal + ), log }) - setDesktopBrowserBridgeReconciler(() => manager?.reconcile()) + setDesktopBrowserBridgeControl({ + reconcile: () => manager?.reconcile(), + resetEnvironment: (environmentId) => manager?.resetEnvironment(environmentId) + }) manager.reconcile() } export function disposeDesktopBrowserBridge(): void { - setDesktopBrowserBridgeReconciler(null) + setDesktopBrowserBridgeControl(null) manager?.dispose() manager = null } diff --git a/src/main/ipc/runtime-environment-manual-disconnect.ts b/src/main/ipc/runtime-environment-manual-disconnect.ts index 31c300895df3..cc7315d6ec99 100644 --- a/src/main/ipc/runtime-environment-manual-disconnect.ts +++ b/src/main/ipc/runtime-environment-manual-disconnect.ts @@ -1,13 +1,21 @@ +import { + requestDesktopBrowserBridgeReconcile, + resetDesktopBrowserBridgeEnvironment +} from './desktop-browser-bridge-reconcile' + const manuallyDisconnectedEnvironmentIds = new Set() export const RUNTIME_MANUALLY_DISCONNECTED_MESSAGE = 'Runtime environment is manually disconnected.' export function markRuntimeEnvironmentManuallyDisconnected(environmentId: string): void { manuallyDisconnectedEnvironmentIds.add(environmentId) + // Why: a disconnected server must lose its reach into the desktop browser at once. + resetDesktopBrowserBridgeEnvironment(environmentId) } export function clearRuntimeEnvironmentManualDisconnect(environmentId: string): void { manuallyDisconnectedEnvironmentIds.delete(environmentId) + requestDesktopBrowserBridgeReconcile() } export function isRuntimeEnvironmentManuallyDisconnected(environmentId: string): boolean { diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 8746d181d82e..45e4fa520918 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -35,6 +35,7 @@ import { advanceRuntimeEnvironmentCapabilityIncarnation } from './runtime-enviro import { retireMobileDesktopRelayEnvironment } from './runtime-environment-mobile-relay-hosts' import { watchRuntimeEnvironmentPreference } from './runtime-environment-preference' import { isGatedOffWslHost } from '../wsl/wsl-host-gate' +import { resetDesktopBrowserBridgeEnvironment } from './desktop-browser-bridge-reconcile' const remoteRuntimeSubscriptions = new Map() const getUserDataPath = (): string => app.getPath('userData') @@ -77,6 +78,7 @@ export function invalidateRuntimeEnvironmentTransport(environmentId: string): Pr closeRemoteRuntimeRequestConnection(environmentId) closeSubscriptionsForEnvironment(environmentId) retireMobileDesktopRelayEnvironment(environmentId) + resetDesktopBrowserBridgeEnvironment(environmentId) reactivateRuntimeEnvironmentStatus(environmentId) return retirePairedRuntimeBrowserClientHostEnvironment( environmentId, diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts index a441b7476a8b..82e21df8ec6c 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts @@ -145,6 +145,22 @@ describe('browser requests from this desktop follow the workspace owner (#22275) }) }) + it("closes a desktop-rendered tab in a managed host's workspace (#29145)", () => { + const state = storeState(LOCAL_WT) + mocks.getState.mockReturnValue({ + ...state, + browserPagesByWorkspace: { + ...state.browserPagesByWorkspace, + 'ws-remote': [{ id: 'page-remote', browserRuntimeEnvironmentId: null }] + } + }) + + mocks.closeListener?.({ requestId: 'c3', tabId: 'page-remote', worktreeId: REMOTE_WT }) + + expect(mocks.closeBrowserTab).toHaveBeenCalledExactlyOnceWith('ws-remote') + expect(mocks.replyTabClose).toHaveBeenCalledWith({ requestId: 'c3' }) + }) + it("closes a local page but refuses a paired server's page", () => { mocks.getState.mockReturnValue(storeState(LOCAL_WT)) diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts index d184fac67153..50c2f2708700 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts @@ -10,6 +10,7 @@ import { acquireBrowserAutomationBootstrapLease } from './browser-automation-boo import { findBrowserTabWorktreeId, isBrowserWorkspaceOwnedByPairedServer, + isDesktopRenderedBrowserTab, resolveRemoteCliBrowserWorkspace } from './browser-request-workspace-owner' @@ -198,7 +199,11 @@ export function registerBrowserRequestIpcBridge(unsubs: (() => void)[]): void { return } const closeWorktreeId = findBrowserTabWorktreeId(store, tabToClose) - if (closeWorktreeId && isBrowserWorkspaceOwnedByPairedServer(store, closeWorktreeId)) { + if ( + closeWorktreeId && + isBrowserWorkspaceOwnedByPairedServer(store, closeWorktreeId) && + !isDesktopRenderedBrowserTab(store, tabToClose) + ) { window.api.ui.replyTabClose({ requestId: data.requestId, error: pairedServerWorkspaceError() diff --git a/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts b/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts index 23ed01aa9529..76b11ea32ffd 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts @@ -59,6 +59,25 @@ export function resolveRemoteCliBrowserWorkspace( : null } +/** + * Whether this desktop renders the tab: every page records an explicit null runtime, as the tab + * bar's + and a managed SSH host's bridged create do. A paired server's streamed page never does. + */ +export function isDesktopRenderedBrowserTab( + state: Pick, + tabOrPageId: string +): boolean { + for (const tabs of Object.values(state.browserTabsByWorktree)) { + for (const tab of tabs) { + const pages = state.browserPagesByWorkspace[tab.id] ?? [] + if (tab.id === tabOrPageId || pages.some((page) => page.id === tabOrPageId)) { + return pages.length > 0 && pages.every((page) => page.browserRuntimeEnvironmentId === null) + } + } + } + return false +} + /** The workspace holding a browser tab, addressed by its tab (workspace) id or one of its page ids. */ export function findBrowserTabWorktreeId( state: Pick, diff --git a/tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts b/tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts index e8234948c9f3..768fb990cad2 100644 --- a/tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts +++ b/tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts @@ -192,6 +192,19 @@ test('a managed SSH host drives the desktop browser only while its CLI control s expect(listed.ok).toBe(true) expect(JSON.stringify(listed.result)).toContain(browserPageId) await page.screenshot({ path: testInfo.outputPath('desktop-browser-bridge-on.png') }) + // A desktop file is never reachable, even through a tab the host may drive. + const fileRead = await hostCli( + page, + target, + tabId, + `orca goto --url file:///etc/hosts --page ${browserPageId}` + ) + expect(fileRead.error?.code).toBe('invalid_argument') + const closed = await hostCli(page, target, tabId, `orca tab close --page ${browserPageId}`) + expect(closed.ok).toBe(true) + await expect + .poll(() => browserTabUrls(page, seeded.worktreeId), { timeout: 30_000 }) + .not.toContain(navigatedUrl) // Off again: the bridge detaches at once and the host is refused. await setRemoteCliControl(page, seeded.targetId, false) From 57a562557f8738c958ed43928db72c50f83d2411 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 20:46:02 -0700 Subject: [PATCH 04/15] fix(ssh): judge bridged paste chords as the page sees them; accept long workspace ids --- .../desktop-browser-bridge-executor.test.ts | 45 +++++++++++-------- .../desktop-browser-bridge-executor.ts | 18 ++++++-- .../desktop-browser-bridge-manager.test.ts | 22 +++++++++ src/shared/desktop-browser-bridge-protocol.ts | 4 +- 4 files changed, 66 insertions(+), 23 deletions(-) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts index 98eb16a8e828..e320f35d7ae7 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts @@ -369,27 +369,34 @@ describe('executeDesktopBrowserBridgeRequest', () => { expect(dispatched).toHaveLength(3) }) - it.each(['Control+v', 'Meta+V', 'cmd + v', 'Shift+Insert', 'Control+Shift+V'])( - 'refuses the paste chord %s', - async (key) => { - const { value, dispatched } = deps() + it.each([ + 'Control+v', + 'Meta+V', + 'cmd + v', + 'Win+v', + 'Super+v', + 'Shift+Insert', + 'Shift+Ins', + 'Control+Shift+V', + 'Alt+Meta+v' + ])('refuses the paste chord %s', async (key) => { + const { value, dispatched } = deps() - const outcome = await executeDesktopBrowserBridgeRequest( - request({ - method: 'browser.keypress', - workspaceId: HOST_WORKSPACE, - params: { key, worktree: `id:${HOST_WORKSPACE}` } - }), - value - ) + const outcome = await executeDesktopBrowserBridgeRequest( + request({ + method: 'browser.keypress', + workspaceId: HOST_WORKSPACE, + params: { key, worktree: `id:${HOST_WORKSPACE}` } + }), + value + ) - expect(outcome).toMatchObject({ - ok: false, - error: { code: 'desktop_browser_bridge_not_forwarded' } - }) - expect(dispatched).toEqual([]) - } - ) + expect(outcome).toMatchObject({ + ok: false, + error: { code: 'desktop_browser_bridge_not_forwarded' } + }) + expect(dispatched).toEqual([]) + }) it('lets ordinary keys through', async () => { const { value, dispatched } = deps() diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts index a8fe5e8f9640..f264da62bfce 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts @@ -13,6 +13,7 @@ import { } from '../../../shared/desktop-browser-bridge-protocol' import type { RpcResponse } from '../../runtime/rpc/core' import { runInRemoteCliBrowserScope } from '../../runtime/desktop-browser-bridge/remote-cli-browser-scope' +import { parseCdpKeyEvent } from '../cdp-keyboard-us-layout' export type DesktopBrowserBridgeOutcome = | { ok: true; result: unknown } @@ -189,12 +190,23 @@ function isWebNavigationUrl(value: unknown, optional: boolean): boolean { } } -const PASTE_MODIFIERS = new Set(['control', 'ctrl', 'meta', 'cmd', 'command', 'super']) +const CTRL_OR_META = 2 | 4 +const SHIFT = 8 +const PASTE_MODIFIER_NAMES = new Set(['control', 'ctrl', 'meta', 'cmd', 'command', 'super', 'win']) +// Why the input parser first: the key must be judged as the event the page receives, aliases and all. function isPasteChord(value: unknown): boolean { if (typeof value !== 'string') { return false } + const event = parseCdpKeyEvent(value) + if (event) { + return ( + (event.code === 'KeyV' && (event.modifiers & CTRL_OR_META) !== 0) || + (event.code === 'Insert' && (event.modifiers & SHIFT) !== 0) + ) + } + // A spelling the parser cannot express falls back to the raw driver, so refuse it by name too. const parts = value .toLowerCase() .split('+') @@ -202,8 +214,8 @@ function isPasteChord(value: unknown): boolean { const key = parts.at(-1) const modifiers = parts.slice(0, -1) return ( - (key === 'v' && modifiers.some((modifier) => PASTE_MODIFIERS.has(modifier))) || - (key === 'insert' && modifiers.includes('shift')) + (key === 'v' && modifiers.some((modifier) => PASTE_MODIFIER_NAMES.has(modifier))) || + ((key === 'insert' || key === 'ins') && modifiers.includes('shift')) ) } diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts index 8f75493680b5..bd3bb5c6fc41 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts @@ -214,4 +214,26 @@ describe('DesktopBrowserBridgeManager', () => { expect(h.subscriptions[0]!.close).toHaveBeenCalled() await vi.waitFor(() => expect(h.subscriptions).toHaveLength(2)) }) + + it('accepts a request for a workspace whose id embeds a long path', async () => { + const h = harness() + h.manager.reconcile() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + const socket = h.subscriptions[0]! + socket.callbacks.onResponse(event({ type: 'ready', attachmentId: 'att-1' })) + const workspaceId = `repo-1::/${'deep/'.repeat(200)}app` + + socket.callbacks.onResponse( + event({ + type: 'request', + requestId: 'long', + method: 'browser.tabList', + workspaceId, + params: { worktree: `id:${workspaceId}` } + }) + ) + + await vi.waitFor(() => expect(h.execute).toHaveBeenCalled()) + expect(socket.close).not.toHaveBeenCalled() + }) }) diff --git a/src/shared/desktop-browser-bridge-protocol.ts b/src/shared/desktop-browser-bridge-protocol.ts index 96372c4d1f4e..a1a961dcb208 100644 --- a/src/shared/desktop-browser-bridge-protocol.ts +++ b/src/shared/desktop-browser-bridge-protocol.ts @@ -88,6 +88,7 @@ export function isDesktopBrowserBridgeMethod(method: string): boolean { const Identity = z.string().min(1).max(256) const MAX_FORWARDED_PARAMS_BYTES = 1024 * 1024 +const MAX_WORKSPACE_ID_LENGTH = 16 * 1024 export const DesktopBrowserBridgeAttachParams = z.object({ bridgeClientId: Identity @@ -98,7 +99,8 @@ export const DesktopBrowserBridgeRequest = z.object({ requestId: Identity, method: z.string().refine(isDesktopBrowserBridgeMethod, 'method is not forwarded'), /** The workspace orcad resolved the caller's selector to; absent when it named none. */ - workspaceId: Identity.optional(), + // Why longer than an identity: a worktree id embeds the repository's absolute path. + workspaceId: z.string().min(1).max(MAX_WORKSPACE_ID_LENGTH).optional(), params: z .record(z.string(), z.unknown()) .refine( From a50e57a668b7bbaa4828df1bdf83412f7f23dd59 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 21:03:27 -0700 Subject: [PATCH 05/15] fix(ssh): bridged clipboard denial follows popups; revoked commands never leave the browser queue --- .../browser/agent-browser-bridge-queue.ts | 16 +++++- src/main/browser/browser-manager-queries.ts | 5 ++ .../desktop-browser-bridge-executor.test.ts | 26 +++++++++- .../desktop-browser-bridge-executor.ts | 6 ++- .../remote-cli-driven-workspaces.test.ts | 52 +++++++++++++++++++ .../remote-cli-driven-workspaces.ts | 14 +++-- .../remote-cli-browser-scope.ts | 28 ++++++++-- 7 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 src/main/browser/desktop-browser-bridge/remote-cli-driven-workspaces.test.ts diff --git a/src/main/browser/agent-browser-bridge-queue.ts b/src/main/browser/agent-browser-bridge-queue.ts index 7c6bfe919e50..d55404f50983 100644 --- a/src/main/browser/agent-browser-bridge-queue.ts +++ b/src/main/browser/agent-browser-bridge-queue.ts @@ -6,6 +6,7 @@ import type { EnqueueTargetedCommandOptions, ResolvedBrowserCommandTarget } from './agent-browser-bridge-types' +import { captureRemoteCliBrowserRevocation } from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown { // Why: route tab switch through the command queue so it can't race in-flight commands targeting the old tab. @@ -59,6 +60,8 @@ export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown options: EnqueueTargetedCommandOptions = {} ): Promise { this.assertCommandAdmission() + const assertNotRevoked = captureRemoteCliBrowserRevocation() + assertNotRevoked() const { browserPageId: pageId } = this.resolveCommandTarget( worktreeId, browserPageId, @@ -73,7 +76,18 @@ export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown this.commandQueues.set(sessionName, queue) } queue.push({ - execute: () => this.executeQueuedCommand(worktreeId, pageId, execute, options), + execute: () => { + assertNotRevoked() + return this.executeQueuedCommand( + worktreeId, + pageId, + (session, target) => { + assertNotRevoked() + return execute(session, target) + }, + options + ) + }, resolve: resolve as (value: unknown) => void, reject }) diff --git a/src/main/browser/browser-manager-queries.ts b/src/main/browser/browser-manager-queries.ts index dd8a5dc87a7f..d170e6cd3c8b 100644 --- a/src/main/browser/browser-manager-queries.ts +++ b/src/main/browser/browser-manager-queries.ts @@ -21,6 +21,11 @@ export abstract class BrowserManagerQueries extends BrowserManagerRegistration { return this.tabIdByWebContentsId.get(webContentsId) ?? null } + /** The page a guest belongs to, following popups back to the page that opened them. */ + getOwningTabIdForGuestWebContentsId(guestWebContentsId: number): string | null { + return this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + } + getWorktreeIdForTab(browserTabId: string): string | undefined { return this.worktreeIdByTabId.get(browserTabId) } diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts index e320f35d7ae7..8d2a595bfb50 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts @@ -3,7 +3,11 @@ import { DESKTOP_BROWSER_BRIDGE_METHODS, type DesktopBrowserBridgeRequest } from '../../../shared/desktop-browser-bridge-protocol' -import { resolveRemoteCliBrowserScopeSelector } from '../../runtime/desktop-browser-bridge/remote-cli-browser-scope' +import { + captureRemoteCliBrowserRevocation, + resolveRemoteCliBrowserScopeSelector, + runInRemoteCliBrowserScope +} from '../../runtime/desktop-browser-bridge/remote-cli-browser-scope' import { executeDesktopBrowserBridgeRequest, type DesktopBrowserBridgeAuditEntry, @@ -443,3 +447,23 @@ describe('resolveRemoteCliBrowserScopeSelector', () => { expect(resolveRemoteCliBrowserScopeSelector(`id:${LOCAL_WORKSPACE}`)).toBeNull() }) }) + +describe('captureRemoteCliBrowserRevocation', () => { + it('stops a command queued under a bridge that detached before it ran', async () => { + const revoked = new AbortController() + const assertNotRevoked = await runInRemoteCliBrowserScope( + HOST_WORKSPACE, + async () => captureRemoteCliBrowserRevocation(), + revoked.signal + ) + + expect(() => assertNotRevoked()).not.toThrow() + revoked.abort() + expect(() => assertNotRevoked()).toThrow('stopped sharing its browser') + }) + + it('never blocks a command that did not come from a host', () => { + const assertNotRevoked = captureRemoteCliBrowserRevocation() + expect(() => assertNotRevoked()).not.toThrow() + }) +}) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts index f264da62bfce..90652ff95048 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts @@ -93,8 +93,10 @@ export async function executeDesktopBrowserBridgeRequest( ) } deps.markWorkspaceDriven(workspaceId) - const response = await runInRemoteCliBrowserScope(workspaceId, () => - deps.dispatch(request.method, params) + const response = await runInRemoteCliBrowserScope( + workspaceId, + () => deps.dispatch(request.method, params), + signal ) if (!response.ok) { deps.audit({ ...audit, outcome: 'failed', code: response.error.code }) diff --git a/src/main/browser/desktop-browser-bridge/remote-cli-driven-workspaces.test.ts b/src/main/browser/desktop-browser-bridge/remote-cli-driven-workspaces.test.ts new file mode 100644 index 000000000000..12a15df8e15d --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/remote-cli-driven-workspaces.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const owners = vi.hoisted(() => ({ + tabByGuest: new Map(), + worktreeByTab: new Map() +})) + +vi.mock('../browser-manager', () => ({ + browserManager: { + getOwningTabIdForGuestWebContentsId: (id: number) => owners.tabByGuest.get(id) ?? null, + getWorktreeIdForTab: (tab: string) => owners.worktreeByTab.get(tab) + } +})) + +import { + isRemoteCliDrivenGuest, + markRemoteCliDrivenWorkspace, + resetRemoteCliDrivenWorkspacesForTests +} from './remote-cli-driven-workspaces' + +afterEach(() => { + resetRemoteCliDrivenWorkspacesForTests() + owners.tabByGuest.clear() + owners.worktreeByTab.clear() +}) + +describe('isRemoteCliDrivenGuest', () => { + it('leaves every guest alone until a host drives a workspace', () => { + expect(isRemoteCliDrivenGuest(1)).toBe(false) + expect(isRemoteCliDrivenGuest(undefined)).toBe(false) + }) + + it('covers a driven page and the popups it opened, not other workspaces', () => { + owners.tabByGuest.set(1, 'host-page') + // A popup resolves to the page that opened it. + owners.tabByGuest.set(2, 'host-page') + owners.tabByGuest.set(3, 'local-page') + owners.worktreeByTab.set('host-page', 'repo-host::/srv/app') + owners.worktreeByTab.set('local-page', 'repo-local::/Users/me/app') + markRemoteCliDrivenWorkspace('repo-host::/srv/app') + + expect(isRemoteCliDrivenGuest(1)).toBe(true) + expect(isRemoteCliDrivenGuest(2)).toBe(true) + expect(isRemoteCliDrivenGuest(3)).toBe(false) + }) + + it('fails closed for a guest whose owning page is unknown once a host drives pages', () => { + markRemoteCliDrivenWorkspace('repo-host::/srv/app') + + expect(isRemoteCliDrivenGuest(99)).toBe(true) + }) +}) diff --git a/src/main/browser/desktop-browser-bridge/remote-cli-driven-workspaces.ts b/src/main/browser/desktop-browser-bridge/remote-cli-driven-workspaces.ts index 084c08daa1fe..87a328cf9c71 100644 --- a/src/main/browser/desktop-browser-bridge/remote-cli-driven-workspaces.ts +++ b/src/main/browser/desktop-browser-bridge/remote-cli-driven-workspaces.ts @@ -12,11 +12,19 @@ export function markRemoteCliDrivenWorkspace(workspaceId: string): void { } export function isRemoteCliDrivenGuest(webContentsId: number | undefined): boolean { - if (webContentsId === undefined || drivenWorkspaceIds.size === 0) { + if (drivenWorkspaceIds.size === 0) { return false } - const pageId = browserManager.getTabIdForWebContentsId(webContentsId) - const workspaceId = pageId ? browserManager.getWorktreeIdForTab(pageId) : undefined + // Why popup-aware: a popup a host-scripted page opened runs that host's code too. + const pageId = + webContentsId === undefined + ? null + : browserManager.getOwningTabIdForGuestWebContentsId(webContentsId) + // Why fail closed: once a host drives pages, a guest with no known owner could be one of its. + if (!pageId) { + return true + } + const workspaceId = browserManager.getWorktreeIdForTab(pageId) return workspaceId !== undefined && drivenWorkspaceIds.has(workspaceId) } diff --git a/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts b/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts index 8ef3b29206ab..4950da8c85be 100644 --- a/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts +++ b/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts @@ -5,15 +5,35 @@ */ import { AsyncLocalStorage } from 'node:async_hooks' import { BrowserError } from '../../browser/browser-error' -import { DESKTOP_BROWSER_BRIDGE_SCOPE_CODE } from '../../../shared/desktop-browser-bridge-protocol' +import { + DESKTOP_BROWSER_BRIDGE_SCOPE_CODE, + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE +} from '../../../shared/desktop-browser-bridge-protocol' -const scope = new AsyncLocalStorage<{ workspaceId: string }>() +const scope = new AsyncLocalStorage<{ workspaceId: string; revoked?: AbortSignal }>() export function runInRemoteCliBrowserScope( workspaceId: string, - run: () => Promise + run: () => Promise, + revoked?: AbortSignal ): Promise { - return scope.run({ workspaceId }, run) + return scope.run({ workspaceId, ...(revoked ? { revoked } : {}) }, run) +} + +/** + * Captured where a browser command is queued; the returned check throws once the bridge that + * issued it detached, so a command waiting behind another never runs after access was revoked. + */ +export function captureRemoteCliBrowserRevocation(): () => void { + const revoked = scope.getStore()?.revoked + return () => { + if (revoked?.aborted) { + throw new BrowserError( + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, + 'The Orca desktop stopped sharing its browser before this command ran.' + ) + } + } } /** Null outside a bridged command; inside one, the scoped workspace or a refusal. */ From e4c73af816d3bc5cd0e4979359e4545bac1aa3a3 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 21:30:13 -0700 Subject: [PATCH 06/15] fix(ssh): refuse option-like host values bound for agent-browser argv; report undeliverable results --- .../desktop-browser-bridge-connection.ts | 20 ++++++++ .../desktop-browser-bridge-executor.test.ts | 47 +++++++++++++++++++ .../desktop-browser-bridge-executor.ts | 40 ++++++++++++++++ .../desktop-browser-bridge-manager.test.ts | 24 ++++++++++ 4 files changed, 131 insertions(+) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts index 59b0bd09a689..c7076efd1641 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts @@ -113,12 +113,32 @@ export class DesktopBrowserBridgeConnection { : { attachmentId, requestId: request.requestId, ok: false, error: outcome.error } try { await sendRequest('desktopBrowserBridge.result', params, RESULT_TIMEOUT_MS) + return } catch (error) { console.warn( '[desktop-browser-bridge] Could not return a result to the host:', error instanceof Error ? error.message : String(error) ) } + // Why: the command already ran; the host must hear that now, not at its own timeout. + try { + await sendRequest( + 'desktopBrowserBridge.result', + { + attachmentId, + requestId: request.requestId, + ok: false, + error: { + code: 'desktop_browser_bridge_result_undeliverable', + message: + 'The desktop ran the command but could not send its result back (it may be too large, such as a full-page screenshot).' + } + }, + RESULT_TIMEOUT_MS + ) + } catch { + // The transport is gone; orcad fails the request when the attachment detaches. + } } private lose(error: Error): void { diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts index 8d2a595bfb50..a7044bdb9df0 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts @@ -440,6 +440,53 @@ describe('executeDesktopBrowserBridgeRequest', () => { }) expect(dispatched).toEqual([]) }) + + it.each([ + ['browser.find', { locator: 'text', value: 'Continue', action: '--cdp', text: '9222' }], + ['browser.click', { element: '--cdp=9222' }], + ['browser.select', { element: 'e1', value: ' --session=other' }], + ['browser.wait', { selector: '-e' }], + ['browser.type', { input: '--cdp=9222' }], + ['browser.keyboardInsertText', { text: `${'a'.repeat(8 * 1024)}--cdp=9222` }] + ])( + 'refuses %s with an option-like value an SSH host could use to repoint agent-browser', + async (method, extra) => { + const { value, dispatched } = deps() + + const outcome = await executeDesktopBrowserBridgeRequest( + request({ + method, + workspaceId: HOST_WORKSPACE, + params: { ...extra, worktree: `id:${HOST_WORKSPACE}` } + }), + value + ) + + expect(outcome).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(dispatched).toEqual([]) + } + ) + + it('lets dashes through where they never reach agent-browser argv, or sit mid-text', async () => { + const { value, dispatched } = deps() + + for (const [method, extra] of [ + ['browser.eval', { expression: '-1 + 2' }], + ['browser.fill', { element: 'e1', value: '-5' }], + ['browser.type', { input: 'a - b --c' }] + ] as const) { + await executeDesktopBrowserBridgeRequest( + request({ + method, + workspaceId: HOST_WORKSPACE, + params: { ...extra, worktree: `id:${HOST_WORKSPACE}` } + }), + value + ) + } + + expect(dispatched).toHaveLength(3) + }) }) describe('resolveRemoteCliBrowserScopeSelector', () => { diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts index 90652ff95048..a7740f17dfcd 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts @@ -14,6 +14,8 @@ import { import type { RpcResponse } from '../../runtime/rpc/core' import { runInRemoteCliBrowserScope } from '../../runtime/desktop-browser-bridge/remote-cli-browser-scope' import { parseCdpKeyEvent } from '../cdp-keyboard-us-layout' +import { iterateBrowserTextInsertionChunks } from '../browser-text-insertion' +import { AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES } from '../agent-browser-bridge-types' export type DesktopBrowserBridgeOutcome = | { ok: true; result: unknown } @@ -142,6 +144,7 @@ async function scopeRequest( 'An SSH host can open only http:// or https:// URLs in the desktop browser. Pass the full URL.' ) } + assertNoOptionLikeArguments(request.method, params) if (request.method === 'browser.keypress' && isPasteChord(params.key)) { throw new BridgeRefusal( DESKTOP_BROWSER_BRIDGE_NOT_FORWARDED_CODE, @@ -192,6 +195,43 @@ function isWebNavigationUrl(value: unknown, optional: boolean): boolean { } } +// Params that never reach agent-browser's argv: eval runs over CDP, fill writes through stdin. +const NON_ARGV_PARAMS: Readonly> = { + 'browser.eval': ['expression'], + 'browser.fill': ['value'] +} +// Params agent-browser receives as argv in fixed-size chunks, so every chunk start is an argument. +const CHUNKED_ARGV_PARAMS: Readonly> = { + 'browser.type': 'input', + 'browser.keyboardInsertText': 'text' +} + +/** + * agent-browser reads its global options (such as --cdp) anywhere in argv, so a host value that + * starts with "-" could repoint the command at another page's proxy. Refuse it rather than escape. + */ +function assertNoOptionLikeArguments(method: string, params: Record): void { + const exempt = new Set(NON_ARGV_PARAMS[method] ?? []) + const chunked = CHUNKED_ARGV_PARAMS[method] + for (const [name, value] of Object.entries(params)) { + if (name === 'worktree' || name === 'page' || exempt.has(name)) { + continue + } + const values = + name === chunked && typeof value === 'string' + ? [...iterateBrowserTextInsertionChunks(value, AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES)] + : Array.isArray(value) + ? value + : [value] + if (values.some((entry) => typeof entry === 'string' && entry.trimStart().startsWith('-'))) { + throw new BridgeRefusal( + 'invalid_argument', + `An SSH host cannot send a --${name} value that starts with "-". Rephrase it, or type it with orca fill.` + ) + } + } +} + const CTRL_OR_META = 2 | 4 const SHIFT = 8 const PASTE_MODIFIER_NAMES = new Set(['control', 'ctrl', 'meta', 'cmd', 'command', 'super', 'win']) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts index bd3bb5c6fc41..c5f67f35bb97 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts @@ -236,4 +236,28 @@ describe('DesktopBrowserBridgeManager', () => { await vi.waitFor(() => expect(h.execute).toHaveBeenCalled()) expect(socket.close).not.toHaveBeenCalled() }) + + it('tells the host at once when a finished command result cannot be delivered', async () => { + const h = harness() + h.manager.reconcile() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + const socket = h.subscriptions[0]! + socket.sendRequest.mockRejectedValueOnce(new Error('outbound_message_too_large')) + socket.callbacks.onResponse(event({ type: 'ready', attachmentId: 'att-1' })) + + socket.callbacks.onResponse( + event({ type: 'request', requestId: 'big', method: 'browser.fullScreenshot', params: {} }) + ) + + await vi.waitFor(() => expect(socket.sendRequest).toHaveBeenCalledTimes(2)) + expect(socket.sendRequest).toHaveBeenLastCalledWith( + 'desktopBrowserBridge.result', + expect.objectContaining({ + requestId: 'big', + ok: false, + error: expect.objectContaining({ code: 'desktop_browser_bridge_result_undeliverable' }) + }), + expect.any(Number) + ) + }) }) From d6af0da9a739712db5586e20a3c92fc34862d051 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 21:44:49 -0700 Subject: [PATCH 07/15] fix(ssh): bound bridged error results and fall back when the host refuses one --- .../desktop-browser-bridge-connection.ts | 64 ++++++++++++------- .../desktop-browser-bridge-manager.test.ts | 34 ++++++++++ 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts index c7076efd1641..062f802d7a67 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts @@ -13,6 +13,9 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { DesktopBrowserBridgeOutcome } from './desktop-browser-bridge-executor' const RESULT_TIMEOUT_MS = 30_000 +// Why: orcad's result schema bounds both; an over-long browser error must still reach the CLI. +const MAX_ERROR_CODE_LENGTH = 128 +const MAX_ERROR_MESSAGE_LENGTH = 4096 export type DesktopBrowserBridgeSubscribe = (callbacks: { onResponse(response: RuntimeRpcResponse): void @@ -110,35 +113,52 @@ export class DesktopBrowserBridgeConnection { } const params: DesktopBrowserBridgeResultParams = outcome.ok ? { attachmentId, requestId: request.requestId, ok: true, result: outcome.result } - : { attachmentId, requestId: request.requestId, ok: false, error: outcome.error } - try { - await sendRequest('desktopBrowserBridge.result', params, RESULT_TIMEOUT_MS) - return - } catch (error) { - console.warn( - '[desktop-browser-bridge] Could not return a result to the host:', - error instanceof Error ? error.message : String(error) - ) - } - // Why: the command already ran; the host must hear that now, not at its own timeout. - try { - await sendRequest( - 'desktopBrowserBridge.result', - { + : { attachmentId, requestId: request.requestId, ok: false, error: { - code: 'desktop_browser_bridge_result_undeliverable', - message: - 'The desktop ran the command but could not send its result back (it may be too large, such as a full-page screenshot).' + code: outcome.error.code.slice(0, MAX_ERROR_CODE_LENGTH), + message: outcome.error.message.slice(0, MAX_ERROR_MESSAGE_LENGTH) } - }, - RESULT_TIMEOUT_MS + } + if (await this.sendResult(sendRequest, params)) { + return + } + // Why: the command already ran; the host must hear that now, not at its own timeout. + await this.sendResult(sendRequest, { + attachmentId, + requestId: request.requestId, + ok: false, + error: { + code: 'desktop_browser_bridge_result_undeliverable', + message: + 'The desktop ran the command but could not send its result back (it may be too large, such as a full-page screenshot).' + } + }) + } + + /** True only when orcad accepted the result; a thrown or refused submission is false. */ + private async sendResult( + sendRequest: NonNullable, + params: DesktopBrowserBridgeResultParams + ): Promise { + try { + const response = await sendRequest('desktopBrowserBridge.result', params, RESULT_TIMEOUT_MS) + if (response.ok) { + return true + } + console.warn( + '[desktop-browser-bridge] The host refused a result:', + `${response.error.code}: ${response.error.message}` + ) + } catch (error) { + console.warn( + '[desktop-browser-bridge] Could not return a result to the host:', + error instanceof Error ? error.message : String(error) ) - } catch { - // The transport is gone; orcad fails the request when the attachment detaches. } + return false } private lose(error: Error): void { diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts index c5f67f35bb97..a1918bb5971f 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts @@ -260,4 +260,38 @@ describe('DesktopBrowserBridgeManager', () => { expect.any(Number) ) }) + + it('bounds a long browser error and falls back when the host refuses a result', async () => { + const h = harness() + h.execute.mockResolvedValueOnce({ + ok: false, + error: { code: 'browser_error', message: 'x'.repeat(10_000) } + }) + h.manager.reconcile() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + const socket = h.subscriptions[0]! + socket.sendRequest.mockResolvedValueOnce({ + id: 'r', + ok: false, + error: { code: 'invalid_argument', message: 'refused' }, + _meta: { runtimeId: 'orcad' } + }) + socket.callbacks.onResponse(event({ type: 'ready', attachmentId: 'att-1' })) + + socket.callbacks.onResponse( + event({ type: 'request', requestId: 'long', method: 'browser.eval', params: {} }) + ) + + await vi.waitFor(() => expect(socket.sendRequest).toHaveBeenCalledTimes(2)) + expect(socket.sendRequest.mock.calls[0]?.[1]).toMatchObject({ + error: { code: 'browser_error', message: 'x'.repeat(4096) } + }) + expect(socket.sendRequest).toHaveBeenLastCalledWith( + 'desktopBrowserBridge.result', + expect.objectContaining({ + error: expect.objectContaining({ code: 'desktop_browser_bridge_result_undeliverable' }) + }), + expect.any(Number) + ) + }) }) From ae71e91dbdce0fced32dc24a0c6e02135ba5f2bf Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 21:58:18 -0700 Subject: [PATCH 08/15] fix(ssh): bridge only routed host tabs, and bound bridged page scripts by a deadline and revocation --- .../agent-browser-bridge-capture-commands.ts | 17 +++-- .../desktop-browser-bridge-executor.test.ts | 62 ++++++++++++++++++ .../desktop-browser-bridge-executor.ts | 10 +++ src/main/ipc/desktop-browser-bridge-wiring.ts | 11 ++++ .../remote-cli-browser-scope.ts | 63 +++++++++++++++++++ src/shared/desktop-browser-bridge-protocol.ts | 1 + 6 files changed, 159 insertions(+), 5 deletions(-) diff --git a/src/main/browser/agent-browser-bridge-capture-commands.ts b/src/main/browser/agent-browser-bridge-capture-commands.ts index db756e87c4f5..0f455303e16b 100644 --- a/src/main/browser/agent-browser-bridge-capture-commands.ts +++ b/src/main/browser/agent-browser-bridge-capture-commands.ts @@ -5,6 +5,7 @@ import { captureFullPageScreenshot } from './cdp-screenshot' import { acquireElectronDebugger } from './electron-debugger-lease' import { AgentBrowserBridgeUtilityCommands } from './agent-browser-bridge-utility-commands' import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { captureRemoteCliBrowserWorkBound } from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' export abstract class AgentBrowserBridgeCaptureCommands extends AgentBrowserBridgeUtilityCommands { async screenshot( @@ -60,6 +61,8 @@ export abstract class AgentBrowserBridgeCaptureCommands extends AgentBrowserBrid worktreeId?: string, browserPageId?: string ): Promise { + // Why captured here: the queue may run this later, outside the bridged command's context. + const bound = captureRemoteCliBrowserWorkBound() return this.enqueueTargetedCommand( worktreeId, browserPageId, @@ -68,11 +71,15 @@ export abstract class AgentBrowserBridgeCaptureCommands extends AgentBrowserBrid let releaseDebugger = (): void => {} try { releaseDebugger = acquireElectronDebugger(wc).release - const { result, exceptionDetails } = (await wc.debugger.sendCommand('Runtime.evaluate', { - expression, - returnByValue: true, - awaitPromise: true - })) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime.evaluate with returnByValue always answers this CDP shape; unchanged from before the bound. + const { result, exceptionDetails } = (await bound( + wc.debugger.sendCommand('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true + }), + () => void wc.debugger.sendCommand('Runtime.terminateExecution').catch(() => {}) + )) as { result: { value?: unknown; description?: string } exceptionDetails?: { text: string; exception?: { description?: string } } } diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts index a7044bdb9df0..bb2a1f32c3c7 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts @@ -4,6 +4,7 @@ import { type DesktopBrowserBridgeRequest } from '../../../shared/desktop-browser-bridge-protocol' import { + boundRemoteCliBrowserWork, captureRemoteCliBrowserRevocation, resolveRemoteCliBrowserScopeSelector, runInRemoteCliBrowserScope @@ -64,6 +65,7 @@ function deps(overrides: Partial = {}) { resolveWorkspace: rendererScope, worktreeIdForPage: (page) => MOUNTED_PAGE_OWNERS[page] ?? null, markWorkspaceDriven: (workspaceId) => driven.push(workspaceId), + isHostBrowsingRouted: () => true, dispatch: async (method, params) => { // Inside the dispatch, a selector for any other workspace must be refused. const scopeProbe = await resolveRemoteCliBrowserScopeSelector(`id:${LOCAL_WORKSPACE}`)?.catch( @@ -487,6 +489,23 @@ describe('executeDesktopBrowserBridgeRequest', () => { expect(dispatched).toHaveLength(3) }) + + it("refuses every command while the host's tabs browse from this desktop's own network", async () => { + const { value, dispatched, driven } = deps({ isHostBrowsingRouted: () => false }) + + const outcome = await executeDesktopBrowserBridgeRequest( + request({ + method: 'browser.goto', + workspaceId: HOST_WORKSPACE, + params: { url: 'http://127.0.0.1:8080', worktree: `id:${HOST_WORKSPACE}` } + }), + value + ) + + expect(outcome).toMatchObject({ ok: false, error: { code: 'desktop_browser_bridge_unrouted' } }) + expect(dispatched).toEqual([]) + expect(driven).toEqual([]) + }) }) describe('resolveRemoteCliBrowserScopeSelector', () => { @@ -514,3 +533,46 @@ describe('captureRemoteCliBrowserRevocation', () => { expect(() => assertNotRevoked()).not.toThrow() }) }) + +describe('boundRemoteCliBrowserWork', () => { + it('returns page work unchanged outside a bridged command', async () => { + const cancel = vi.fn() + await expect(boundRemoteCliBrowserWork(Promise.resolve(1), cancel, 5)).resolves.toBe(1) + expect(cancel).not.toHaveBeenCalled() + }) + + it('stops a bridged page script at its deadline and cancels it', async () => { + const cancel = vi.fn() + const outcome = runInRemoteCliBrowserScope(HOST_WORKSPACE, () => + boundRemoteCliBrowserWork(new Promise(() => {}), cancel, 10) + ) + + await expect(outcome).rejects.toMatchObject({ code: 'browser_timeout' }) + expect(cancel).toHaveBeenCalledOnce() + }) + + it('stops a bridged page script when the bridge detaches', async () => { + const revoked = new AbortController() + const cancel = vi.fn() + const outcome = runInRemoteCliBrowserScope( + HOST_WORKSPACE, + () => boundRemoteCliBrowserWork(new Promise(() => {}), cancel, 60_000), + revoked.signal + ) + + revoked.abort() + + await expect(outcome).rejects.toMatchObject({ code: 'desktop_browser_bridge_unavailable' }) + expect(cancel).toHaveBeenCalledOnce() + }) + + it('lets a bridged script that finishes in time return its result', async () => { + const cancel = vi.fn() + const outcome = runInRemoteCliBrowserScope(HOST_WORKSPACE, () => + boundRemoteCliBrowserWork(Promise.resolve('done'), cancel, 60_000) + ) + + await expect(outcome).resolves.toBe('done') + expect(cancel).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts index a7740f17dfcd..c8fc5ed2aff8 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts @@ -8,6 +8,7 @@ import { DESKTOP_BROWSER_BRIDGE_NOT_FORWARDED_CODE, DESKTOP_BROWSER_BRIDGE_SCOPE_CODE, DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, + DESKTOP_BROWSER_BRIDGE_UNROUTED_CODE, isDesktopBrowserBridgeMethod, type DesktopBrowserBridgeRequest } from '../../../shared/desktop-browser-bridge-protocol' @@ -47,6 +48,8 @@ export type DesktopBrowserBridgeExecutorDeps = { worktreeIdForPage(browserPageId: string): string | null /** Records that a host drives this workspace's pages, so they lose programmatic clipboard reads. */ markWorkspaceDriven(workspaceId: string): void + /** True when this host's desktop tabs browse through the host (SSH browser routing on for it). */ + isHostBrowsingRouted(): boolean dispatch(method: string, params: Record): Promise audit(entry: DesktopBrowserBridgeAuditEntry): void } @@ -128,6 +131,13 @@ async function scopeRequest( `${request.method} cannot run on this desktop from an SSH host.` ) } + // Why: an unrouted page egresses from this desktop, so a host could reach its loopback and LAN. + if (!deps.isHostBrowsingRouted()) { + throw new BridgeRefusal( + DESKTOP_BROWSER_BRIDGE_UNROUTED_CODE, + "This host's browser tabs browse from your desktop's own network, so its orca CLI cannot use them. Turn SSH browser routing back on for this host to allow it." + ) + } const params: Record = { ...request.params } if (request.method === 'browser.tabCreate') { for (const key of TAB_CREATE_STRIPPED_PARAMS) { diff --git a/src/main/ipc/desktop-browser-bridge-wiring.ts b/src/main/ipc/desktop-browser-bridge-wiring.ts index 6e3fb0de5c8a..a75577217515 100644 --- a/src/main/ipc/desktop-browser-bridge-wiring.ts +++ b/src/main/ipc/desktop-browser-bridge-wiring.ts @@ -33,6 +33,7 @@ import { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-connectivity-handlers' import { setDesktopBrowserBridgeControl } from './desktop-browser-bridge-reconcile' import { markRemoteCliDrivenWorkspace } from '../browser/desktop-browser-bridge/remote-cli-driven-workspaces' +import { persistedStore } from './ssh-ipc-context' const ATTACH_TIMEOUT_MS = 15_000 const BRIDGE_CLIENT_ID = randomUUID() @@ -142,6 +143,7 @@ export function installDesktopBrowserBridge(options: { ...(scope.page ? { pageId: scope.page } : {}) }), markWorkspaceDriven: markRemoteCliDrivenWorkspace, + isHostBrowsingRouted: () => isSshTargetBrowsingRouted(candidate.targetId), worktreeIdForPage: (browserPageId) => browserManager.getWorktreeIdForTab(browserPageId) ?? null, dispatch: (method, params) => @@ -159,6 +161,15 @@ export function installDesktopBrowserBridge(options: { manager.reconcile() } +// Mirrors the renderer's route eligibility: routing on globally and not opted out for this host. +function isSshTargetBrowsingRouted(targetId: string): boolean { + const settings = persistedStore?.getSettings() + return ( + settings?.browserSshWorkspaceRoutingEnabled !== false && + !settings?.browserSshWorkspaceRoutingDisabledTargetIds?.includes(targetId) + ) +} + export function disposeDesktopBrowserBridge(): void { setDesktopBrowserBridgeControl(null) manager?.dispose() diff --git a/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts b/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts index 4950da8c85be..7817fc14a740 100644 --- a/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts +++ b/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts @@ -54,3 +54,66 @@ export function resolveRemoteCliBrowserScopeSelector( ) ) } + +export const REMOTE_CLI_EVAL_TIMEOUT_MS = 30_000 + +/** + * Inside a bridged command, bounds page work by a deadline and by revocation, and runs `cancel` + * (for example terminating the page's script) when either fires. Outside one it returns `work`. + */ +export function boundRemoteCliBrowserWork( + work: Promise, + cancel: () => void, + timeoutMs = REMOTE_CLI_EVAL_TIMEOUT_MS +): Promise { + const active = scope.getStore() + if (!active) { + return work + } + let timer: ReturnType | undefined + let onRevoked = (): void => {} + const interrupted = new Promise((_resolve, reject) => { + const interrupt = (error: BrowserError): void => { + cancel() + reject(error) + } + onRevoked = () => + interrupt( + new BrowserError( + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, + 'The Orca desktop stopped sharing its browser while this command ran.' + ) + ) + timer = setTimeout( + () => + interrupt( + new BrowserError( + 'browser_timeout', + `The page script did not finish within ${Math.round(timeoutMs / 1000)}s.` + ) + ), + timeoutMs + ) + if (active.revoked?.aborted) { + onRevoked() + } else { + active.revoked?.addEventListener('abort', onRevoked, { once: true }) + } + }) + // Why: a settled race leaves `interrupted` pending forever; it must not surface as unhandled. + interrupted.catch(() => {}) + return Promise.race([work, interrupted]).finally(() => { + clearTimeout(timer) + active.revoked?.removeEventListener('abort', onRevoked) + }) +} + +/** Captures the bridged scope now so `boundRemoteCliBrowserWork` applies when queued work runs. */ +export function captureRemoteCliBrowserWorkBound(): ( + work: Promise, + cancel: () => void +) => Promise { + const active = scope.getStore() + return (work, cancel) => + active ? scope.run(active, () => boundRemoteCliBrowserWork(work, cancel)) : work +} diff --git a/src/shared/desktop-browser-bridge-protocol.ts b/src/shared/desktop-browser-bridge-protocol.ts index a1a961dcb208..b547e578a9fb 100644 --- a/src/shared/desktop-browser-bridge-protocol.ts +++ b/src/shared/desktop-browser-bridge-protocol.ts @@ -13,6 +13,7 @@ export const DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY = 'browser.desktopBridge. export const DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE = 'desktop_browser_bridge_unavailable' export const DESKTOP_BROWSER_BRIDGE_NOT_FORWARDED_CODE = 'desktop_browser_bridge_not_forwarded' export const DESKTOP_BROWSER_BRIDGE_SCOPE_CODE = 'desktop_browser_bridge_out_of_scope' +export const DESKTOP_BROWSER_BRIDGE_UNROUTED_CODE = 'desktop_browser_bridge_unrouted' export const DESKTOP_BROWSER_BRIDGE_SETTING_LABEL = "Allow this host's orca CLI to control Orca" From 2e9ab578a41dbe91e44e99dfbf52bb8d0bb4086e Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 22:06:28 -0700 Subject: [PATCH 09/15] fix(ssh): cap waiting bridged commands below the desktop socket's result capacity --- .../desktop-browser-bridge-registry.test.ts | 23 +++++++++++++++++++ .../desktop-browser-bridge-registry.ts | 4 +++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts new file mode 100644 index 000000000000..89a224969524 --- /dev/null +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { + DesktopBrowserBridgeRegistry, + MAX_PENDING_PER_ATTACHMENT +} from './desktop-browser-bridge-registry' + +describe('DesktopBrowserBridgeRegistry', () => { + it('keeps waiting commands under what one desktop socket can answer at once', async () => { + const registry = new DesktopBrowserBridgeRegistry() + registry.attach({ connectionId: 'c', pairedDeviceId: 'd', emit: () => {} }) + const waiting = Array.from({ length: MAX_PENDING_PER_ATTACHMENT }, () => + registry.forward({ method: 'browser.snapshot', params: {} }, { timeoutMs: 60_000 }) + ) + for (const pending of waiting) { + pending.catch(() => {}) + } + + await expect( + registry.forward({ method: 'browser.snapshot', params: {} }, { timeoutMs: 60_000 }) + ).rejects.toMatchObject({ code: 'browser_busy' }) + expect(MAX_PENDING_PER_ATTACHMENT).toBeLessThan(32) + }) +}) diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts index 6535ba42d75b..e3afbc8403dc 100644 --- a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts @@ -12,7 +12,9 @@ import { type DesktopBrowserBridgeResultParams } from '../../../shared/desktop-browser-bridge-protocol' -const MAX_PENDING_PER_ATTACHMENT = 64 +// Why below 32: the desktop answers over one subscription that holds at most 32 unacknowledged +// requests (remote-runtime-subscription-request-channel), so more would strand finished results. +export const MAX_PENDING_PER_ATTACHMENT = 16 type Attachment = { attachmentId: string From 0727a77a8d09f41ccc36036892c686ab16be818c Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 22:20:22 -0700 Subject: [PATCH 10/15] fix(ssh): bridge revokes on routing changes, checks each page's route at run time, and cancels host-driven downloads --- .../browser/agent-browser-bridge-queue.ts | 2 +- ...browser-session-partition-policies.test.ts | 9 +++++ .../browser-session-partition-policies.ts | 8 ++++- .../desktop-browser-bridge-executor.test.ts | 19 ++++++++++ .../desktop-browser-bridge-executor.ts | 5 ++- .../browser/local-ssh-browser-partitions.ts | 6 ++++ src/main/ipc/desktop-browser-bridge-wiring.ts | 30 +++++++++++++++- .../remote-cli-browser-scope.ts | 35 ++++++++++++++----- 8 files changed, 102 insertions(+), 12 deletions(-) diff --git a/src/main/browser/agent-browser-bridge-queue.ts b/src/main/browser/agent-browser-bridge-queue.ts index d55404f50983..bf9826119691 100644 --- a/src/main/browser/agent-browser-bridge-queue.ts +++ b/src/main/browser/agent-browser-bridge-queue.ts @@ -82,7 +82,7 @@ export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown worktreeId, pageId, (session, target) => { - assertNotRevoked() + assertNotRevoked(target.webContentsId) return execute(session, target) }, options diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts index 0928fb1e70b5..2c9507867e0f 100644 --- a/src/main/browser/browser-session-partition-policies.test.ts +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -230,6 +230,15 @@ describe('partition permission policy', () => { expect(checkHandler({ id: 8 }, 'clipboard-read')).toBe(true) }) + it('cancels a download from a page an SSH host drives (#29145)', async () => { + mocks.isRemoteCliDrivenGuest.mockImplementation((id?: number) => id === 42) + const install = await loadInstaller() + install(profileFor('persist:browsing-3')) + + expect(fireWillDownload('persist:browsing-3')).toEqual({ cancelled: true }) + expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled() + }) + it('denies every request and check on a strict partition without WebAuthn handlers', async () => { const install = await loadInstaller() install(profileFor('orca-doc-preview'), { permissions: 'deny' }) diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index a38f185cae6c..66b351ccf2b0 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -59,10 +59,16 @@ export function forgetBrowserSessionPartitionConfiguration(partition: string): v configuredPartitions.delete(partition) } const handleWillDownload = ( - _event: Electron.Event, + event: Electron.Event, item: Electron.DownloadItem, webContents: Electron.WebContents ): void => { + // Why: a host-driven page could otherwise write a host-chosen file into this desktop's Downloads. + if (isRemoteCliDrivenGuest(webContents.id)) { + event.preventDefault() + console.info('[desktop-browser-bridge] refused a download from a page an SSH host drives') + return + } browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) } diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts index bb2a1f32c3c7..d117511d104d 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts @@ -66,6 +66,7 @@ function deps(overrides: Partial = {}) { worktreeIdForPage: (page) => MOUNTED_PAGE_OWNERS[page] ?? null, markWorkspaceDriven: (workspaceId) => driven.push(workspaceId), isHostBrowsingRouted: () => true, + isGuestRoutedThroughHost: () => true, dispatch: async (method, params) => { // Inside the dispatch, a selector for any other workspace must be refused. const scopeProbe = await resolveRemoteCliBrowserScopeSelector(`id:${LOCAL_WORKSPACE}`)?.catch( @@ -576,3 +577,21 @@ describe('boundRemoteCliBrowserWork', () => { expect(cancel).not.toHaveBeenCalled() }) }) + +describe('captureRemoteCliBrowserRevocation guest routing', () => { + it('refuses to run a queued command on a page that stopped browsing through the host', async () => { + let routed = true + const assertAdmitted = await runInRemoteCliBrowserScope( + HOST_WORKSPACE, + async () => captureRemoteCliBrowserRevocation(), + new AbortController().signal, + () => routed + ) + + expect(() => assertAdmitted(7)).not.toThrow() + routed = false + expect(() => assertAdmitted(7)).toThrow('not browsing through the SSH host') + // A check with no guest yet (before the queue resolves one) still only checks revocation. + expect(() => assertAdmitted()).not.toThrow() + }) +}) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts index c8fc5ed2aff8..8d918ab22e14 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts @@ -50,6 +50,8 @@ export type DesktopBrowserBridgeExecutorDeps = { markWorkspaceDriven(workspaceId: string): void /** True when this host's desktop tabs browse through the host (SSH browser routing on for it). */ isHostBrowsingRouted(): boolean + /** True only for a guest whose session is this host's prepared SSH route partition. */ + isGuestRoutedThroughHost(webContentsId: number): boolean dispatch(method: string, params: Record): Promise audit(entry: DesktopBrowserBridgeAuditEntry): void } @@ -101,7 +103,8 @@ export async function executeDesktopBrowserBridgeRequest( const response = await runInRemoteCliBrowserScope( workspaceId, () => deps.dispatch(request.method, params), - signal + signal, + (webContentsId) => deps.isHostBrowsingRouted() && deps.isGuestRoutedThroughHost(webContentsId) ) if (!response.ok) { deps.audit({ ...audit, outcome: 'failed', code: response.error.code }) diff --git a/src/main/browser/local-ssh-browser-partitions.ts b/src/main/browser/local-ssh-browser-partitions.ts index 44c2ea52bc49..313811b943cd 100644 --- a/src/main/browser/local-ssh-browser-partitions.ts +++ b/src/main/browser/local-ssh-browser-partitions.ts @@ -61,6 +61,12 @@ export function localSshBrowserPartitionForSession(webSession: Session): string return partitionBySession.get(webSession) ?? null } +/** The SSH target a session's prepared route partition egresses through, or null if unrouted. */ +export function localSshBrowserTargetIdForSession(webSession: Session): string | null { + const partition = partitionBySession.get(webSession) + return partition ? (preparedByPartition.get(partition)?.targetId ?? null) : null +} + /** * Derives, binds, and proxies the route partition for (SSH target × browser * profile), fail-closed: the partition is only handed to the renderer once diff --git a/src/main/ipc/desktop-browser-bridge-wiring.ts b/src/main/ipc/desktop-browser-bridge-wiring.ts index a75577217515..600236631e34 100644 --- a/src/main/ipc/desktop-browser-bridge-wiring.ts +++ b/src/main/ipc/desktop-browser-bridge-wiring.ts @@ -3,7 +3,7 @@ * reach their orcad over the paired encrypted connection, and how a forwarded command runs here. */ import { randomUUID } from 'node:crypto' -import type { BrowserWindow } from 'electron' +import { webContents, type BrowserWindow } from 'electron' import { DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY, isDesktopBrowserBridgeMethod @@ -34,11 +34,13 @@ import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment- import { setDesktopBrowserBridgeControl } from './desktop-browser-bridge-reconcile' import { markRemoteCliDrivenWorkspace } from '../browser/desktop-browser-bridge/remote-cli-driven-workspaces' import { persistedStore } from './ssh-ipc-context' +import { localSshBrowserTargetIdForSession } from '../browser/local-ssh-browser-partitions' const ATTACH_TIMEOUT_MS = 15_000 const BRIDGE_CLIENT_ID = randomUUID() let manager: DesktopBrowserBridgeManager | null = null +let stopWatchingRoutingSettings: (() => void) | null = null function log(message: string): void { console.info(`[desktop-browser-bridge] ${message}`) @@ -57,6 +59,10 @@ export function listDesktopBrowserBridgeCandidates( if (connections?.getState(target.id)?.status !== 'connected') { continue } + // Why: an unrouted host's pages egress from this desktop, so it must not stay attached at all. + if (!isSshTargetBrowsingRouted(target.id)) { + continue + } const environmentId = getManagedOrcadFenceEnvironmentId(target) if (!environmentId || isRuntimeEnvironmentManuallyDisconnected(environmentId)) { continue @@ -144,6 +150,14 @@ export function installDesktopBrowserBridge(options: { }), markWorkspaceDriven: markRemoteCliDrivenWorkspace, isHostBrowsingRouted: () => isSshTargetBrowsingRouted(candidate.targetId), + isGuestRoutedThroughHost: (webContentsId) => { + const guest = webContents.fromId(webContentsId) + return Boolean( + guest && + !guest.isDestroyed() && + localSshBrowserTargetIdForSession(guest.session) === candidate.targetId + ) + }, worktreeIdForPage: (browserPageId) => browserManager.getWorktreeIdForTab(browserPageId) ?? null, dispatch: (method, params) => @@ -154,6 +168,18 @@ export function installDesktopBrowserBridge(options: { ), log }) + // Why: turning routing off for a host must detach it (and cancel its waiting commands) at once. + stopWatchingRoutingSettings?.() + // Why optional: test doubles of the store omit the listener API. + stopWatchingRoutingSettings = + persistedStore?.onSettingsChanged?.((updates) => { + if ( + 'browserSshWorkspaceRoutingEnabled' in updates || + 'browserSshWorkspaceRoutingDisabledTargetIds' in updates + ) { + manager?.reconcile() + } + }) ?? null setDesktopBrowserBridgeControl({ reconcile: () => manager?.reconcile(), resetEnvironment: (environmentId) => manager?.resetEnvironment(environmentId) @@ -171,6 +197,8 @@ function isSshTargetBrowsingRouted(targetId: string): boolean { } export function disposeDesktopBrowserBridge(): void { + stopWatchingRoutingSettings?.() + stopWatchingRoutingSettings = null setDesktopBrowserBridgeControl(null) manager?.dispose() manager = null diff --git a/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts b/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts index 7817fc14a740..2a1ad031dde1 100644 --- a/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts +++ b/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts @@ -7,32 +7,51 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { BrowserError } from '../../browser/browser-error' import { DESKTOP_BROWSER_BRIDGE_SCOPE_CODE, - DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, + DESKTOP_BROWSER_BRIDGE_UNROUTED_CODE } from '../../../shared/desktop-browser-bridge-protocol' -const scope = new AsyncLocalStorage<{ workspaceId: string; revoked?: AbortSignal }>() +type RemoteCliBrowserScope = { + workspaceId: string + revoked?: AbortSignal + /** True only for a guest that browses through this host's SSH route right now. */ + admitGuest?: (webContentsId: number) => boolean +} + +const scope = new AsyncLocalStorage() export function runInRemoteCliBrowserScope( workspaceId: string, run: () => Promise, - revoked?: AbortSignal + revoked?: AbortSignal, + admitGuest?: (webContentsId: number) => boolean ): Promise { - return scope.run({ workspaceId, ...(revoked ? { revoked } : {}) }, run) + return scope.run( + { workspaceId, ...(revoked ? { revoked } : {}), ...(admitGuest ? { admitGuest } : {}) }, + run + ) } /** * Captured where a browser command is queued; the returned check throws once the bridge that * issued it detached, so a command waiting behind another never runs after access was revoked. */ -export function captureRemoteCliBrowserRevocation(): () => void { - const revoked = scope.getStore()?.revoked - return () => { - if (revoked?.aborted) { +export function captureRemoteCliBrowserRevocation(): (webContentsId?: number) => void { + const active = scope.getStore() + return (webContentsId) => { + if (active?.revoked?.aborted) { throw new BrowserError( DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, 'The Orca desktop stopped sharing its browser before this command ran.' ) } + // Why at execution: routing can be turned off while the command waits, remounting the page unrouted. + if (webContentsId !== undefined && active?.admitGuest && !active.admitGuest(webContentsId)) { + throw new BrowserError( + DESKTOP_BROWSER_BRIDGE_UNROUTED_CODE, + "This page is not browsing through the SSH host, so the host's orca CLI cannot use it." + ) + } } } From 607c2c2081eead8f7136e7f294b07320a738648b Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 22:29:31 -0700 Subject: [PATCH 11/15] fix(ssh): name the bridge registry's owner type --- .../desktop-browser-bridge/desktop-browser-bridge-registry.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts index e3afbc8403dc..cad7efd94893 100644 --- a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts @@ -186,7 +186,9 @@ export function isDesktopBrowserBridgeHostEnabled(): boolean { return hostEnabled } -export function getDesktopBrowserBridgeRegistry(runtime: object): DesktopBrowserBridgeRegistry { +export function getDesktopBrowserBridgeRegistry(runtime: { + getRuntimeId(): string +}): DesktopBrowserBridgeRegistry { let registry = registries.get(runtime) if (!registry) { registry = new DesktopBrowserBridgeRegistry() From 2facfdf2b25116c9363afb97de2939a6a2fb1b2e Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 22:50:56 -0700 Subject: [PATCH 12/15] fix(ssh): bridge refuses pages showing a local document, and never starts a command past orcad's deadline --- .../desktop-browser-bridge-connection.ts | 6 +++++- .../desktop-browser-bridge-executor.test.ts | 15 +++++++++++++ .../desktop-browser-bridge-executor.ts | 11 ++++++++++ .../desktop-browser-bridge-manager.test.ts | 21 +++++++++++++++++++ src/main/ipc/desktop-browser-bridge-wiring.ts | 8 +++++-- .../desktop-browser-bridge-registry.ts | 1 + src/shared/desktop-browser-bridge-protocol.ts | 7 +++++++ 7 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts index 062f802d7a67..391c96216fb5 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts @@ -106,7 +106,11 @@ export class DesktopBrowserBridgeConnection { } private async answer(attachmentId: string, request: DesktopBrowserBridgeRequest): Promise { - const outcome = await this.options.execute(request, this.revoked.signal) + // Why: once orcad has stopped waiting, a command still queued here must not run late. + const signal = request.timeoutMs + ? AbortSignal.any([this.revoked.signal, AbortSignal.timeout(request.timeoutMs)]) + : this.revoked.signal + const outcome = await this.options.execute(request, signal) const sendRequest = this.subscription?.sendRequest if (this.closed || this.attachmentId !== attachmentId || !sendRequest) { return diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts index d117511d104d..6b5b24028da4 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts @@ -11,6 +11,7 @@ import { } from '../../runtime/desktop-browser-bridge/remote-cli-browser-scope' import { executeDesktopBrowserBridgeRequest, + isHostReachableDocument, type DesktopBrowserBridgeAuditEntry, type DesktopBrowserBridgeExecutorDeps } from './desktop-browser-bridge-executor' @@ -595,3 +596,17 @@ describe('captureRemoteCliBrowserRevocation guest routing', () => { expect(() => assertAdmitted()).not.toThrow() }) }) + +describe('isHostReachableDocument', () => { + it('refuses a page showing a desktop file, even in a routed tab', () => { + expect(isHostReachableDocument('file:///Users/me/.ssh/id_ed25519')).toBe(false) + expect(isHostReachableDocument('orca-doc://preview')).toBe(false) + expect(isHostReachableDocument('chrome://settings')).toBe(false) + }) + + it('allows web documents and a page that has not loaded yet', () => { + for (const url of ['https://a.test', 'http://localhost:3000', 'about:blank', '']) { + expect(isHostReachableDocument(url)).toBe(true) + } + }) +}) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts index 8d918ab22e14..9e4df36d33bd 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts @@ -291,3 +291,14 @@ function urlOrigin(value: unknown): string | null { return null } } + +// Why: a user can open a desktop file in a routed tab; its document is still this desktop's file. +const HOST_REACHABLE_DOCUMENT_PROTOCOLS = new Set(['http:', 'https:', 'about:', 'blob:', 'data:']) + +export function isHostReachableDocument(url: string): boolean { + try { + return HOST_REACHABLE_DOCUMENT_PROTOCOLS.has(new URL(url).protocol) + } catch { + return url === '' + } +} diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts index a1918bb5971f..2af864680d8d 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts @@ -294,4 +294,25 @@ describe('DesktopBrowserBridgeManager', () => { expect.any(Number) ) }) + + it('never starts a command after orcad stopped waiting for it', async () => { + const h = harness() + let seen: AbortSignal | null = null + h.execute.mockImplementationOnce(async (_candidate, _request, signal) => { + seen = signal + return { ok: true, result: {} } + }) + h.manager.reconcile() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + const socket = h.subscriptions[0]! + socket.callbacks.onResponse(event({ type: 'ready', attachmentId: 'att-1' })) + + socket.callbacks.onResponse( + event({ type: 'request', requestId: 'r', method: 'browser.click', timeoutMs: 20, params: {} }) + ) + + await vi.waitFor(() => expect(seen).not.toBeNull()) + expect(seen!.aborted).toBe(false) + await vi.waitFor(() => expect(seen!.aborted).toBe(true)) + }) }) diff --git a/src/main/ipc/desktop-browser-bridge-wiring.ts b/src/main/ipc/desktop-browser-bridge-wiring.ts index 600236631e34..45b3945da356 100644 --- a/src/main/ipc/desktop-browser-bridge-wiring.ts +++ b/src/main/ipc/desktop-browser-bridge-wiring.ts @@ -21,7 +21,10 @@ import { DesktopBrowserBridgeManager, type DesktopBrowserBridgeCandidate } from '../browser/desktop-browser-bridge/desktop-browser-bridge-manager' -import { executeDesktopBrowserBridgeRequest } from '../browser/desktop-browser-bridge/desktop-browser-bridge-executor' +import { + executeDesktopBrowserBridgeRequest, + isHostReachableDocument +} from '../browser/desktop-browser-bridge/desktop-browser-bridge-executor' import { rendererResolveRemoteCliWorkspace } from '../browser/desktop-browser-bridge/desktop-browser-bridge-renderer-scope' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { RpcDispatcher } from '../runtime/rpc/dispatcher' @@ -155,7 +158,8 @@ export function installDesktopBrowserBridge(options: { return Boolean( guest && !guest.isDestroyed() && - localSshBrowserTargetIdForSession(guest.session) === candidate.targetId + localSshBrowserTargetIdForSession(guest.session) === candidate.targetId && + isHostReachableDocument(guest.getURL()) ) }, worktreeIdForPage: (browserPageId) => diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts index cad7efd94893..5075b50ac131 100644 --- a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts @@ -106,6 +106,7 @@ export class DesktopBrowserBridgeRegistry { requestId, method: request.method, params: request.params, + timeoutMs: options.timeoutMs, ...(request.workspaceId ? { workspaceId: request.workspaceId } : {}) }) } catch { diff --git a/src/shared/desktop-browser-bridge-protocol.ts b/src/shared/desktop-browser-bridge-protocol.ts index b547e578a9fb..17425962c6b6 100644 --- a/src/shared/desktop-browser-bridge-protocol.ts +++ b/src/shared/desktop-browser-bridge-protocol.ts @@ -99,6 +99,13 @@ export const DesktopBrowserBridgeRequest = z.object({ type: z.literal('request'), requestId: Identity, method: z.string().refine(isDesktopBrowserBridgeMethod, 'method is not forwarded'), + /** How long orcad waits for this answer; the desktop never starts the command after it. */ + timeoutMs: z + .number() + .int() + .positive() + .max(60 * 60_000) + .optional(), /** The workspace orcad resolved the caller's selector to; absent when it named none. */ // Why longer than an identity: a worktree id embeds the repository's absolute path. workspaceId: z.string().min(1).max(MAX_WORKSPACE_ID_LENGTH).optional(), From 7f102776f6873dbb8d8faeaed819df3eb00f422a Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 23:00:26 -0700 Subject: [PATCH 13/15] fix(ssh): bridged commands may leave Chromium error pages, and stop starting before orcad's deadline --- .../desktop-browser-bridge-executor.test.ts | 8 +++++++- .../desktop-browser-bridge-executor.ts | 10 +++++++++- .../desktop-browser-bridge-registry.test.ts | 10 ++++++++++ .../desktop-browser-bridge-registry.ts | 8 +++++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts index 6b5b24028da4..31b007d85eca 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts @@ -605,7 +605,13 @@ describe('isHostReachableDocument', () => { }) it('allows web documents and a page that has not loaded yet', () => { - for (const url of ['https://a.test', 'http://localhost:3000', 'about:blank', '']) { + for (const url of [ + 'https://a.test', + 'http://localhost:3000', + 'about:blank', + 'chrome-error://chromewebdata/', + '' + ]) { expect(isHostReachableDocument(url)).toBe(true) } }) diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts index 9e4df36d33bd..a46c19eef5b2 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts @@ -293,7 +293,15 @@ function urlOrigin(value: unknown): string | null { } // Why: a user can open a desktop file in a routed tab; its document is still this desktop's file. -const HOST_REACHABLE_DOCUMENT_PROTOCOLS = new Set(['http:', 'https:', 'about:', 'blob:', 'data:']) +// chrome-error: is Chromium's own load-failure page, which a host must be able to navigate away from. +const HOST_REACHABLE_DOCUMENT_PROTOCOLS = new Set([ + 'http:', + 'https:', + 'about:', + 'blob:', + 'data:', + 'chrome-error:' +]) export function isHostReachableDocument(url: string): boolean { try { diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts index 89a224969524..77b393a9967f 100644 --- a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts @@ -20,4 +20,14 @@ describe('DesktopBrowserBridgeRegistry', () => { ).rejects.toMatchObject({ code: 'browser_busy' }) expect(MAX_PENDING_PER_ATTACHMENT).toBeLessThan(32) }) + + it('gives the desktop a deadline that ends before orcad stops waiting', () => { + const registry = new DesktopBrowserBridgeRegistry() + const events: { timeoutMs?: number }[] = [] + registry.attach({ connectionId: 'c', pairedDeviceId: 'd', emit: (event) => events.push(event) }) + + registry.forward({ method: 'browser.click', params: {} }, { timeoutMs: 60_000 }).catch(() => {}) + + expect(events[0]?.timeoutMs).toBe(50_000) + }) }) diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts index 5075b50ac131..0f10f2423cf1 100644 --- a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts @@ -15,6 +15,8 @@ import { // Why below 32: the desktop answers over one subscription that holds at most 32 unacknowledged // requests (remote-runtime-subscription-request-channel), so more would strand finished results. export const MAX_PENDING_PER_ATTACHMENT = 16 +const DESKTOP_DEADLINE_MARGIN_MS = 10_000 +const MIN_DESKTOP_DEADLINE_MS = 1_000 type Attachment = { attachmentId: string @@ -106,7 +108,11 @@ export class DesktopBrowserBridgeRegistry { requestId, method: request.method, params: request.params, - timeoutMs: options.timeoutMs, + // Why shorter: the desktop must stop starting this before orcad gives up, transit included. + timeoutMs: Math.max( + MIN_DESKTOP_DEADLINE_MS, + options.timeoutMs - DESKTOP_DEADLINE_MARGIN_MS + ), ...(request.workspaceId ? { workspaceId: request.workspaceId } : {}) }) } catch { From 7f0fe4c50360f476067c3de7f0d321242cebb728 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 15 Oct 2026 22:43:21 -0700 Subject: [PATCH 14/15] fix(ssh): ask for a fresh opt-in to host CLI control saved before it drove the desktop browser --- ...h-normalization-remote-cli-consent.test.ts | 37 +++++++++++++++++++ .../leasing-ssh-ptys/ssh-normalization.ts | 35 ++++++++++++++---- src/main/ssh/ssh-connection-store.test.ts | 23 ++++++++++++ src/main/ssh/ssh-connection-store.ts | 18 ++++++++- .../settings/SshHostAdvancedFields.tsx | 8 ++++ .../components/settings/ssh-target-draft.ts | 5 ++- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/ssh-types.ts | 9 ++++- 13 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 src/main/persistence/leasing-ssh-ptys/ssh-normalization-remote-cli-consent.test.ts diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-normalization-remote-cli-consent.test.ts b/src/main/persistence/leasing-ssh-ptys/ssh-normalization-remote-cli-consent.test.ts new file mode 100644 index 000000000000..97cfae686336 --- /dev/null +++ b/src/main/persistence/leasing-ssh-ptys/ssh-normalization-remote-cli-consent.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { SshTarget } from '../../../shared/ssh-types' +import { normalizeSshTarget } from './ssh-normalization' + +const base: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' } + +describe("loading a host's orca CLI control switch (#29145)", () => { + it('turns off a switch saved before it let the host drive the desktop browser, and says why', () => { + const loaded = normalizeSshTarget({ ...base, allowRemoteCliControl: true }) + + expect(loaded.allowRemoteCliControl).toBeUndefined() + expect(loaded.allowRemoteCliControlReset).toBe(true) + }) + + it('keeps a switch the user turned on with its current meaning', () => { + const loaded = normalizeSshTarget({ + ...base, + allowRemoteCliControl: true, + allowRemoteCliControlConsent: 'desktop-browser-bridge' + }) + + expect(loaded.allowRemoteCliControl).toBe(true) + expect(loaded.allowRemoteCliControlReset).toBeUndefined() + }) + + it('keeps the notice across loads until the switch is saved, and drops a stray consent', () => { + const reset = normalizeSshTarget(normalizeSshTarget({ ...base, allowRemoteCliControl: true })) + expect(reset.allowRemoteCliControlReset).toBe(true) + + const stray = normalizeSshTarget({ + ...base, + allowRemoteCliControlConsent: 'desktop-browser-bridge' + }) + expect(stray.allowRemoteCliControlConsent).toBeUndefined() + expect(stray.allowRemoteCliControl).toBeUndefined() + }) +}) diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts index b2cbb071545d..aab631fa85c1 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts @@ -66,19 +66,40 @@ export function normalizeSshTarget(t: SshTarget): SshTarget { if (remoteRuntimeResolution) { normalized.remoteRuntimeResolution = remoteRuntimeResolution } - return normalizeManagedServerLeaveBehind( - normalizeManagedServerMoveOffered( - normalizeAppVersionNote( + return normalizeRemoteCliControlConsent( + normalizeManagedServerLeaveBehind( + normalizeManagedServerMoveOffered( normalizeAppVersionNote( - migrateLegacyManagedOrcadOwner(normalized), - 'managedServerUnavailable' - ), - 'managedServerUpdateFailure' + normalizeAppVersionNote( + migrateLegacyManagedOrcadOwner(normalized), + 'managedServerUnavailable' + ), + 'managedServerUpdateFailure' + ) ) ) ) } +/** + * The switch did nothing from 1.4.225 until the desktop browser bridge gave it a new meaning, so a + * `true` saved before that is not consent to it. Turn it off once and say why in settings. + */ +function normalizeRemoteCliControlConsent(target: SshTarget): SshTarget { + const { + allowRemoteCliControl, + allowRemoteCliControlConsent, + allowRemoteCliControlReset, + ...rest + } = target + if (allowRemoteCliControl === true) { + return allowRemoteCliControlConsent === 'desktop-browser-bridge' + ? { ...rest, allowRemoteCliControl, allowRemoteCliControlConsent } + : { ...rest, allowRemoteCliControlReset: true } + } + return allowRemoteCliControlReset === true ? { ...rest, allowRemoteCliControlReset } : rest +} + /** * Phase-3 builds fenced a managed host through `owner`, which shipped builds hide. Moving the fence * to `orcadFence` keeps the host visible, and reachable over its relay, after a downgrade. diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index fc2b39e88af9..d1dbb1e311aa 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -58,6 +58,29 @@ describe('SshConnectionStore', () => { expect(mockStore.addSshTarget).toHaveBeenCalledWith(target) }) + it('records consent when the CLI control switch is saved on, and clears the reset notice (#29145)', () => { + const target = sshStore.addTarget({ + label: 'Box', + host: 'box', + port: 22, + username: 'me', + allowRemoteCliControl: true + }) + expect(target.allowRemoteCliControlConsent).toBe('desktop-browser-bridge') + + sshStore.updateTarget(target.id, { allowRemoteCliControl: false }) + expect(mockStore.updateSshTarget).toHaveBeenLastCalledWith( + target.id, + expect.objectContaining({ + allowRemoteCliControl: false, + allowRemoteCliControlConsent: undefined, + allowRemoteCliControlReset: undefined + }) + ) + sshStore.updateTarget(target.id, { label: 'Renamed' }) + expect(mockStore.updateSshTarget).toHaveBeenLastCalledWith(target.id, { label: 'Renamed' }) + }) + it('addTarget stamps source as manual by default', () => { const target = sshStore.addTarget({ label: 'My Server', diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index e2b6ba745506..bb6e61c40abc 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -37,7 +37,7 @@ export class SshConnectionStore { addTarget(target: Omit): SshTarget { const full: SshTarget = { - ...target, + ...withRemoteCliControlConsent(target), configHost: target.configHost ?? target.host, // Why: default to 'manual' so user-created targets are never overwritten // by a later ~/.ssh/config import (only 'ssh-config' targets are synced). @@ -102,9 +102,10 @@ export class SshConnectionStore { key in updates && existing?.[key] !== updates[key] const resetsRuntime = changed('remoteRuntime') || changed('host') || changed('port') || changed('configHost') + const consented = withRemoteCliControlConsent(updates) const updated = this.store.updateSshTarget( id, - resetsRuntime ? { ...updates, remoteRuntimeResolution: undefined } : updates + resetsRuntime ? { ...consented, remoteRuntimeResolution: undefined } : consented ) if (updated) { // Why: actively editing a target reclaims its alias from the deleted set, @@ -295,3 +296,16 @@ function isLegacyConfigImportTarget(target: SshTarget): boolean { alias && target.label === alias && target.configHost === alias && target.host !== alias ) } + +/** Saving the switch records consent to its current meaning and retires the reset notice. */ +function withRemoteCliControlConsent>(updates: T): T { + if (!('allowRemoteCliControl' in updates)) { + return updates + } + return { + ...updates, + allowRemoteCliControlConsent: + updates.allowRemoteCliControl === true ? 'desktop-browser-bridge' : undefined, + allowRemoteCliControlReset: undefined + } +} diff --git a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx index fcb5393dc720..b7b5f3c935ce 100644 --- a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx +++ b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx @@ -122,6 +122,14 @@ export function SshHostAdvancedFields({ "When on, orca commands run on this host can open and drive Orca browser tabs on this desktop, only in this host's workspaces and only while it is connected. They cannot read your files, clipboard or cookies. When off, they reach only this host's own Orca server." )}

+ {form.allowRemoteCliControlReset && !form.allowRemoteCliControl ? ( +

+ {translate( + 'auto.components.settings.SshTargetForm.allowRemoteCliControlReset', + 'Orca turned this off after an update, because it now lets this host drive your desktop browser. Turn it on again only if you want that.' + )} +

+ ) : null} Date: Thu, 15 Oct 2026 23:02:03 -0700 Subject: [PATCH 15/15] test(ssh): type the bridge registry deadline test --- .../desktop-browser-bridge-registry.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts index 77b393a9967f..53cf377a205d 100644 --- a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import type { DesktopBrowserBridgeEvent } from '../../../shared/desktop-browser-bridge-protocol' import { DesktopBrowserBridgeRegistry, MAX_PENDING_PER_ATTACHMENT @@ -23,11 +24,11 @@ describe('DesktopBrowserBridgeRegistry', () => { it('gives the desktop a deadline that ends before orcad stops waiting', () => { const registry = new DesktopBrowserBridgeRegistry() - const events: { timeoutMs?: number }[] = [] + const events: DesktopBrowserBridgeEvent[] = [] registry.attach({ connectionId: 'c', pairedDeviceId: 'd', emit: (event) => events.push(event) }) registry.forward({ method: 'browser.click', params: {} }, { timeoutMs: 60_000 }).catch(() => {}) - expect(events[0]?.timeoutMs).toBe(50_000) + expect(events[0]).toMatchObject({ type: 'request', timeoutMs: 50_000 }) }) })