mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
* fix(browser): keep isolated profile storage on its partition * fix(browser): keep cloned isolated tabs on their resolved partition * fix(browser): thread resolved partition through UI profile switches Keep the isolated-storage invariant universal: UI-initiated profile switch/create now persist the resolved partition alongside the profile id, matching the runtime path, so a tab stays on its partition even if the renderer profile mirror is later stale (issue #6923). Co-authored-by: Orca <help@stably.ai> * fix(browser): persist resolved partition through session restore Add sessionPartition to the workspace-session zod schema so the resolved partition survives persist->load; without it zod strips the field and a restored isolated tab whose profile mirror is stale at startup falls back to the shared default partition, reopening the storage leak (#6923). Fold the webview teardown branches (parent drift or partition mismatch) into one and re-resolve the viewport container once, matching the pre-refactor null-guard behavior. Split the sleeping-agent parse tests into their own file to keep both under the 800-line cap. Co-authored-by: Orca <help@stably.ai> * fix(browser): keep CLI-created tabs inheriting the default profile browserTabCreate without an explicit profile was sending sessionProfileId: null, which the renderer store treats as 'no inheritance' (its guard is !== undefined). That forced CLI-created tabs onto the shared default partition even when the user had configured a default browser session profile, silently changing behavior a #6923 fix should not touch. Leave sessionProfileId/sessionPartition undefined when no profile is named so the renderer applies default-profile inheritance; thread the resolved partition only when a profile is explicitly chosen. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai>
351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { sessionFromPartitionMock, askForMediaAccessMock, getMediaAccessStatusMock } = vi.hoisted(
|
|
() => ({
|
|
sessionFromPartitionMock: vi.fn(),
|
|
askForMediaAccessMock: vi.fn(),
|
|
getMediaAccessStatusMock: vi.fn()
|
|
})
|
|
)
|
|
|
|
vi.mock('electron', () => ({
|
|
session: {
|
|
fromPartition: sessionFromPartitionMock
|
|
},
|
|
systemPreferences: {
|
|
askForMediaAccess: askForMediaAccessMock,
|
|
getMediaAccessStatus: getMediaAccessStatusMock
|
|
}
|
|
}))
|
|
|
|
vi.mock('./browser-manager', () => ({
|
|
browserManager: {
|
|
notifyPermissionDenied: vi.fn(),
|
|
handleGuestWillDownload: vi.fn()
|
|
}
|
|
}))
|
|
|
|
import { browserSessionRegistry } from './browser-session-registry'
|
|
import { setupClientHintsOverride } from './browser-session-ua'
|
|
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
|
|
|
|
describe('BrowserSessionRegistry', () => {
|
|
beforeEach(() => {
|
|
sessionFromPartitionMock.mockReset()
|
|
askForMediaAccessMock.mockReset()
|
|
getMediaAccessStatusMock.mockReset()
|
|
askForMediaAccessMock.mockResolvedValue(true)
|
|
getMediaAccessStatusMock.mockReturnValue('granted')
|
|
sessionFromPartitionMock.mockReturnValue({
|
|
setPermissionRequestHandler: vi.fn(),
|
|
setPermissionCheckHandler: vi.fn(),
|
|
setDevicePermissionHandler: vi.fn(),
|
|
setDisplayMediaRequestHandler: vi.fn(),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
clearStorageData: vi.fn().mockResolvedValue(undefined),
|
|
clearCache: vi.fn().mockResolvedValue(undefined)
|
|
})
|
|
})
|
|
|
|
it('has a default profile on construction', () => {
|
|
const defaultProfile = browserSessionRegistry.getDefaultProfile()
|
|
expect(defaultProfile.id).toBe('default')
|
|
expect(defaultProfile.scope).toBe('default')
|
|
expect(defaultProfile.partition).toBe(ORCA_BROWSER_PARTITION)
|
|
})
|
|
|
|
it('allows the default partition', () => {
|
|
expect(browserSessionRegistry.isAllowedPartition(ORCA_BROWSER_PARTITION)).toBe(true)
|
|
})
|
|
|
|
it('rejects unknown partitions', () => {
|
|
expect(browserSessionRegistry.isAllowedPartition('persist:evil-partition')).toBe(false)
|
|
})
|
|
|
|
it('creates an isolated profile with a unique partition', () => {
|
|
const profile = browserSessionRegistry.createProfile('isolated', 'Test Isolated')
|
|
expect(profile).not.toBeNull()
|
|
expect(profile!.scope).toBe('isolated')
|
|
expect(profile!.partition).toMatch(/^persist:orca-browser-session-/)
|
|
expect(profile!.partition).not.toBe(ORCA_BROWSER_PARTITION)
|
|
expect(profile!.label).toBe('Test Isolated')
|
|
expect(profile!.source).toBeNull()
|
|
})
|
|
|
|
it('rejects creating a profile with scope default', () => {
|
|
const profile = browserSessionRegistry.createProfile('default', 'Sneaky')
|
|
expect(profile).toBeNull()
|
|
})
|
|
|
|
it('allows created profile partitions', () => {
|
|
const profile = browserSessionRegistry.createProfile('isolated', 'Allowed')
|
|
expect(profile).not.toBeNull()
|
|
expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(true)
|
|
})
|
|
|
|
it('creates an imported profile', () => {
|
|
const profile = browserSessionRegistry.createProfile('imported', 'My Import')
|
|
expect(profile).not.toBeNull()
|
|
expect(profile!.scope).toBe('imported')
|
|
expect(profile!.partition).toMatch(/^persist:orca-browser-session-/)
|
|
})
|
|
|
|
it('resolves partition for a known profile', () => {
|
|
const profile = browserSessionRegistry.createProfile('isolated', 'Resolve Test')
|
|
expect(profile).not.toBeNull()
|
|
expect(browserSessionRegistry.resolvePartition(profile!.id)).toBe(profile!.partition)
|
|
})
|
|
|
|
it('resolves default partition for null/undefined profileId', () => {
|
|
expect(browserSessionRegistry.resolvePartition(null)).toBe(ORCA_BROWSER_PARTITION)
|
|
expect(browserSessionRegistry.resolvePartition(undefined)).toBe(ORCA_BROWSER_PARTITION)
|
|
})
|
|
|
|
it('resolves default partition for unknown profileId', () => {
|
|
expect(browserSessionRegistry.resolvePartition('nonexistent')).toBe(ORCA_BROWSER_PARTITION)
|
|
})
|
|
|
|
it('strictly resolves known profile partitions without downgrading unknown profiles', () => {
|
|
const profile = browserSessionRegistry.createProfile('isolated', 'Strict Resolve')
|
|
expect(profile).not.toBeNull()
|
|
|
|
expect(browserSessionRegistry.resolveKnownPartition(null)).toBe(ORCA_BROWSER_PARTITION)
|
|
expect(browserSessionRegistry.resolveKnownPartition(undefined)).toBe(ORCA_BROWSER_PARTITION)
|
|
expect(browserSessionRegistry.resolveKnownPartition('default')).toBe(ORCA_BROWSER_PARTITION)
|
|
expect(browserSessionRegistry.resolveKnownPartition(profile!.id)).toBe(profile!.partition)
|
|
expect(browserSessionRegistry.resolveKnownPartition('missing-profile')).toBeNull()
|
|
})
|
|
|
|
it('lists all profiles', () => {
|
|
const before = browserSessionRegistry.listProfiles().length
|
|
browserSessionRegistry.createProfile('isolated', 'List Test')
|
|
const after = browserSessionRegistry.listProfiles()
|
|
expect(after.length).toBe(before + 1)
|
|
})
|
|
|
|
it('updates profile source', () => {
|
|
const profile = browserSessionRegistry.createProfile('imported', 'Source Test')
|
|
expect(profile).not.toBeNull()
|
|
const updated = browserSessionRegistry.updateProfileSource(profile!.id, {
|
|
browserFamily: 'edge',
|
|
importedAt: Date.now()
|
|
})
|
|
expect(updated).not.toBeNull()
|
|
expect(updated!.source?.browserFamily).toBe('edge')
|
|
})
|
|
|
|
it('updates profile source with comet family', () => {
|
|
const profile = browserSessionRegistry.createProfile('imported', 'Comet Source Test')
|
|
expect(profile).not.toBeNull()
|
|
const updated = browserSessionRegistry.updateProfileSource(profile!.id, {
|
|
browserFamily: 'comet',
|
|
importedAt: Date.now()
|
|
})
|
|
expect(updated).not.toBeNull()
|
|
expect(updated!.source?.browserFamily).toBe('comet')
|
|
})
|
|
|
|
it('deletes a non-default profile', async () => {
|
|
const profile = browserSessionRegistry.createProfile('isolated', 'Delete Test')
|
|
expect(profile).not.toBeNull()
|
|
expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(true)
|
|
const deleted = await browserSessionRegistry.deleteProfile(profile!.id)
|
|
expect(deleted).toBe(true)
|
|
expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(false)
|
|
expect(browserSessionRegistry.getProfile(profile!.id)).toBeNull()
|
|
})
|
|
|
|
it('clears session policy callbacks when deleting a profile', async () => {
|
|
const profile = browserSessionRegistry.createProfile('isolated', 'Policy Delete Test')
|
|
expect(profile).not.toBeNull()
|
|
const mockSession = sessionFromPartitionMock.mock.results[0]?.value
|
|
const downloadHandler = mockSession.on.mock.calls.find(
|
|
([eventName]) => eventName === 'will-download'
|
|
)?.[1]
|
|
|
|
await expect(browserSessionRegistry.deleteProfile(profile!.id)).resolves.toBe(true)
|
|
|
|
expect(mockSession.removeListener).toHaveBeenCalledWith('will-download', downloadHandler)
|
|
expect(mockSession.setPermissionRequestHandler).toHaveBeenLastCalledWith(null)
|
|
expect(mockSession.setPermissionCheckHandler).toHaveBeenLastCalledWith(null)
|
|
expect(mockSession.setDevicePermissionHandler).toHaveBeenLastCalledWith(null)
|
|
expect(mockSession.setDisplayMediaRequestHandler).toHaveBeenLastCalledWith(null)
|
|
})
|
|
|
|
it('refuses to delete the default profile', async () => {
|
|
const deleted = await browserSessionRegistry.deleteProfile('default')
|
|
expect(deleted).toBe(false)
|
|
expect(browserSessionRegistry.getDefaultProfile()).not.toBeNull()
|
|
})
|
|
|
|
it('hydrates profiles from persisted data', () => {
|
|
const fakeProfile = {
|
|
id: '00000000-0000-0000-0000-000000000001',
|
|
scope: 'imported' as const,
|
|
partition: 'persist:orca-browser-session-00000000-0000-0000-0000-000000000001',
|
|
label: 'Hydrated',
|
|
source: { browserFamily: 'manual' as const, importedAt: 1000 }
|
|
}
|
|
browserSessionRegistry.hydrateFromPersisted([fakeProfile])
|
|
expect(browserSessionRegistry.getProfile('00000000-0000-0000-0000-000000000001')).not.toBeNull()
|
|
expect(browserSessionRegistry.isAllowedPartition(fakeProfile.partition)).toBe(true)
|
|
})
|
|
|
|
it('sets up session policies for new partitions', () => {
|
|
browserSessionRegistry.createProfile('isolated', 'Policy Test')
|
|
expect(sessionFromPartitionMock).toHaveBeenCalled()
|
|
const mockSession = sessionFromPartitionMock.mock.results[0]?.value
|
|
expect(mockSession?.setPermissionRequestHandler).toHaveBeenCalled()
|
|
expect(mockSession?.setPermissionCheckHandler).toHaveBeenCalled()
|
|
expect(mockSession?.setDevicePermissionHandler).toHaveBeenCalled()
|
|
})
|
|
|
|
it('routes media permission requests through macOS TCC for isolated partitions', async () => {
|
|
// Why: verify the parallel fix to the default partition — isolated/imported
|
|
// profiles must also defer media permission checks to macOS instead of
|
|
// denying outright, otherwise pages inside them still hit NotAllowedError
|
|
// after the user grants Camera/Microphone to Orca.
|
|
browserSessionRegistry.createProfile('isolated', 'Media Test')
|
|
const mockSession = sessionFromPartitionMock.mock.results[0]?.value
|
|
const requestHandler = mockSession.setPermissionRequestHandler.mock.calls[0][0]
|
|
const checkHandler = mockSession.setPermissionCheckHandler.mock.calls[0][0]
|
|
|
|
const cb = vi.fn()
|
|
const guestWc = { id: 7, getURL: vi.fn(() => 'https://example.com/') }
|
|
requestHandler(guestWc, 'media', cb, { mediaTypes: ['video'] })
|
|
await vi.waitFor(() => expect(cb).toHaveBeenCalledWith(true))
|
|
|
|
expect(checkHandler(null, 'media', '', { mediaType: 'video' })).toBe(true)
|
|
expect(checkHandler(null, 'notifications', '', {})).toBe(true)
|
|
expect(checkHandler(null, 'persistent-storage', '', {})).toBe(true)
|
|
expect(checkHandler(null, 'geolocation', '', {})).toBe(false)
|
|
})
|
|
|
|
it('wires WebAuthn device selection for isolated partitions', () => {
|
|
browserSessionRegistry.createProfile('isolated', 'Security Key Test')
|
|
const mockSession = sessionFromPartitionMock.mock.results[0]?.value
|
|
const devicePermissionHandler = mockSession.setDevicePermissionHandler.mock.calls[0][0]
|
|
const checkHandler = mockSession.setPermissionCheckHandler.mock.calls[0][0]
|
|
|
|
expect(
|
|
devicePermissionHandler({
|
|
deviceType: 'hid',
|
|
origin: 'https://github.com',
|
|
device: { collections: [{ usagePage: 0xf1d0 }] }
|
|
})
|
|
).toBe(true)
|
|
expect(
|
|
devicePermissionHandler({
|
|
deviceType: 'hid',
|
|
origin: 'http://[::1]:5173',
|
|
device: { collections: [{ usagePage: 0xf1d0 }] }
|
|
})
|
|
).toBe(true)
|
|
expect(
|
|
devicePermissionHandler({
|
|
deviceType: 'hid',
|
|
origin: 'https://github.com',
|
|
device: { collections: [{ usagePage: 1 }] }
|
|
})
|
|
).toBe(false)
|
|
expect(checkHandler(null, 'hid', '', { securityOrigin: 'https://github.com' })).toBe(true)
|
|
|
|
const selectHidHandler = mockSession.on.mock.calls.find(
|
|
([eventName]) => eventName === 'select-hid-device'
|
|
)?.[1]
|
|
const hidCallback = vi.fn()
|
|
selectHidHandler(
|
|
{ preventDefault: vi.fn() },
|
|
{
|
|
frame: { url: 'https://github.com' },
|
|
deviceList: [
|
|
{ deviceId: 'keyboard', collections: [{ usagePage: 1 }] },
|
|
{ deviceId: 'security-key', collections: [{ usagePage: 0xf1d0 }] }
|
|
]
|
|
},
|
|
hidCallback
|
|
)
|
|
expect(hidCallback).toHaveBeenCalledWith('security-key')
|
|
|
|
const selectWebAuthnHandler = mockSession.on.mock.calls.find(
|
|
([eventName]) => eventName === 'select-webauthn-account'
|
|
)?.[1]
|
|
const webAuthnCallback = vi.fn()
|
|
selectWebAuthnHandler(
|
|
{ preventDefault: vi.fn() },
|
|
{ accounts: [{ credentialId: 'credential-1' }] },
|
|
webAuthnCallback
|
|
)
|
|
expect(webAuthnCallback).toHaveBeenCalledWith('credential-1')
|
|
})
|
|
|
|
describe('setupClientHintsOverride', () => {
|
|
it('overrides sec-ch-ua headers for Edge UA', () => {
|
|
const onBeforeSendHeaders = vi.fn()
|
|
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
|
const edgeUa =
|
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36 Edg/147.0.3210.5'
|
|
|
|
setupClientHintsOverride(mockSess, edgeUa)
|
|
|
|
expect(onBeforeSendHeaders).toHaveBeenCalledWith(
|
|
{ urls: ['https://*/*'] },
|
|
expect.any(Function)
|
|
)
|
|
|
|
const callback = vi.fn()
|
|
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
|
listener(
|
|
{ requestHeaders: { 'sec-ch-ua': 'old', 'sec-ch-ua-full-version-list': 'old' } },
|
|
callback
|
|
)
|
|
const modified = callback.mock.calls[0][0].requestHeaders
|
|
expect(modified['sec-ch-ua']).toContain('Microsoft Edge')
|
|
expect(modified['sec-ch-ua']).toContain('"147"')
|
|
expect(modified['sec-ch-ua-full-version-list']).toContain('147.0.3210.5')
|
|
})
|
|
|
|
it('overrides sec-ch-ua headers for Chrome UA', () => {
|
|
const onBeforeSendHeaders = vi.fn()
|
|
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
|
const chromeUa =
|
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
|
|
|
setupClientHintsOverride(mockSess, chromeUa)
|
|
|
|
const callback = vi.fn()
|
|
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
|
listener({ requestHeaders: { 'sec-ch-ua': 'old' } }, callback)
|
|
const modified = callback.mock.calls[0][0].requestHeaders
|
|
expect(modified['sec-ch-ua']).toContain('Google Chrome')
|
|
expect(modified['sec-ch-ua']).not.toContain('Microsoft Edge')
|
|
})
|
|
|
|
it('does not register handler for non-Chrome UA', () => {
|
|
const onBeforeSendHeaders = vi.fn()
|
|
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
|
|
|
setupClientHintsOverride(mockSess, 'Mozilla/5.0 (compatible; MSIE 10.0)')
|
|
|
|
expect(onBeforeSendHeaders).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('leaves non-Client-Hints headers unchanged', () => {
|
|
const onBeforeSendHeaders = vi.fn()
|
|
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
|
setupClientHintsOverride(mockSess, 'Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36')
|
|
|
|
const callback = vi.fn()
|
|
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
|
listener(
|
|
{ requestHeaders: { Cookie: 'abc=123', 'sec-ch-ua': 'old', Accept: 'text/html' } },
|
|
callback
|
|
)
|
|
const modified = callback.mock.calls[0][0].requestHeaders
|
|
expect(modified.Cookie).toBe('abc=123')
|
|
expect(modified.Accept).toBe('text/html')
|
|
})
|
|
})
|
|
})
|