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
107 changes: 102 additions & 5 deletions apps/extension/scripts/test-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createElement } from 'react'
import { type BrowserContext, chromium, expect, type Frame, type Page } from '@playwright/test'
import { pageSnapshotSchema } from '@sim/browser-extension-protocol'
import { SecretReveal } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { renderToStaticMarkup } from 'react-dom/server'

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const logger = createLogger('BrowserExtensionE2E')
Expand All @@ -18,6 +21,9 @@ if (!reportPath) throw new Error('Set EXTENSION_REPORT_PATH to the output JSON r
const output = resolve(root, reportPath)
const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] =
[]
const secretReveal = renderToStaticMarkup(
createElement(SecretReveal, { value: 'SECRET_REVEAL_SENTINEL' })
)
const fixture = Bun.serve({
hostname: '127.0.0.1',
port: 0,
Expand Down Expand Up @@ -55,6 +61,7 @@ const fixture = Bun.serve({
<input type="password" value="PASSWORD_SENTINEL"/><textarea>TEXTAREA_SENTINEL</textarea>
<div contenteditable="true" id="editable">EDITABLE_SENTINEL</div><div hidden>HIDDEN_SENTINEL</div>
<div style="display:none">CSS_HIDDEN_SENTINEL</div><div aria-hidden="true">ARIA_SENTINEL</div>
<section id="secret-reveal"><p>Credential instructions</p>${secretReveal}<code>visible-code-control</code></section>
<script>const secret='SCRIPT_SENTINEL'</script>
</body></html>`,
{ headers: { 'content-type': 'text/html' } }
Expand Down Expand Up @@ -129,12 +136,17 @@ try {
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(() => {
const observeDispatch = () => {
window.addEventListener('message', () => {
document.documentElement.dataset.queryCountAtDispatch =
document.documentElement.dataset.browserQueryCount ?? '0'
})
})
}
const panelBundle = resolve(extensionPath, 'sidepanel.js')
await writeFile(
panelBundle,
`(${observeDispatch.toString()})();\n${await readFile(panelBundle, 'utf8')}`
)
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 @@ -231,9 +243,11 @@ try {
'A typed sim/ link opens the Sim link resolver on the configured origin',
async () => {
const tab = await page.context().newPage()
for (const path of ['handbook', 'jira/123?view=board']) {
await tab.goto(`http://sim/${path}`)
assert(tab.url() === `${origin}/sim/${path}`, `sim/${path} reached ${tab.url()}`)
for (const scheme of ['http', 'https']) {
for (const path of ['handbook', 'jira/123?view=board', 'jira/a%2Fb?view=board%20view']) {
await tab.goto(`${scheme}://sim/${path}`)
assert(tab.url() === `${origin}/sim/${path}`, `sim/${path} reached ${tab.url()}`)
}
}
await tab.close()
}
Expand Down Expand Up @@ -310,6 +324,7 @@ try {
await check('Page extraction excludes secrets and strips URL query and fragment', async () => {
const [snapshot] = await snapshots()
assert(snapshot.text.includes('visible-reference-482'), 'Visible page content missing')
assert(snapshot.text.includes('visible-code-control'), 'Ordinary code was excluded')
for (const sentinel of [
'PASSWORD_SENTINEL',
'TEXTAREA_SENTINEL',
Expand All @@ -318,6 +333,7 @@ try {
'CSS_HIDDEN_SENTINEL',
'ARIA_SENTINEL',
'SCRIPT_SENTINEL',
'SECRET_REVEAL_SENTINEL',
]) {
assert(!snapshot.text.includes(sentinel), `Captured ${sentinel}`)
}
Expand Down Expand Up @@ -668,6 +684,87 @@ try {
await parentCdp.detach()
}
)
await check('Ordinary Sim and encoded remote pages attach without sensitive fields', async () => {
for (const url of [
`${origin}/workspace/example`,
`${origin}/extension-notes`,
`${origin}/workspace/%E2%9C%93`,
`${pageOrigin}/ticket/100%done`,
]) {
const before = (await snapshots()).length
await page.goto(`${url}?token=URL_SECRET#FRAGMENT_SECRET`)
await page.bringToFront()
await capture()
await expect.poll(async () => (await snapshots()).length).toBe(before + 1)
const snapshot = (await snapshots()).at(-1)
assert(snapshot?.url === url, 'Page URL retained private parameters')
assert(snapshot.text.includes('visible-reference-482'), 'Visible page content is missing')
assert(!snapshot.text.includes('SENTINEL'), 'Sensitive page content was captured')
}
})
await check('Embedded chat and malformed Sim paths fail closed', async () => {
for (const path of [
'/extension',
'/extension/workspace',
'/extension/o/organization',
'/%65xtension',
'/%65xtension/workspace',
'/extension%2fworkspace',
'/extension%ZZ/workspace',
]) {
await page.goto(`${origin}/workspace/example`)
await page.bringToFront()
const previous = (await snapshots()).length
await capture()
await expect.poll(async () => (await snapshots()).length).toBe(previous + 1)
await expect(panel.getByRole('status')).toHaveText('')
const before = (await snapshots()).length
await page.goto(`${origin}${path}`)
await page.bringToFront()
await capture()
await panel.getByRole('status').filter({ hasText: 'This page cannot be attached.' }).waitFor()
assert((await snapshots()).length === before, 'An excluded Sim path was captured')
}
})
await check(
'Revealed credentials stay excluded from exact and surrounding selections',
async () => {
await page.goto(`${origin}/workspace/example`)
await page.bringToFront()
const before = (await snapshots()).length
await capture()
await expect.poll(async () => (await snapshots()).length).toBe(before + 1)
await expect(panel.getByRole('status')).toHaveText('')
await page
.locator('#secret-reveal code')
.first()
.evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element)
window.getSelection()?.removeAllRanges()
window.getSelection()?.addRange(range)
})
await capture('Selection')
await expect(panel.getByRole('status')).toContainText('could not be read')
assert((await snapshots()).length === before + 1, 'A revealed credential was attached')
await page.locator('#secret-reveal').evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element)
window.getSelection()?.removeAllRanges()
window.getSelection()?.addRange(range)
})
await capture('Selection')
await expect.poll(async () => (await snapshots()).length).toBe(before + 2)
const snapshot = (await snapshots()).at(-1)
assert(snapshot, 'Missing surrounding selection snapshot')
assert(snapshot.text.includes('Credential instructions'), 'Public selection text is missing')
assert(snapshot.text.includes('visible-code-control'), 'Ordinary selected code was excluded')
assert(
!snapshot.text.includes('SECRET_REVEAL_SENTINEL'),
'A surrounding selection leaked the credential'
)
}
)
await check(
'Site access stays modal without violating the packaged security policy',
async () => {
Expand Down
13 changes: 11 additions & 2 deletions apps/extension/src/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,23 @@ export async function captureActivePage(
if (expected && (await digestUrl(tab.url)) !== expected.urlDigest) {
throw new Error('The page changed before reading. Attach it again.')
}
let pathname = url.pathname
if (url.origin === SIM_ORIGIN) {
try {
pathname = decodeURIComponent(pathname)
} catch {
throw new Error('This page cannot be attached.')
}
}
if (
!['http:', 'https:'].includes(url.protocol) ||
url.origin === SIM_ORIGIN ||
(url.origin === SIM_ORIGIN &&
(pathname === '/extension' || pathname.startsWith('/extension/'))) ||
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
url.username ||
url.password ||
tab.incognito
) {
throw new Error('Choose a regular web page outside Sim to attach.')
throw new Error('This page cannot be attached.')
}
const results = await chrome.scripting.executeScript({
target: expected
Expand Down
6 changes: 2 additions & 4 deletions apps/sim/app/extension/[workspaceId]/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,10 @@ export function ExtensionChat({ workspaceId, workspaceName }: ExtensionChatProps
draftScopeKey={`extension:${userId}:${workspaceId}:${selectedChatId ?? 'new'}`}
composerAttachments={composer.attachments}
composerResourceActions={composer.resourceActions}
composerToolbarActions={composer.toolbarActions}
composerModelSelectorOptions={composer.modelSelectorOptions}
layout='copilot-view'
className={cn(
'h-auto min-h-0',
selectedChatId || chat.messages.length > 0 ? 'flex-1' : 'shrink-0'
)}
className={cn('h-auto min-h-0', (selectedChatId || chat.messages.length > 0) && 'flex-1')}
/>
<ChipModal
open={Boolean(sourceMessage)}
Expand Down
20 changes: 19 additions & 1 deletion apps/sim/app/extension/hooks/use-composer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { useCallback, useMemo } from 'react'
import { DropdownMenuItem, toast } from '@sim/emcn'
import { Chip, DropdownMenuItem, Tooltip, toast } from '@sim/emcn'
import { Globe, Highlighter, Settings } from '@sim/emcn/icons'
import { getPageContextBudgetError } from '@/lib/mothership/chat/page-context'
import { usePageContext } from '@/app/extension/hooks/use-page-context'
Expand Down Expand Up @@ -77,9 +77,27 @@ export function useExtensionComposer(scopeKey: string) {
},
[snapshots]
)
const toolbarActions = useMemo(
() =>
connected ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Chip
shape='round'
leftIcon={Globe}
onClick={() => requestAction('capture-page')}
aria-label='Attach current page'
/>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Attach current page</Tooltip.Content>
</Tooltip.Root>
) : undefined,
[connected, requestAction]
)
return {
attachments,
resourceActions,
toolbarActions,
modelSelectorOptions: MODEL_SELECTOR_OPTIONS,
prepareSubmission,
onSubmitted: clearSnapshots,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
organizationSearchUrlKeys,
} from '@/app/o/[organizationId]/search/search-params'
import { ChatResourcePanel } from '@/app/workspace/[workspaceId]/home/components/chat-resource-panel'
import { ComposerScrollArea } from '@/app/workspace/[workspaceId]/home/components/composer-scroll-area'
import { useSearchHistoryActions } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-history-context'
import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection'
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat'
Expand Down Expand Up @@ -94,6 +95,7 @@ interface OrganizationConversationProps {
header: ReactNode
attachments?: ReactNode
resourceActions?: ReactNode
toolbarActions?: ReactNode
modelSelectorOptions?: ModelSelectorOptions
onChatIdResolved: (chatId: string) => void
onNavigateToChat: (chatId: string) => void
Expand Down Expand Up @@ -453,6 +455,7 @@ function OrganizationConversationContent({
compact={Boolean(panelOptions)}
attachments={panelOptions?.attachments}
resourceActions={panelOptions?.resourceActions}
toolbarActions={panelOptions?.toolbarActions}
modelSelectorOptions={panelOptions?.modelSelectorOptions}
onPlanChange={
!hasChat && destinationMode === 'agent' && planEnabled
Expand Down Expand Up @@ -535,7 +538,7 @@ function OrganizationConversationContent({
<div className='flex flex-1 items-center justify-center px-5'>
<h1 className='text-lg'>How can I help?</h1>
</div>
<div className='px-3 pb-3'>{composer}</div>
<ComposerScrollArea className='px-3 pb-3'>{composer}</ComposerScrollArea>
</>
) : (
<OrganizationLanding
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ interface ComposerProps {
compact?: boolean
attachments?: ReactNode
resourceActions?: ReactNode
toolbarActions?: ReactNode
modelSelectorOptions?: ModelSelectorOptions
}

Expand All @@ -80,6 +81,7 @@ export function Composer({
compact = false,
attachments,
resourceActions,
toolbarActions,
modelSelectorOptions,
}: ComposerProps) {
const attachedFilesRef = useRef(files.attachedFiles)
Expand Down Expand Up @@ -257,6 +259,7 @@ export function Composer({
</Tooltip.Root>
)}
{!imagesOnly && contextPicker('skills', Slash, 'Skills')}
{toolbarActions}
{onPlanChange && (
<Chip
shape='round'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client'

import { type AnimationEventHandler, type ReactNode, useRef } from 'react'
import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'

interface ComposerScrollAreaProps {
children: ReactNode
className?: string
onAnimationEnd?: AnimationEventHandler<HTMLDivElement>
}

/** Keeps the complete composer reachable when its contents exceed the available chat height. */
export function ComposerScrollArea({
children,
className,
onAnimationEnd,
}: ComposerScrollAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const edges = useScrollEdges(scrollRef)

return (
<div
ref={scrollRef}
className={cn('min-h-0 overflow-y-auto overscroll-contain', scrollFadeClass, className)}
{...scrollFadeAttributes(edges)}
onAnimationEnd={onAnimationEnd}
>
{children}
</div>
)
}
Loading
Loading