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/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/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/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/agent-browser-bridge-queue.ts b/src/main/browser/agent-browser-bridge-queue.ts index 7c6bfe919e50..bf9826119691 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(target.webContentsId) + 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/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts index 4c86e1067f33..2c9507867e0f 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,46 @@ 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('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 d55dbc30d78c..66b351ccf2b0 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,15 +49,26 @@ 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) } 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 }) } @@ -159,7 +171,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 +184,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 new file mode 100644 index 000000000000..391c96216fb5 --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts @@ -0,0 +1,175 @@ +/** + * 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 +// 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 + onError(error: Error): void + onClose(): void +}) => Promise + +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, + signal: AbortSignal + ): 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.revoked.abort() + 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 { + // 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 + } + const params: DesktopBrowserBridgeResultParams = outcome.ok + ? { attachmentId, requestId: request.requestId, ok: true, result: outcome.result } + : { + attachmentId, + requestId: request.requestId, + ok: false, + error: { + code: outcome.error.code.slice(0, MAX_ERROR_CODE_LENGTH), + message: outcome.error.message.slice(0, MAX_ERROR_MESSAGE_LENGTH) + } + } + 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) + ) + } + return false + } + + 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..31b007d85eca --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.test.ts @@ -0,0 +1,618 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DESKTOP_BROWSER_BRIDGE_METHODS, + type DesktopBrowserBridgeRequest +} from '../../../shared/desktop-browser-bridge-protocol' +import { + boundRemoteCliBrowserWork, + captureRemoteCliBrowserRevocation, + resolveRemoteCliBrowserScopeSelector, + runInRemoteCliBrowserScope +} from '../../runtime/desktop-browser-bridge/remote-cli-browser-scope' +import { + executeDesktopBrowserBridgeRequest, + isHostReachableDocument, + type DesktopBrowserBridgeAuditEntry, + type DesktopBrowserBridgeExecutorDeps +} from './desktop-browser-bridge-executor' + +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 { + type: 'request', + requestId: 'r1', + method: 'browser.snapshot', + params: {}, + ...overrides + } +} + +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), + 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( + (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, driven } +} + +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 resolveWorkspace = vi.fn(async () => null) + const { value, dispatched } = deps({ resolveWorkspace }) + + await executeDesktopBrowserBridgeRequest( + request({ workspaceId: HOST_WORKSPACE, params: { worktree: `id:${HOST_WORKSPACE}` } }), + value + ) + + 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([]) + }) + + 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', + '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 + ) + + 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([]) + }) + + 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) + }) + + 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', () => { + it('does nothing outside a bridged command', () => { + 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() + }) +}) + +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() + }) +}) + +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() + }) +}) + +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', + '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 new file mode 100644 index 000000000000..a46c19eef5b2 --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-executor.ts @@ -0,0 +1,312 @@ +/** + * 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, + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, + DESKTOP_BROWSER_BRIDGE_UNROUTED_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' +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 } + | { 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 + /** + * 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 + /** 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 + /** 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 +} + +// 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, + /** Aborted when the bridge detaches; a command not yet dispatched must then never run. */ + signal?: AbortSignal +): Promise { + const audit: Omit = { + environmentId: deps.environmentId, + hostLabel: deps.hostLabel, + method: request.method, + workspaceId: null, + page: null, + urlOrigin: urlOrigin(request.params.url) + } + try { + 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), + signal, + (webContentsId) => deps.isHostBrowsingRouted() && deps.isGuestRoutedThroughHost(webContentsId) + ) + 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.` + ) + } + // 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) { + 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.' + ) + } + assertNoOptionLikeArguments(request.method, params) + 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 && + (!requestedWorkspaceId || params.worktree !== `id:${requestedWorkspaceId}`) + ) { + throw outOfScope() + } + const page = typeof params.page === 'string' && params.page.length > 0 ? params.page : null + 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.' + ) + } + 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`) + : outOfScope() + } + params.worktree = `id:${workspaceId}` + 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 + } +} + +// 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']) + +// 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('+') + .map((part) => part.trim()) + const key = parts.at(-1) + const modifiers = parts.slice(0, -1) + return ( + (key === 'v' && modifiers.some((modifier) => PASTE_MODIFIER_NAMES.has(modifier))) || + ((key === 'insert' || key === 'ins') && modifiers.includes('shift')) + ) +} + +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 + } +} + +// Why: a user can open a desktop file in a routed tab; its document is still this desktop's file. +// 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 { + 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 new file mode 100644 index 000000000000..2af864680d8d --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts @@ -0,0 +1,318 @@ +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, + 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.any(AbortSignal) + ) + 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('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() + 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) + }) + + 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)) + }) + + 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() + }) + + 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) + ) + }) + + 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) + ) + }) + + 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/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..a3df0e21df4a --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.ts @@ -0,0 +1,193 @@ +/** + * 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[] + /** False when the server answers without the capability; throws when it could not answer. */ + supportsBridge(environmentId: string): Promise + subscribe(environmentId: string): DesktopBrowserBridgeSubscribe + execute( + candidate: DesktopBrowserBridgeCandidate, + request: DesktopBrowserBridgeRequest, + signal: AbortSignal + ): 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) + } + } + } + + /** 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 + } + + 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: boolean + try { + supported = await this.deps.supportsBridge(environmentId) + } 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 + } + 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, signal) => this.deps.execute(entry.candidate, request, signal), + onReady: () => { + entry.failures = 0 + this.deps.log(`attached: host=${entry.candidate.hostLabel} env=${environmentId}`) + }, + onLost: (error) => { + if (entry.connection !== connection) { + return + } + entry.connection = null + 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 + } + 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..8a8b00b0076c --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-renderer-scope.ts @@ -0,0 +1,46 @@ +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 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 rendererResolveRemoteCliWorkspace( + getWindow: () => BrowserWindow | null, + query: { environmentId: string; workspaceId?: string; pageId?: string } +): Promise { + const win = getWindow() + if (!win || win.isDestroyed()) { + return Promise.resolve(null) + } + const requestId = randomUUID() + return new Promise((resolve) => { + const finish = (workspaceId: string | null): void => { + clearTimeout(timer) + ipcMain.removeListener('browser:remoteCliScopeReply', handler) + resolve(workspaceId) + } + 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( + 'workspaceId' in reply && typeof reply.workspaceId === 'string' && reply.workspaceId + ? reply.workspaceId + : null + ) + } + const timer = setTimeout(() => finish(null), SCOPE_REPLY_TIMEOUT_MS) + ipcMain.on('browser:remoteCliScopeReply', handler) + win.webContents.send('browser:requestRemoteCliScope', { requestId, ...query }) + }) +} 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 new file mode 100644 index 000000000000..87a328cf9c71 --- /dev/null +++ b/src/main/browser/desktop-browser-bridge/remote-cli-driven-workspaces.ts @@ -0,0 +1,33 @@ +/** + * 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 (drivenWorkspaceIds.size === 0) { + return false + } + // 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) +} + +export function resetRemoteCliDrivenWorkspacesForTests(): void { + drivenWorkspaceIds.clear() +} 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-reconcile.ts b/src/main/ipc/desktop-browser-bridge-reconcile.ts new file mode 100644 index 000000000000..dad23159885f --- /dev/null +++ b/src/main/ipc/desktop-browser-bridge-reconcile.ts @@ -0,0 +1,28 @@ +/** 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 setDesktopBrowserBridgeControl(next: DesktopBrowserBridgeControl | null): void { + control = next +} + +export function requestDesktopBrowserBridgeReconcile(): void { + try { + 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 new file mode 100644 index 000000000000..45b3945da356 --- /dev/null +++ b/src/main/ipc/desktop-browser-bridge-wiring.ts @@ -0,0 +1,209 @@ +/** + * 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 { webContents, 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, + 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' +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 { 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}`) +} + +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 + } + // 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 + } + 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 + }) + 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) + 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, 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, + isHostBrowsingRouted: () => isSshTargetBrowsingRouted(candidate.targetId), + isGuestRoutedThroughHost: (webContentsId) => { + const guest = webContents.fromId(webContentsId) + return Boolean( + guest && + !guest.isDestroyed() && + localSshBrowserTargetIdForSession(guest.session) === candidate.targetId && + isHostReachableDocument(guest.getURL()) + ) + }, + 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 + }) + // 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) + }) + 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 { + stopWatchingRoutingSettings?.() + stopWatchingRoutingSettings = 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/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/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/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..81e4de526d0f --- /dev/null +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-allowlist.test.ts @@ -0,0 +1,52 @@ +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.network', + '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..79725b7dcbe5 --- /dev/null +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-dispatch.test.ts @@ -0,0 +1,290 @@ +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 +} 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 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: [] })) + +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 = 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 = ForwardedEvent.parse(events[1]) + // 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.test.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts new file mode 100644 index 000000000000..53cf377a205d --- /dev/null +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import type { DesktopBrowserBridgeEvent } from '../../../shared/desktop-browser-bridge-protocol' +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) + }) + + it('gives the desktop a deadline that ends before orcad stops waiting', () => { + const registry = new DesktopBrowserBridgeRegistry() + 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]).toMatchObject({ type: 'request', timeoutMs: 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 new file mode 100644 index 000000000000..0f10f2423cf1 --- /dev/null +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts @@ -0,0 +1,205 @@ +/** + * 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' + +// 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 + 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, + // 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 { + 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: { + getRuntimeId(): string +}): 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..2a1ad031dde1 --- /dev/null +++ b/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts @@ -0,0 +1,138 @@ +/** + * 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, + DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE, + DESKTOP_BROWSER_BRIDGE_UNROUTED_CODE +} from '../../../shared/desktop-browser-bridge-protocol' + +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, + admitGuest?: (webContentsId: number) => boolean +): Promise { + 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(): (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." + ) + } + } +} + +/** 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.' + ) + ) +} + +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/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/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/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..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'), @@ -177,5 +181,16 @@ export const uiStateAndMenuCommandsApi = { }, replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }): void => { ipcRenderer.send('browser:tabCreateReply', reply) + }, + onRequestRemoteCliBrowserScope: ( + callback: (data: RemoteCliBrowserScopeQuery) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: RemoteCliBrowserScopeQuery) => + callback(data) + ipcRenderer.on('browser:requestRemoteCliScope', listener) + return () => ipcRenderer.removeListener('browser:requestRemoteCliScope', listener) + }, + 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 89dc1ba22b18..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 } @@ -116,6 +120,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: RemoteCliBrowserScopeQuery) => void + ) => () => void + replyRemoteCliBrowserScope: (reply: RemoteCliBrowserScopeReply) => 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..b7b5f3c935ce 100644 --- a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx +++ b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx @@ -119,9 +119,17 @@ 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." )}

