Skip to content

Commit 2e6bbb1

Browse files
authored
fix(extension): attach Sim pages and streamline composer controls (#9560)
* fix(extension): attach Sim pages and streamline composer controls * fix(extension): normalize Sim paths before capture * fix(extension): exclude revealed secrets and keep large composers usable
1 parent e3ce679 commit 2e6bbb1

12 files changed

Lines changed: 389 additions & 32 deletions

File tree

‎apps/extension/scripts/test-e2e.ts‎

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
55
import { tmpdir } from 'node:os'
66
import { dirname, resolve } from 'node:path'
77
import { fileURLToPath } from 'node:url'
8+
import { createElement } from 'react'
89
import { type BrowserContext, chromium, expect, type Frame, type Page } from '@playwright/test'
910
import { pageSnapshotSchema } from '@sim/browser-extension-protocol'
11+
import { SecretReveal } from '@sim/emcn'
1012
import { createLogger } from '@sim/logger'
1113
import { getErrorMessage } from '@sim/utils/errors'
1214
import { generateId } from '@sim/utils/id'
15+
import { renderToStaticMarkup } from 'react-dom/server'
1316

1417
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
1518
const logger = createLogger('BrowserExtensionE2E')
@@ -18,6 +21,9 @@ if (!reportPath) throw new Error('Set EXTENSION_REPORT_PATH to the output JSON r
1821
const output = resolve(root, reportPath)
1922
const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] =
2023
[]
24+
const secretReveal = renderToStaticMarkup(
25+
createElement(SecretReveal, { value: 'SECRET_REVEAL_SENTINEL' })
26+
)
2127
const fixture = Bun.serve({
2228
hostname: '127.0.0.1',
2329
port: 0,
@@ -55,6 +61,7 @@ const fixture = Bun.serve({
5561
<input type="password" value="PASSWORD_SENTINEL"/><textarea>TEXTAREA_SENTINEL</textarea>
5662
<div contenteditable="true" id="editable">EDITABLE_SENTINEL</div><div hidden>HIDDEN_SENTINEL</div>
5763
<div style="display:none">CSS_HIDDEN_SENTINEL</div><div aria-hidden="true">ARIA_SENTINEL</div>
64+
<section id="secret-reveal"><p>Credential instructions</p>${secretReveal}<code>visible-code-control</code></section>
5865
<script>const secret='SCRIPT_SENTINEL'</script>
5966
</body></html>`,
6067
{ headers: { 'content-type': 'text/html' } }
@@ -129,12 +136,17 @@ try {
129136
await signIn.close()
130137
const panel: Page = await context.newPage()
131138
// Observe dispatch before the bridge so background metadata queries stay outside each action.
132-
await panel.addInitScript(() => {
139+
const observeDispatch = () => {
133140
window.addEventListener('message', () => {
134141
document.documentElement.dataset.queryCountAtDispatch =
135142
document.documentElement.dataset.browserQueryCount ?? '0'
136143
})
137-
})
144+
}
145+
const panelBundle = resolve(extensionPath, 'sidepanel.js')
146+
await writeFile(
147+
panelBundle,
148+
`(${observeDispatch.toString()})();\n${await readFile(panelBundle, 'utf8')}`
149+
)
138150
await panel.goto(`chrome-extension://${extensionId}/sidepanel.html`)
139151
await panel.getByTitle('Sim chat').waitFor()
140152
const frame: Frame = panel.frames()[1] ?? (await panel.waitForEvent('frameattached'))
@@ -231,9 +243,11 @@ try {
231243
'A typed sim/ link opens the Sim link resolver on the configured origin',
232244
async () => {
233245
const tab = await page.context().newPage()
234-
for (const path of ['handbook', 'jira/123?view=board']) {
235-
await tab.goto(`http://sim/${path}`)
236-
assert(tab.url() === `${origin}/sim/${path}`, `sim/${path} reached ${tab.url()}`)
246+
for (const scheme of ['http', 'https']) {
247+
for (const path of ['handbook', 'jira/123?view=board', 'jira/a%2Fb?view=board%20view']) {
248+
await tab.goto(`${scheme}://sim/${path}`)
249+
assert(tab.url() === `${origin}/sim/${path}`, `sim/${path} reached ${tab.url()}`)
250+
}
237251
}
238252
await tab.close()
239253
}
@@ -310,6 +324,7 @@ try {
310324
await check('Page extraction excludes secrets and strips URL query and fragment', async () => {
311325
const [snapshot] = await snapshots()
312326
assert(snapshot.text.includes('visible-reference-482'), 'Visible page content missing')
327+
assert(snapshot.text.includes('visible-code-control'), 'Ordinary code was excluded')
313328
for (const sentinel of [
314329
'PASSWORD_SENTINEL',
315330
'TEXTAREA_SENTINEL',
@@ -318,6 +333,7 @@ try {
318333
'CSS_HIDDEN_SENTINEL',
319334
'ARIA_SENTINEL',
320335
'SCRIPT_SENTINEL',
336+
'SECRET_REVEAL_SENTINEL',
321337
]) {
322338
assert(!snapshot.text.includes(sentinel), `Captured ${sentinel}`)
323339
}
@@ -668,6 +684,87 @@ try {
668684
await parentCdp.detach()
669685
}
670686
)
687+
await check('Ordinary Sim and encoded remote pages attach without sensitive fields', async () => {
688+
for (const url of [
689+
`${origin}/workspace/example`,
690+
`${origin}/extension-notes`,
691+
`${origin}/workspace/%E2%9C%93`,
692+
`${pageOrigin}/ticket/100%done`,
693+
]) {
694+
const before = (await snapshots()).length
695+
await page.goto(`${url}?token=URL_SECRET#FRAGMENT_SECRET`)
696+
await page.bringToFront()
697+
await capture()
698+
await expect.poll(async () => (await snapshots()).length).toBe(before + 1)
699+
const snapshot = (await snapshots()).at(-1)
700+
assert(snapshot?.url === url, 'Page URL retained private parameters')
701+
assert(snapshot.text.includes('visible-reference-482'), 'Visible page content is missing')
702+
assert(!snapshot.text.includes('SENTINEL'), 'Sensitive page content was captured')
703+
}
704+
})
705+
await check('Embedded chat and malformed Sim paths fail closed', async () => {
706+
for (const path of [
707+
'/extension',
708+
'/extension/workspace',
709+
'/extension/o/organization',
710+
'/%65xtension',
711+
'/%65xtension/workspace',
712+
'/extension%2fworkspace',
713+
'/extension%ZZ/workspace',
714+
]) {
715+
await page.goto(`${origin}/workspace/example`)
716+
await page.bringToFront()
717+
const previous = (await snapshots()).length
718+
await capture()
719+
await expect.poll(async () => (await snapshots()).length).toBe(previous + 1)
720+
await expect(panel.getByRole('status')).toHaveText('')
721+
const before = (await snapshots()).length
722+
await page.goto(`${origin}${path}`)
723+
await page.bringToFront()
724+
await capture()
725+
await panel.getByRole('status').filter({ hasText: 'This page cannot be attached.' }).waitFor()
726+
assert((await snapshots()).length === before, 'An excluded Sim path was captured')
727+
}
728+
})
729+
await check(
730+
'Revealed credentials stay excluded from exact and surrounding selections',
731+
async () => {
732+
await page.goto(`${origin}/workspace/example`)
733+
await page.bringToFront()
734+
const before = (await snapshots()).length
735+
await capture()
736+
await expect.poll(async () => (await snapshots()).length).toBe(before + 1)
737+
await expect(panel.getByRole('status')).toHaveText('')
738+
await page
739+
.locator('#secret-reveal code')
740+
.first()
741+
.evaluate((element) => {
742+
const range = document.createRange()
743+
range.selectNodeContents(element)
744+
window.getSelection()?.removeAllRanges()
745+
window.getSelection()?.addRange(range)
746+
})
747+
await capture('Selection')
748+
await expect(panel.getByRole('status')).toContainText('could not be read')
749+
assert((await snapshots()).length === before + 1, 'A revealed credential was attached')
750+
await page.locator('#secret-reveal').evaluate((element) => {
751+
const range = document.createRange()
752+
range.selectNodeContents(element)
753+
window.getSelection()?.removeAllRanges()
754+
window.getSelection()?.addRange(range)
755+
})
756+
await capture('Selection')
757+
await expect.poll(async () => (await snapshots()).length).toBe(before + 2)
758+
const snapshot = (await snapshots()).at(-1)
759+
assert(snapshot, 'Missing surrounding selection snapshot')
760+
assert(snapshot.text.includes('Credential instructions'), 'Public selection text is missing')
761+
assert(snapshot.text.includes('visible-code-control'), 'Ordinary selected code was excluded')
762+
assert(
763+
!snapshot.text.includes('SECRET_REVEAL_SENTINEL'),
764+
'A surrounding selection leaked the credential'
765+
)
766+
}
767+
)
671768
await check(
672769
'Site access stays modal without violating the packaged security policy',
673770
async () => {

‎apps/extension/src/capture.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,23 @@ export async function captureActivePage(
2121
if (expected && (await digestUrl(tab.url)) !== expected.urlDigest) {
2222
throw new Error('The page changed before reading. Attach it again.')
2323
}
24+
let pathname = url.pathname
25+
if (url.origin === SIM_ORIGIN) {
26+
try {
27+
pathname = decodeURIComponent(pathname)
28+
} catch {
29+
throw new Error('This page cannot be attached.')
30+
}
31+
}
2432
if (
2533
!['http:', 'https:'].includes(url.protocol) ||
26-
url.origin === SIM_ORIGIN ||
34+
(url.origin === SIM_ORIGIN &&
35+
(pathname === '/extension' || pathname.startsWith('/extension/'))) ||
2736
url.username ||
2837
url.password ||
2938
tab.incognito
3039
) {
31-
throw new Error('Choose a regular web page outside Sim to attach.')
40+
throw new Error('This page cannot be attached.')
3241
}
3342
const results = await chrome.scripting.executeScript({
3443
target: expected

‎apps/sim/app/extension/[workspaceId]/chat.tsx‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,10 @@ export function ExtensionChat({ workspaceId, workspaceName }: ExtensionChatProps
169169
draftScopeKey={`extension:${userId}:${workspaceId}:${selectedChatId ?? 'new'}`}
170170
composerAttachments={composer.attachments}
171171
composerResourceActions={composer.resourceActions}
172+
composerToolbarActions={composer.toolbarActions}
172173
composerModelSelectorOptions={composer.modelSelectorOptions}
173174
layout='copilot-view'
174-
className={cn(
175-
'h-auto min-h-0',
176-
selectedChatId || chat.messages.length > 0 ? 'flex-1' : 'shrink-0'
177-
)}
175+
className={cn('h-auto min-h-0', (selectedChatId || chat.messages.length > 0) && 'flex-1')}
178176
/>
179177
<ChipModal
180178
open={Boolean(sourceMessage)}

‎apps/sim/app/extension/hooks/use-composer.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useCallback, useMemo } from 'react'
4-
import { DropdownMenuItem, toast } from '@sim/emcn'
4+
import { Chip, DropdownMenuItem, Tooltip, toast } from '@sim/emcn'
55
import { Globe, Highlighter, Settings } from '@sim/emcn/icons'
66
import { getPageContextBudgetError } from '@/lib/mothership/chat/page-context'
77
import { usePageContext } from '@/app/extension/hooks/use-page-context'
@@ -77,9 +77,27 @@ export function useExtensionComposer(scopeKey: string) {
7777
},
7878
[snapshots]
7979
)
80+
const toolbarActions = useMemo(
81+
() =>
82+
connected ? (
83+
<Tooltip.Root>
84+
<Tooltip.Trigger asChild>
85+
<Chip
86+
shape='round'
87+
leftIcon={Globe}
88+
onClick={() => requestAction('capture-page')}
89+
aria-label='Attach current page'
90+
/>
91+
</Tooltip.Trigger>
92+
<Tooltip.Content side='top'>Attach current page</Tooltip.Content>
93+
</Tooltip.Root>
94+
) : undefined,
95+
[connected, requestAction]
96+
)
8097
return {
8198
attachments,
8299
resourceActions,
100+
toolbarActions,
83101
modelSelectorOptions: MODEL_SELECTOR_OPTIONS,
84102
prepareSubmission,
85103
onSubmitted: clearSnapshots,

‎apps/sim/app/o/[organizationId]/components/organization-conversation.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
organizationSearchUrlKeys,
3636
} from '@/app/o/[organizationId]/search/search-params'
3737
import { ChatResourcePanel } from '@/app/workspace/[workspaceId]/home/components/chat-resource-panel'
38+
import { ComposerScrollArea } from '@/app/workspace/[workspaceId]/home/components/composer-scroll-area'
3839
import { useSearchHistoryActions } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-history-context'
3940
import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection'
4041
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat'
@@ -94,6 +95,7 @@ interface OrganizationConversationProps {
9495
header: ReactNode
9596
attachments?: ReactNode
9697
resourceActions?: ReactNode
98+
toolbarActions?: ReactNode
9799
modelSelectorOptions?: ModelSelectorOptions
98100
onChatIdResolved: (chatId: string) => void
99101
onNavigateToChat: (chatId: string) => void
@@ -453,6 +455,7 @@ function OrganizationConversationContent({
453455
compact={Boolean(panelOptions)}
454456
attachments={panelOptions?.attachments}
455457
resourceActions={panelOptions?.resourceActions}
458+
toolbarActions={panelOptions?.toolbarActions}
456459
modelSelectorOptions={panelOptions?.modelSelectorOptions}
457460
onPlanChange={
458461
!hasChat && destinationMode === 'agent' && planEnabled
@@ -535,7 +538,7 @@ function OrganizationConversationContent({
535538
<div className='flex flex-1 items-center justify-center px-5'>
536539
<h1 className='text-lg'>How can I help?</h1>
537540
</div>
538-
<div className='px-3 pb-3'>{composer}</div>
541+
<ComposerScrollArea className='px-3 pb-3'>{composer}</ComposerScrollArea>
539542
</>
540543
) : (
541544
<OrganizationLanding

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ interface ComposerProps {
5959
compact?: boolean
6060
attachments?: ReactNode
6161
resourceActions?: ReactNode
62+
toolbarActions?: ReactNode
6263
modelSelectorOptions?: ModelSelectorOptions
6364
}
6465

@@ -80,6 +81,7 @@ export function Composer({
8081
compact = false,
8182
attachments,
8283
resourceActions,
84+
toolbarActions,
8385
modelSelectorOptions,
8486
}: ComposerProps) {
8587
const attachedFilesRef = useRef(files.attachedFiles)
@@ -257,6 +259,7 @@ export function Composer({
257259
</Tooltip.Root>
258260
)}
259261
{!imagesOnly && contextPicker('skills', Slash, 'Skills')}
262+
{toolbarActions}
260263
{onPlanChange && (
261264
<Chip
262265
shape='round'
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use client'
2+
3+
import { type AnimationEventHandler, type ReactNode, useRef } from 'react'
4+
import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
5+
6+
interface ComposerScrollAreaProps {
7+
children: ReactNode
8+
className?: string
9+
onAnimationEnd?: AnimationEventHandler<HTMLDivElement>
10+
}
11+
12+
/** Keeps the complete composer reachable when its contents exceed the available chat height. */
13+
export function ComposerScrollArea({
14+
children,
15+
className,
16+
onAnimationEnd,
17+
}: ComposerScrollAreaProps) {
18+
const scrollRef = useRef<HTMLDivElement>(null)
19+
const edges = useScrollEdges(scrollRef)
20+
21+
return (
22+
<div
23+
ref={scrollRef}
24+
className={cn('min-h-0 overflow-y-auto overscroll-contain', scrollFadeClass, className)}
25+
{...scrollFadeAttributes(edges)}
26+
onAnimationEnd={onAnimationEnd}
27+
>
28+
{children}
29+
</div>
30+
)
31+
}

0 commit comments

Comments
 (0)