mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { executeMobileWebSessionOperation } from './mobile-web-session-operations'
|
||||
import { MobileWebBrowserAuthority } from './mobile-web-browser-authority'
|
||||
import { MobileWebNativeChatAuthority } from './mobile-web-native-chat-authority'
|
||||
import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
|
||||
|
||||
const WORKTREE_PATH = '/tmp/worktree-a'
|
||||
|
||||
/**
|
||||
* `session.createBrowser` is reachable from unprivileged page script, and the page it creates is
|
||||
* streamed back over `browser.screencast`. The shell is the trusted side of that call, so it — not
|
||||
* the page — decides which filesystem path a `file:` create may name.
|
||||
*/
|
||||
function resolveTerminalPathReply(pathText: string) {
|
||||
if (!pathText.startsWith(`${WORKTREE_PATH}/`)) {
|
||||
return { ok: true as const, result: { worktree: 'workspace-1', exists: false } }
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
result: {
|
||||
worktree: 'workspace-1',
|
||||
relativePath: pathText.slice(WORKTREE_PATH.length + 1),
|
||||
absolutePath: pathText,
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: pathText.slice(WORKTREE_PATH.length + 1),
|
||||
absolutePath: pathText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createClient(overrides?: { provider?: 'local' | 'ssh' }) {
|
||||
const sendRequest = vi.fn<RpcClient['sendRequest']>(async (method, params) => {
|
||||
if (method === 'files.resolveTerminalPath') {
|
||||
const reply = resolveTerminalPathReply((params as { pathText: string }).pathText)
|
||||
if (overrides?.provider && 'openTarget' in reply.result && reply.result.openTarget) {
|
||||
reply.result.openTarget.provider = overrides.provider
|
||||
}
|
||||
return reply
|
||||
}
|
||||
if (method === 'browser.tabCreate') {
|
||||
return { ok: true, result: { browserPageId: 'page-1' } }
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`)
|
||||
})
|
||||
return sendRequest
|
||||
}
|
||||
|
||||
function operationArgs(sendRequest: RpcClient['sendRequest']) {
|
||||
const workspaceAuthority = new MobileWebWorkspaceAuthority((length) => new Uint8Array(length))
|
||||
workspaceAuthority.synchronize([{ workspaceId: 'workspace-1', repoId: 'repo-1' }])
|
||||
return {
|
||||
operation: 'createBrowser',
|
||||
requestId: 'R'.repeat(22),
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
workspaceAuthority,
|
||||
browserAuthority: new MobileWebBrowserAuthority((length) => new Uint8Array(length)),
|
||||
nativeChatAuthority: new MobileWebNativeChatAuthority((length) => new Uint8Array(length)),
|
||||
workspaceId: workspaceAuthority.pageWorkspaceId('workspace-1')
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile web createBrowser file: confinement', () => {
|
||||
it('refuses a file: URL the host does not resolve inside that workspace', async () => {
|
||||
const sendRequest = createClient()
|
||||
const args = operationArgs(sendRequest)
|
||||
await expect(
|
||||
executeMobileWebSessionOperation({
|
||||
...args,
|
||||
payload: { workspaceId: args.workspaceId, url: 'file:///Users/dev/.ssh/id_rsa' }
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'invalid_request' })
|
||||
expect(sendRequest).not.toHaveBeenCalledWith('browser.tabCreate', expect.anything())
|
||||
})
|
||||
|
||||
it('opens a workspace HTML artifact and forwards the host path, not the page string', async () => {
|
||||
const sendRequest = createClient()
|
||||
const args = operationArgs(sendRequest)
|
||||
await expect(
|
||||
executeMobileWebSessionOperation({
|
||||
...args,
|
||||
payload: {
|
||||
workspaceId: args.workspaceId,
|
||||
url: `file://${WORKTREE_PATH}/build/report.html#frag`
|
||||
}
|
||||
})
|
||||
).resolves.toMatchObject({ workspaceId: args.workspaceId })
|
||||
expect(sendRequest).toHaveBeenCalledWith('browser.tabCreate', {
|
||||
worktree: 'id:workspace-1',
|
||||
url: `file://${WORKTREE_PATH}/build/report.html`,
|
||||
activate: true
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses an SSH workspace file, which names a path on another machine', async () => {
|
||||
const sendRequest = createClient({ provider: 'ssh' })
|
||||
const args = operationArgs(sendRequest)
|
||||
await expect(
|
||||
executeMobileWebSessionOperation({
|
||||
...args,
|
||||
payload: {
|
||||
workspaceId: args.workspaceId,
|
||||
url: `file://${WORKTREE_PATH}/build/report.html`
|
||||
}
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'invalid_request' })
|
||||
expect(sendRequest).not.toHaveBeenCalledWith('browser.tabCreate', expect.anything())
|
||||
})
|
||||
|
||||
it('leaves an https create untouched', async () => {
|
||||
const sendRequest = createClient()
|
||||
const args = operationArgs(sendRequest)
|
||||
await executeMobileWebSessionOperation({
|
||||
...args,
|
||||
payload: { workspaceId: args.workspaceId, url: 'https://example.com/' }
|
||||
})
|
||||
expect(sendRequest).not.toHaveBeenCalledWith(
|
||||
'files.resolveTerminalPath',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledWith('browser.tabCreate', {
|
||||
worktree: 'id:workspace-1',
|
||||
url: 'https://example.com/',
|
||||
activate: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { fileUriToFilesystemPath, filesystemPathToFileUri } from '../../../src/shared/file-uri-path'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MobileWebBrokerError } from './mobile-web-broker-error'
|
||||
|
||||
const RESOLVE_TIMEOUT_MS = 10_000
|
||||
|
||||
export function isMobileWebBrowserFileUrl(url: string): boolean {
|
||||
try {
|
||||
return new URL(url).protocol === 'file:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The hosted page is untrusted, so a `file:` browser create it asks for is only honoured when the
|
||||
* host itself resolves the path back to a file inside that workspace's root. The returned URL is
|
||||
* rebuilt from the host's own absolute path, so nothing the page wrote reaches `browser.tabCreate`.
|
||||
*/
|
||||
export async function confineMobileWebBrowserFileUrl(args: {
|
||||
url: string
|
||||
hostWorkspaceId: string
|
||||
client: RpcClient
|
||||
}): Promise<string> {
|
||||
let pathText: string
|
||||
try {
|
||||
pathText = fileUriToFilesystemPath(new URL(args.url)) ?? ''
|
||||
} catch {
|
||||
throw new MobileWebBrokerError('invalid_request')
|
||||
}
|
||||
if (!pathText) {
|
||||
throw new MobileWebBrokerError('invalid_request')
|
||||
}
|
||||
const response = await args.client.sendRequest(
|
||||
'files.resolveTerminalPath',
|
||||
{ worktree: `id:${args.hostWorkspaceId}`, pathText },
|
||||
{ timeoutMs: RESOLVE_TIMEOUT_MS }
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new MobileWebBrokerError('host_error')
|
||||
}
|
||||
const resolved = response.result
|
||||
if (
|
||||
!isRecord(resolved) ||
|
||||
resolved.worktree !== args.hostWorkspaceId ||
|
||||
resolved.exists !== true ||
|
||||
resolved.isDirectory !== false ||
|
||||
!isRecord(resolved.openTarget) ||
|
||||
resolved.openTarget.kind !== 'worktree-file' ||
|
||||
// Why: browser.tabCreate opens the URL on the runtime host, so an SSH worktree's path would
|
||||
// name an unrelated local file (or none) on the desktop that renders it.
|
||||
resolved.openTarget.provider !== 'local' ||
|
||||
typeof resolved.openTarget.absolutePath !== 'string' ||
|
||||
resolved.openTarget.absolutePath.length < 1 ||
|
||||
resolved.openTarget.absolutePath.length > 4096
|
||||
) {
|
||||
throw new MobileWebBrokerError('invalid_request')
|
||||
}
|
||||
return filesystemPathToFileUri(resolved.openTarget.absolutePath)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -22,6 +22,10 @@ import { parseRuntimeStatusCapabilities } from '../transport/runtime-capability-
|
||||
import { projectHostSessionRuntimeCapabilities } from '../session/host-session-runtime-capabilities'
|
||||
import { loadMobileNewTabAgentOptions } from '../session/mobile-new-tab-agent-loader'
|
||||
import type { MobileWebBrowserAuthority } from './mobile-web-browser-authority'
|
||||
import {
|
||||
confineMobileWebBrowserFileUrl,
|
||||
isMobileWebBrowserFileUrl
|
||||
} from './mobile-web-browser-file-url-confinement'
|
||||
import type { MobileWebNativeChatAuthority } from './mobile-web-native-chat-authority'
|
||||
import { MobileWebBrokerError } from './mobile-web-broker-error'
|
||||
import { mobileWebSessionSnapshot } from './mobile-web-session-snapshot'
|
||||
@@ -176,9 +180,16 @@ export async function executeMobileWebSessionOperation(args: {
|
||||
if (args.operation === 'createBrowser') {
|
||||
const payload = MobileWebSessionBrowserCreatePayloadSchema.parse(args.payload)
|
||||
const hostWorkspaceId = args.workspaceAuthority.hostWorkspaceId(payload.workspaceId)
|
||||
const url = isMobileWebBrowserFileUrl(payload.url)
|
||||
? await confineMobileWebBrowserFileUrl({
|
||||
url: payload.url,
|
||||
hostWorkspaceId,
|
||||
client: args.client
|
||||
})
|
||||
: payload.url
|
||||
const response = await args.client.sendRequest('browser.tabCreate', {
|
||||
worktree: `id:${hostWorkspaceId}`,
|
||||
url: payload.url,
|
||||
url,
|
||||
activate: true
|
||||
})
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertPairedBrowserTabCreateFileUrlAllowed } from './browser-tab-create-file-url-confinement'
|
||||
|
||||
const WORKTREE = { id: 'wt-1', path: '/Users/dev/code/orca' }
|
||||
|
||||
describe('paired browser.tabCreate file: confinement', () => {
|
||||
it('allows a file inside the workspace root, the native HTML-artifact open', () => {
|
||||
expect(() =>
|
||||
assertPairedBrowserTabCreateFileUrlAllowed({
|
||||
url: 'file:///Users/dev/code/orca/build/report.html',
|
||||
pairedCaller: true,
|
||||
worktree: WORKTREE
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('refuses a path outside the workspace root', () => {
|
||||
expect(() =>
|
||||
assertPairedBrowserTabCreateFileUrlAllowed({
|
||||
url: 'file:///Users/dev/.ssh/id_rsa',
|
||||
pairedCaller: true,
|
||||
worktree: WORKTREE
|
||||
})
|
||||
).toThrow(/outside the requested workspace/)
|
||||
})
|
||||
|
||||
it('refuses a sibling directory that shares the root prefix', () => {
|
||||
expect(() =>
|
||||
assertPairedBrowserTabCreateFileUrlAllowed({
|
||||
url: 'file:///Users/dev/code/orca-secrets/env',
|
||||
pairedCaller: true,
|
||||
worktree: WORKTREE
|
||||
})
|
||||
).toThrow(/outside the requested workspace/)
|
||||
})
|
||||
|
||||
it('refuses a traversal escape that percent-encodes its separators', () => {
|
||||
expect(() =>
|
||||
assertPairedBrowserTabCreateFileUrlAllowed({
|
||||
url: 'file:///Users/dev/code/orca/%2e%2e/%2e%2e/.ssh/id_rsa',
|
||||
pairedCaller: true,
|
||||
worktree: WORKTREE
|
||||
})
|
||||
).toThrow(/outside the requested workspace/)
|
||||
})
|
||||
|
||||
it('refuses a file: create with no workspace to confine it to', () => {
|
||||
expect(() =>
|
||||
assertPairedBrowserTabCreateFileUrlAllowed({
|
||||
url: 'file:///etc/passwd',
|
||||
pairedCaller: true,
|
||||
worktree: undefined
|
||||
})
|
||||
).toThrow(/requires an explicit workspace/)
|
||||
})
|
||||
|
||||
it('refuses a remote workspace, whose path names another machine', () => {
|
||||
expect(() =>
|
||||
assertPairedBrowserTabCreateFileUrlAllowed({
|
||||
url: 'file:///Users/dev/code/orca/build/report.html',
|
||||
pairedCaller: true,
|
||||
worktree: { ...WORKTREE, hostId: 'ssh:box' }
|
||||
})
|
||||
).toThrow(/remote workspace/)
|
||||
})
|
||||
|
||||
it('allows a folder workspace on the local host', () => {
|
||||
expect(() =>
|
||||
assertPairedBrowserTabCreateFileUrlAllowed({
|
||||
url: 'file:///Users/dev/notes/index.html',
|
||||
pairedCaller: true,
|
||||
worktree: { id: 'folder-1', path: '/Users/dev/notes', hostId: 'local' }
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('leaves http(s) and local callers alone', () => {
|
||||
expect(() =>
|
||||
assertPairedBrowserTabCreateFileUrlAllowed({
|
||||
url: 'https://example.com',
|
||||
pairedCaller: true,
|
||||
worktree: undefined
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
assertPairedBrowserTabCreateFileUrlAllowed({
|
||||
url: 'file:///etc/passwd',
|
||||
pairedCaller: false,
|
||||
worktree: undefined
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { fileUriToFilesystemPath } from '../../shared/file-uri-path'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { BrowserError } from '../browser/browser-error'
|
||||
|
||||
export type BrowserTabCreateWorktreeTarget = {
|
||||
id: string
|
||||
path?: string
|
||||
hostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export function isBrowserTabCreateFileUrl(url: string): boolean {
|
||||
try {
|
||||
return new URL(url).protocol === 'file:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A paired client (phone, hosted mobile page, remote UI) is not trusted to name a filesystem path:
|
||||
* its `file:` create only renders a file inside the workspace it named, on this host. Local callers
|
||||
* keep their existing reach, and the screencast that streams the render back never leaves that root.
|
||||
*/
|
||||
export function assertPairedBrowserTabCreateFileUrlAllowed(input: {
|
||||
url: string
|
||||
pairedCaller: boolean
|
||||
worktree: BrowserTabCreateWorktreeTarget | undefined
|
||||
}): void {
|
||||
if (!input.pairedCaller || !isBrowserTabCreateFileUrl(input.url)) {
|
||||
return
|
||||
}
|
||||
const root = input.worktree?.path
|
||||
if (!root) {
|
||||
throw new BrowserError(
|
||||
'forbidden',
|
||||
'A file:// browser page requires an explicit workspace on this host.'
|
||||
)
|
||||
}
|
||||
if (input.worktree?.hostId !== undefined && input.worktree.hostId !== LOCAL_EXECUTION_HOST_ID) {
|
||||
// Why: the URL would be opened against this host's filesystem, where a remote workspace's path
|
||||
// names a different file (or none) than the one the caller asked for.
|
||||
throw new BrowserError(
|
||||
'forbidden',
|
||||
'A file:// browser page is not available for a remote workspace.'
|
||||
)
|
||||
}
|
||||
let candidate: string | null
|
||||
try {
|
||||
candidate = fileUriToFilesystemPath(new URL(input.url))
|
||||
} catch {
|
||||
candidate = null
|
||||
}
|
||||
if (!candidate || !isPathInsideOrEqual(root, candidate)) {
|
||||
throw new BrowserError('forbidden', 'That file is outside the requested workspace.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* A paired client reaches `browser.tabCreate` with a caller-supplied URL, and the page it creates is
|
||||
* streamed back over `browser.screencast`. Without a fence, `file:///…/id_rsa` renders any file on
|
||||
* the host into the caller's frames. The native HTML-artifact open is the same call, so the fence
|
||||
* has to be a workspace-root containment check rather than a scheme ban.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { setRuntimeBrowserCommandsFactory } from './runtime-browser-commands-factory'
|
||||
|
||||
const { browserSessionRegistryMock } = vi.hoisted(() => ({
|
||||
browserSessionRegistryMock: {
|
||||
getDefaultProfile: () => ({
|
||||
id: 'default',
|
||||
partition: 'persist:orca-browser'
|
||||
}),
|
||||
getProfile: () => ({ id: 'default', partition: 'persist:orca-browser' }),
|
||||
resolveKnownPartition: () => 'persist:orca-browser'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
handle: vi.fn(),
|
||||
removeHandler: vi.fn()
|
||||
},
|
||||
webContents: { fromId: vi.fn() }
|
||||
}))
|
||||
vi.mock('../browser/browser-session-registry', () => ({
|
||||
browserSessionRegistry: browserSessionRegistryMock
|
||||
}))
|
||||
|
||||
const WORKTREE_PATH = '/tmp/worktree-a'
|
||||
const WT = `repo-1::${WORKTREE_PATH}`
|
||||
|
||||
const storeBase = {
|
||||
getRepo: () => ({
|
||||
id: 'repo-1',
|
||||
path: '/tmp/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
}),
|
||||
getRepos: () => [storeBase.getRepo()],
|
||||
addRepo: () => {},
|
||||
updateRepo: () => undefined as never,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
getGitHubCache: () => ({ pr: {}, issue: {} }),
|
||||
setWorktreeMeta: () => undefined as never,
|
||||
removeWorktreeMeta: () => {},
|
||||
getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: [] }),
|
||||
addRetiredWorktreeName: () => {},
|
||||
mergeRetiredWorktreeNames: () => false,
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/tmp/workspaces',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
})
|
||||
}
|
||||
|
||||
function makeSession(): WorkspaceSessionState {
|
||||
return {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: WT,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: { [WT]: [] },
|
||||
terminalLayoutsByTabId: {}
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntime(worktree: { id: string; path?: string; hostId?: string }) {
|
||||
let session = makeSession()
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...storeBase,
|
||||
getWorkspaceSession: () => session,
|
||||
setWorkspaceSession: (next: WorkspaceSessionState) => {
|
||||
session = next
|
||||
}
|
||||
})
|
||||
const createTab = vi.fn(async (options: { browserPageId?: string }) => ({
|
||||
browserPageId: options.browserPageId ?? 'page-new'
|
||||
}))
|
||||
const internals = runtime as unknown as {
|
||||
offscreenBrowserBackend: unknown
|
||||
agentBrowserBridge: unknown
|
||||
resolveWorktreeSelector: (selector: string) => Promise<typeof worktree>
|
||||
}
|
||||
internals.resolveWorktreeSelector = async () => worktree
|
||||
internals.offscreenBrowserBackend = { closeTab: vi.fn(), createTab }
|
||||
internals.agentBrowserBridge = {
|
||||
tabList: vi.fn(() => ({ tabs: [] })),
|
||||
getRegisteredTabs: vi.fn(() => new Map()),
|
||||
setActiveTab: vi.fn()
|
||||
}
|
||||
return { runtime, createTab }
|
||||
}
|
||||
|
||||
function create(
|
||||
runtime: OrcaRuntimeService,
|
||||
url: string,
|
||||
caller?: { pairedDeviceId?: string; clientKind?: 'mobile' | 'runtime' }
|
||||
): Promise<{ browserPageId: string }> {
|
||||
return runtime.browserTabCreate(
|
||||
{
|
||||
worktree: `id:${WT}`,
|
||||
page: 'page-new',
|
||||
url,
|
||||
activate: true,
|
||||
navigation: 'caller'
|
||||
},
|
||||
caller
|
||||
)
|
||||
}
|
||||
|
||||
describe('browser.tabCreate file: URLs from a paired client', () => {
|
||||
beforeAll(async () => {
|
||||
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
|
||||
setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host))
|
||||
return () => setRuntimeBrowserCommandsFactory(null)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
return () => vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('refuses a paired file: create outside the named workspace and creates no page', async () => {
|
||||
const { runtime, createTab } = createRuntime({
|
||||
id: WT,
|
||||
path: WORKTREE_PATH
|
||||
})
|
||||
await expect(
|
||||
create(runtime, 'file:///tmp/secrets/id_rsa', {
|
||||
pairedDeviceId: 'device-1',
|
||||
clientKind: 'mobile'
|
||||
})
|
||||
).rejects.toThrow(/outside the requested workspace/)
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still opens an HTML artifact inside the workspace, the native file-tap feature', async () => {
|
||||
const { runtime, createTab } = createRuntime({
|
||||
id: WT,
|
||||
path: WORKTREE_PATH
|
||||
})
|
||||
await expect(
|
||||
create(runtime, `file://${WORKTREE_PATH}/build/report.html`, {
|
||||
pairedDeviceId: 'device-1',
|
||||
clientKind: 'mobile'
|
||||
})
|
||||
).resolves.toEqual({ browserPageId: 'page-new' })
|
||||
expect(createTab).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses a paired file: create for an SSH workspace, whose path is on another machine', async () => {
|
||||
const { runtime, createTab } = createRuntime({
|
||||
id: WT,
|
||||
path: WORKTREE_PATH,
|
||||
hostId: 'ssh:box'
|
||||
})
|
||||
await expect(
|
||||
create(runtime, `file://${WORKTREE_PATH}/build/report.html`, {
|
||||
pairedDeviceId: 'device-1',
|
||||
clientKind: 'mobile'
|
||||
})
|
||||
).rejects.toThrow(/remote workspace/)
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves an unpaired local create alone', async () => {
|
||||
const { runtime, createTab } = createRuntime({
|
||||
id: WT,
|
||||
path: WORKTREE_PATH
|
||||
})
|
||||
await expect(create(runtime, 'file:///tmp/secrets/id_rsa')).resolves.toEqual({
|
||||
browserPageId: 'page-new'
|
||||
})
|
||||
expect(createTab).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -162,11 +162,13 @@ export type RuntimeBrowserCommandHost = {
|
||||
id: string
|
||||
repoId?: string
|
||||
hostId?: ExecutionHostId
|
||||
path?: string
|
||||
}>
|
||||
resolveBrowserWorkspace(selector: string): Promise<{
|
||||
id: string
|
||||
repoId?: string
|
||||
hostId?: ExecutionHostId
|
||||
path?: string
|
||||
}>
|
||||
resolveBrowserNetworkExecutionHost(worktree?: {
|
||||
id: string
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
publishCreatedBrowserSessionTab,
|
||||
resolveBrowserTabCreateFocus
|
||||
} from './browser-tab-create-publication'
|
||||
import { assertPairedBrowserTabCreateFileUrlAllowed } from './browser-tab-create-file-url-confinement'
|
||||
import { browserSessionRegistry } from '../browser/browser-session-registry'
|
||||
import { BrowserError } from '../browser/browser-error'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
@@ -45,6 +46,11 @@ export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCo
|
||||
: await this.host.resolveWorktreeSelector(params.worktree)
|
||||
: undefined
|
||||
const worktreeId = worktree?.id
|
||||
assertPairedBrowserTabCreateFileUrlAllowed({
|
||||
url,
|
||||
pairedCaller: Boolean(caller?.pairedDeviceId),
|
||||
worktree
|
||||
})
|
||||
const sessionPartition = browserSessionRegistry.resolveKnownPartition(params.profileId)
|
||||
if (!sessionPartition) {
|
||||
throw new BrowserError(
|
||||
|
||||
Reference in New Issue
Block a user