+ {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} () => {}, 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..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 @@ -3,17 +3,25 @@ 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 + pageId?: 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 +93,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) => { @@ -107,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)) @@ -119,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)) @@ -133,3 +175,78 @@ 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', 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 } + ]) + }) + + 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', + workspaceId: null + }) + }) +}) 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..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 @@ -9,7 +9,9 @@ import { useAppStore } from '../../store' import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' import { findBrowserTabWorktreeId, - isBrowserWorkspaceOwnedByPairedServer + isBrowserWorkspaceOwnedByPairedServer, + isDesktopRenderedBrowserTab, + resolveRemoteCliBrowserWorkspace } from './browser-request-workspace-owner' function pairedServerWorkspaceError(): string { @@ -22,6 +24,18 @@ 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 workspaceId: string | null = null + try { + workspaceId = resolveRemoteCliBrowserWorkspace(useAppStore.getState(), data) + } catch { + // Why: an owner this window cannot prove is a refusal, never a pass. + workspaceId = null + } + window.api.ui.replyRemoteCliBrowserScope({ requestId: data.requestId, workspaceId }) + }) + ) unsubs.push( window.api.ui.onRequestTabCreate((data) => { try { @@ -60,6 +74,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. @@ -182,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 78a819fbeae3..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 @@ -18,6 +18,66 @@ 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 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 +} + +/** + * 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/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..a174bca3ce6b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8985,7 +8985,8 @@ "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.", + "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.", "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..02877c56079b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8712,7 +8712,8 @@ "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.", + "allowRemoteCliControlReset": "Orca lo desactivó tras una actualización, porque ahora permite que este host controle el navegador de tu escritorio. Vuelve a activarlo solo si lo deseas." }, "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..b1d576f7c947 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -8862,7 +8862,8 @@ "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.", + "allowRemoteCliControlReset": "Orca l'a désactivé après une mise à jour, car il permet désormais à cet hôte de piloter le navigateur de votre bureau. Réactivez-le seulement si vous le souhaitez." }, "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..909b137c14c0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8738,7 +8738,8 @@ "addDescription": "SSH でログインできる永続的なマシンを追加します。", "editingPrefix": "編集中", "allowRemoteCliControl": "このホストの orca CLI による Orca の操作を許可する", - "allowRemoteCliControlHelp": "オフの場合、このホストで実行される orca コマンドは、このホスト自身のターミナルにのみアクセスできます。オンにすると、このコンピュータ上の他のホスト、オーケストレーション、ファイル、連携にもアクセスできるようになります。" + "allowRemoteCliControlHelp": "オンにすると、このホストで実行される orca コマンドは、このホストのワークスペース内に限り、接続中のみ、このデスクトップの Orca ブラウザのタブを開いて操作できます。ファイル、クリップボード、Cookie は読み取れません。オフの場合、このホスト自身の Orca サーバーにのみアクセスします。", + "allowRemoteCliControlReset": "アップデート後に Orca がこれをオフにしました。この設定でこのホストがデスクトップのブラウザを操作できるようになったためです。必要な場合のみ再度オンにしてください。" }, "TasksPane": { "f71d8a9dd3": "タスクプロバイダー", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 652d0dc074dd..d967957403f0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8701,7 +8701,8 @@ "addDescription": "SSH로 로그인할 수 있는 영구 머신을 추가합니다.", "editingPrefix": "편집 중", "allowRemoteCliControl": "이 호스트의 orca CLI가 Orca를 제어하도록 허용", - "allowRemoteCliControlHelp": "꺼져 있으면 이 호스트에서 실행한 orca 명령은 이 호스트의 터미널에만 적용됩니다. 켜면 다른 호스트와 이 컴퓨터의 오케스트레이션, 파일 및 연동 기능에도 접근할 수 있습니다." + "allowRemoteCliControlHelp": "켜면 이 호스트에서 실행한 orca 명령이 이 호스트의 작업 공간에서만, 연결되어 있는 동안에만 이 데스크톱의 Orca 브라우저 탭을 열고 조작할 수 있습니다. 파일, 클립보드, 쿠키는 읽을 수 없습니다. 끄면 이 호스트 자체의 Orca 서버에만 접근합니다.", + "allowRemoteCliControlReset": "업데이트 후 Orca가 이 설정을 껐습니다. 이제 이 호스트가 데스크톱 브라우저를 조작할 수 있게 되었기 때문입니다. 원하는 경우에만 다시 켜세요." }, "TasksPane": { "f71d8a9dd3": "작업 제공자", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ab5c567a0236..810bcfcff7a9 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8701,7 +8701,8 @@ "addDescription": "添加一台可通过 SSH 登录的持久机器。", "editingPrefix": "正在编辑", "allowRemoteCliControl": "允许此主机的 orca CLI 控制 Orca", - "allowRemoteCliControlHelp": "关闭时,在此主机上运行的 orca 命令只能访问其自身的终端。开启后,可让它们访问此计算机上的其他主机、编排、文件和集成。" + "allowRemoteCliControlHelp": "开启后,在此主机上运行的 orca 命令可以打开并操作此桌面上的 Orca 浏览器标签页,但仅限此主机的工作区,且仅在主机连接期间。它们无法读取你的文件、剪贴板或 Cookie。关闭时,它们只能访问此主机自身的 Orca 服务器。", + "allowRemoteCliControlReset": "更新后 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..17425962c6b6 --- /dev/null +++ b/src/shared/desktop-browser-bridge-protocol.ts @@ -0,0 +1,146 @@ +/** + * 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_UNROUTED_CODE = 'desktop_browser_bridge_unrouted' + +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, 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', + '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.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' +]) + +/** 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) +} + +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 +}) + +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(), + 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/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 03cccd89b34f..c2b412819beb 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -69,9 +69,14 @@ export type SshTarget = { /** Grace period in seconds before relay shuts down after disconnect. * 0 disables expiry. Default: 0 (until reset). Max: 604800 (7 days). */ relayGracePeriodSeconds?: number - /** Lets this host's relayed `orca` CLI control this Orca beyond its own terminals (other hosts, - * orchestration, files). Off by default: the host is a guest, not an owner. */ + /** Lets this host's `orca` CLI drive this desktop's Orca browser tabs in the host's own + * workspaces (desktop browser bridge). Off by default: the host is a guest, not an owner. */ allowRemoteCliControl?: boolean + /** Stamped when the switch is turned on with that meaning. A `true` without it was set while the + * switch did nothing (1.4.225 onward), so load turns it off and asks for a fresh opt-in. */ + allowRemoteCliControlConsent?: 'desktop-browser-bridge' + /** Orca turned the switch off for that reason; settings say why until the switch is saved again. */ + allowRemoteCliControlReset?: boolean /** Set to true after a successful connection that triggered a credential * prompt (passphrase or password). Persisted so startup reconnect can * partition targets into eager (no passphrase) vs deferred (passphrase) 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..768fb990cad2 --- /dev/null +++ b/tests/e2e/ssh-orcad-desktop-browser-bridge.spec.ts @@ -0,0 +1,227 @@ +/** + * #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 { z } from 'zod' +import { expect, forwardElectronProcessLogs, 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() + +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 + +/** 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 CliResult.parse(JSON.parse(execDockerSshHostTargetCommand(target, `cat ${out}`))) +} + +/** 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 { + 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 { + 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 + forwardElectronProcessLogs(app, testInfo) + 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') + // Like an agent: navigate the tab it opened, then read it back. + const navigatedUrl = `${SSH_REMOTE_ONLY_ORIGIN}/echo/bridge-navigated` + await hostCliUntilOk( + page, + target, + tabId, + `orca goto --url ${navigatedUrl} --page ${browserPageId}` + ) + await expect + .poll(() => readSshRemoteOnlyRequests(target).map((request) => request.path), { + timeout: 60_000, + 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) + 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) + 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) + } + } +})