Files
orca/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts
T
Jinwoo-H f283f88bd8 fix(mobile-web): confine file: browser creates to the named workspace
`session.createBrowser` is reachable from unprivileged page script and the page
it creates is streamed back over `browser.screencast`, so a `file:` URL turned
any host file into frames the page could decode. `browser.navigate` already
refused `file:` and result URLs were redacted; only the create path was open.

Both sides of the call now fence it instead of banning the scheme, because the
native HTML-artifact file tap is the same call:

- the shell re-resolves the path through `files.resolveTerminalPath` against the
  workspace the page named and forwards the host's own absolute path, so nothing
  the page wrote reaches `browser.tabCreate`, and an SSH worktree (whose path is
  on another machine) is refused;
- the runtime independently requires a paired caller's `file:` URL to sit inside
  that worktree's root on this host, so the page is not the only fence.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-04 02:21:13 -04:00

194 lines
6.8 KiB
TypeScript

// @ts-nocheck -- mechanically split declarations.
import type { BrowserScreencastResult } from '../../shared/runtime-types'
import type {
BrowserScreencastFrameBudget,
BrowserScreencastSession,
BrowserScreencastViewport
} from '../browser/browser-screencast-stream-types'
import type { ScreencastSubscriberDeliveryState } from './browser-screencast-ghost-subscriber-eviction'
import {
browserScreencastFrameBudgetsEqual,
mergeBrowserScreencastFrameBudgets
} from '../browser/browser-screencast-frame-budget'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol'
import type { BrowserHostLeaseRegistry } from './browser-host-lease-registry'
import type { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
import type { BrowserWindow } from 'electron'
import type { BrowserBackend } from '../browser/browser-backend'
import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-publication'
export type BrowserCommandTargetParams = {
worktree?: string
page?: string
}
export type ResolvedBrowserCommandTarget = {
worktreeId?: string
browserPageId?: string
}
export type ResolvedBrowserPageWebContents = {
browserPageId: string
webContents: Electron.WebContents
}
export type BrowserScreencastParams = {
format: 'jpeg' | 'png'
quality?: number
maxWidth?: number
maxHeight?: number
viewportWidth?: number
viewportHeight?: number
deviceScaleFactor?: number
mobile?: boolean
everyNthFrame?: number
minFrameIntervalMs?: number
} & BrowserCommandTargetParams
export type BrowserScreencastStartResult = {
subscriptionId: string
ready: Extract<BrowserScreencastResult, { type: 'ready' }>
// The frame budget belongs to the shared page, not to one subscriber's handle.
session: Omit<BrowserScreencastSession, 'updateFrameBudget'>
// Why: callers gate frames until they have emitted `ready`, and the snapshot captured
// for a joining subscriber lands inside that window. This replays it once the gate opens.
flushPendingFrame: () => void
}
export type ActiveBrowserScreencastSubscriber = {
sendBinary: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
emit?: (event: BrowserScreencastResult) => void
done: Promise<void>
resolveDone: () => void
viewport: BrowserScreencastViewport
budget: BrowserScreencastFrameBudget
pendingFrame: Uint8Array<ArrayBufferLike> | null
// Why: identifies the viewer across reconnects, which the RPC connectionId cannot — a new
// socket never reuses the old id, so a reconnecting device would stack a second subscription.
pairedDeviceId?: string
delivery: ScreencastSubscriberDeliveryState
}
export type ActiveBrowserScreencastPage = {
format: 'jpeg' | 'png'
session: BrowserScreencastSession | null
started: Promise<BrowserScreencastSession>
stopping: boolean
subscribers: Map<string, ActiveBrowserScreencastSubscriber>
viewportOwnerSubscriptionId: string | null
appliedBudget: BrowserScreencastFrameBudget
}
export async function applySharedScreencastFrameBudget(
active: ActiveBrowserScreencastPage,
session: BrowserScreencastSession
): Promise<void> {
const merged = mergeBrowserScreencastFrameBudgets(
Array.from(active.subscribers.values(), (subscriber) => subscriber.budget)
)
if (!merged || browserScreencastFrameBudgetsEqual(merged, active.appliedBudget)) {
return
}
active.appliedBudget = merged
await session.updateFrameBudget(merged)
}
export function normalizeScreencastViewport(
params: BrowserScreencastParams
): BrowserScreencastViewport {
return {
viewportWidth: clampOptionalInteger(params.viewportWidth, 320, 3840),
viewportHeight: clampOptionalInteger(params.viewportHeight, 240, 2160),
deviceScaleFactor: clampOptionalNumber(params.deviceScaleFactor, 1, 4),
mobile: params.mobile === true
}
}
export function normalizeScreencastFrameBudget(
params: BrowserScreencastParams
): BrowserScreencastFrameBudget {
return {
quality: clampInteger(params.quality, 10, 100, 70),
maxWidth: clampInteger(params.maxWidth, 320, 3840, 1440),
maxHeight: clampInteger(params.maxHeight, 240, 2160, 1200),
everyNthFrame: clampInteger(params.everyNthFrame, 1, 10, 2),
minFrameIntervalMs: clampInteger(params.minFrameIntervalMs, 0, 1000, 0)
}
}
export function hasScreencastViewportSize(viewport: BrowserScreencastViewport): boolean {
return viewport.viewportWidth !== undefined && viewport.viewportHeight !== undefined
}
export function clampInteger(
value: number | undefined,
min: number,
max: number,
fallback: number
): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback
}
return Math.min(max, Math.max(min, Math.round(value)))
}
export function clampOptionalInteger(
value: number | undefined,
min: number,
max: number
): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined
}
return Math.min(max, Math.max(min, Math.round(value)))
}
export function clampOptionalNumber(
value: number | undefined,
min: number,
max: number
): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined
}
return Math.min(max, Math.max(min, value))
}
export type RuntimeBrowserCommandHost = {
getAgentBrowserBridge(): AgentBrowserBridge | null
resolveWorktreeSelector(selector: string): Promise<{
id: string
repoId?: string
hostId?: ExecutionHostId
path?: string
}>
resolveBrowserWorkspace(selector: string): Promise<{
id: string
repoId?: string
hostId?: ExecutionHostId
path?: string
}>
resolveBrowserNetworkExecutionHost(worktree?: {
id: string
repoId?: string
hostId?: ExecutionHostId
}): BrowserNetworkExecutionHost | Promise<BrowserNetworkExecutionHost>
getBrowserHostLeaseRegistry(): BrowserHostLeaseRegistry
getRuntimeBrowserPageRegistry(): RuntimeBrowserPageRegistry
getAuthoritativeWindow(): BrowserWindow
getAvailableAuthoritativeWindow(): BrowserWindow | null
// Why: headless serve backs pages with a main-process offscreen backend; null when the environment can't support offscreen browsing.
getOffscreenBrowserBackend(): BrowserBackend | null
// Why: the session-tab snapshot owns focus, so a headless create must mark itself active or paired clients snap back to a terminal.
markHeadlessBrowserSessionTabActive?(
worktreeId: string | undefined,
browserPageId: string,
options?: BrowserSessionTabSelectionOptions
): void
notifyHeadlessBrowserSessionTabsChanged?(worktreeId: string): void
/** True when a runtime-owned session row for that page existed and was retired. */
retireRuntimeOwnedBrowserSessionTab?(worktreeId: string, browserPageId: string): boolean | void
}