Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sim/extension",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/extension/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ await writeFile(
{
manifest_version: 3,
name: development ? 'Sim (Development)' : 'Sim',
version: '0.1.0',
version: '0.1.1',
description:
'Ask Sim about the page you are reading, with your existing Sim conversations and tools.',
minimum_chrome_version: '120',
Expand Down
78 changes: 71 additions & 7 deletions apps/extension/scripts/test-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,13 @@ try {
await signIn.goto(`${origin}/sign-in`)
await signIn.close()
const panel: Page = await context.newPage()
// Observe dispatch before the bridge so background metadata queries stay outside each action.
await panel.addInitScript(() => {
window.addEventListener('message', () => {
document.documentElement.dataset.queryCountAtDispatch =
document.documentElement.dataset.browserQueryCount ?? '0'
})
})
await panel.goto(`chrome-extension://${extensionId}/sidepanel.html`)
await panel.getByTitle('Sim chat').waitFor()
const frame: Frame = panel.frames()[1] ?? (await panel.waitForEvent('frameattached'))
Expand Down Expand Up @@ -588,6 +595,7 @@ try {
callback?: (tabs: chrome.tabs.Tab[]) => void
) => {
queries++
document.documentElement.dataset.browserQueryCount = String(queries)
return callback ? originalQuery(query, callback) : originalQuery(query)
}) as typeof chrome.tabs.query
window.addEventListener('message', (event: MessageEvent<unknown>) => {
Expand All @@ -599,24 +607,31 @@ try {
event.data.type !== 'action'
)
return
document.body.dataset.actionQueries = String(queries)
document.body.dataset.actionQueries = String(
queries - Number(document.documentElement.dataset.queryCountAtDispatch ?? '0')
)
document.body.dataset.observedActions = String(++actions)
})
})
await panel.evaluate(() => chrome.tabs.query({ active: true, currentWindow: true }))
const parentCdp = await panel.context().newCDPSession(panel)
const cdp = await panel.context().newCDPSession(app)
await expect
.poll(
async () => {
const result = await parentCdp.send('Runtime.evaluate', {
expression: 'navigator.userActivation.isActive',
userGesture: false,
})
return result.result.value
const results = await Promise.all(
[parentCdp, cdp].map((session) =>
session.send('Runtime.evaluate', {
expression: 'navigator.userActivation.isActive',
userGesture: false,
})
)
)
return results.map((result) => result.result.value)
},
{ timeout: 10_000 }
)
.toBe(false)
.toEqual([false, false])
const action = {
channel: 'sim-extension',
version: 1,
Expand Down Expand Up @@ -653,6 +668,55 @@ try {
await parentCdp.detach()
}
)
await check(
'Site access stays modal without violating the packaged security policy',
async () => {
const policyErrors: string[] = []
panel.on('console', (message) => {
if (message.type() === 'error' && message.text().includes('Content Security Policy'))
policyErrors.push(message.text())
})
const openAccess = () =>
app.evaluate(
({ targetOrigin, sessionId }) => {
parent.postMessage(
{
channel: 'sim-extension',
version: 1,
type: 'action',
sessionId,
action: 'site-access',
},
targetOrigin
)
},
{ targetOrigin: `chrome-extension://${extensionId}`, sessionId: receiverSessionId }
)
await panel.bringToFront()
await openAccess()
const dialog = panel.getByRole('dialog', { name: 'Site access' })
await expect(dialog).toBeVisible()
await settleMessages()
assert(policyErrors.length === 0, `Site access violated CSP: ${policyErrors.join('\n')}`)
await panel.evaluate(() => document.querySelector('iframe')?.focus())
assert(
await dialog.evaluate((element) => element.contains(document.activeElement)),
'The hosted frame accepted focus behind the modal'
)
await dialog.getByRole('button', { name: 'Close' }).press('Escape')
await expect(dialog, 'Escape closes the modal').toBeHidden()
await openAccess()
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: 'Close' }).click()
await expect(dialog, 'The Close button dismisses the modal').toBeHidden()
await openAccess()
await expect(dialog).toBeVisible()
await dialog.click({ trial: true })
await panel.mouse.click(1, 1)
await expect(dialog, 'The backdrop dismisses the modal').toBeHidden()
assert(policyErrors.length === 0, `Site access violated CSP: ${policyErrors.join('\n')}`)
}
)
await mkdir(dirname(output), { recursive: true })
await panel.screenshot({ path: output.replace(/\.json$/, '.png'), fullPage: true })
} catch (error) {
Expand Down
54 changes: 39 additions & 15 deletions apps/extension/src/sidepanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type ExtensionToAppMessage,
type PageSnapshot,
} from '@sim/browser-extension-protocol'
import { Chip, ChipModal, ChipModalBody, ChipModalHeader } from '@sim/emcn'
import { Chip, ChipModalBody, ChipModalHeader, ChipModalSurface } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { createRoot } from 'react-dom/client'
import { captureActivePage } from '@/capture'
Expand All @@ -16,6 +16,13 @@ interface SidePanelProps {
origin: string
}

/** Native modality avoids the inline scroll-lock stylesheet blocked by the extension CSP. */
function mountAccessDialog(dialog: HTMLDialogElement | null) {
if (!dialog) return
dialog.showModal()
return () => dialog.close()
}

function SidePanel({ origin }: SidePanelProps) {
const frameRef = useRef<HTMLIFrameElement>(null)
const pendingRef = useRef<PageSnapshot | null>(null)
Expand Down Expand Up @@ -279,20 +286,37 @@ function SidePanel({ origin }: SidePanelProps) {
referrerPolicy='no-referrer'
sandbox='allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads'
/>
<ChipModal open={accessOpen} onOpenChange={setAccessOpen} srTitle='Site access' size='sm'>
<ChipModalHeader onClose={() => setAccessOpen(false)}>Site access</ChipModalHeader>
<ChipModalBody>
{site ? (
<Chip className='max-w-full' onClick={() => void toggleSite()}>
{`${site.allowed ? 'Remove access to' : 'Allow access to'} ${new URL(site.origin).hostname}`}
</Chip>
) : (
<p className='text-[var(--text-muted)] text-small'>
Open a web page and click the Sim toolbar icon to manage access.
</p>
)}
</ChipModalBody>
</ChipModal>
{accessOpen && (
<dialog
ref={mountAccessDialog}
aria-labelledby='site-access-title'
className='m-auto max-h-[85vh] w-[90vw] max-w-[440px] overflow-visible border-0 bg-transparent p-0 text-inherit backdrop:bg-black/10 backdrop:backdrop-blur-[2px]'
onCancel={(event) => {
event.preventDefault()
setAccessOpen(false)
}}
onClick={(event) => {
if (event.target === event.currentTarget) setAccessOpen(false)
}}
>
<ChipModalSurface>
<ChipModalHeader onClose={() => setAccessOpen(false)}>
<span id='site-access-title'>Site access</span>
</ChipModalHeader>
<ChipModalBody>
{site ? (
<Chip className='max-w-full' onClick={() => void toggleSite()}>
{`${site.allowed ? 'Remove access to' : 'Allow access to'} ${new URL(site.origin).hostname}`}
</Chip>
) : (
<p className='text-[var(--text-muted)] text-small'>
Open a web page and click the Sim toolbar icon to manage access.
</p>
)}
</ChipModalBody>
</ChipModalSurface>
</dialog>
)}
</main>
)
}
Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading