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
120 changes: 113 additions & 7 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 @@ -643,8 +659,17 @@ try {
expression: `parent.postMessage(${JSON.stringify(action)}, ${JSON.stringify(`chrome-extension://${extensionId}`)})`,
userGesture: false,
})
await expect(panel.locator('body')).toHaveAttribute('data-observed-actions', '1')
await expect(panel.locator('body')).toHaveAttribute('data-action-queries', '0')
await expect
.poll(async () => {
const { result } = await parentCdp.send('Runtime.evaluate', {
expression:
'[document.body.dataset.observedActions, document.body.dataset.actionQueries]',
returnByValue: true,
userGesture: false,
})
return result.value
})
.toEqual(['1', '0'])
assert((await snapshots()).length === before, 'Captured without a user gesture')
await app.evaluate(({ action, targetOrigin }) => parent.postMessage(action, targetOrigin), {
action: { ...action, sessionId: generateId() },
Expand All @@ -668,6 +693,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/'))) ||
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
8 changes: 7 additions & 1 deletion apps/sim/app/(landing)/privacy/privacy-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function richText(content: string): ReactNode {
export const PRIVACY_CONFIG: LegalPageConfig = {
title: 'Privacy Policy',
description: 'Sim Studio, Inc. · Operating the Sim platform (sim.ai)',
lastUpdated: 'October 31, 2026',
lastUpdated: 'November 2, 2026',
intro: [
{
kind: 'paragraph',
Expand Down Expand Up @@ -833,6 +833,12 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
'The hosted chat uses Your normal Sim Account and authenticated session, together with the operational diagnostics and consent controls described in this Privacy Policy. The extension document and its page attachments are excluded from session replay. Captured page content is not used for advertising.'
),
},
{
kind: 'paragraph',
content: richText(
'When You use the sim address-bar keyword, the extension sends the typed link name, before any path values or query, to Sim with Your authenticated session to suggest Your organization’s links. Suggestions are cached in memory and are not written to extension storage. Opening a sim/ shortcut sends its path and query to Sim to resolve the destination, then opens that destination. Sim records each link’s visit count and last-visited time. These requests also use the operational diagnostics described in this Privacy Policy. The extension does not read Your browser history or unrelated address-bar input.'
),
},
{
kind: 'subheading',
text: 'Chrome Web Store Limited Use',
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