Skip to content

Commit eb6fa57

Browse files
authored
Merge pull request #9564 from simstudioai/codex/extension-0-1-2-release
fix(extension): release page capture and composer improvements
2 parents df7709b + a812676 commit eb6fa57

13 files changed

Lines changed: 407 additions & 35 deletions

File tree

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

Lines changed: 113 additions & 7 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
}
@@ -643,8 +659,17 @@ try {
643659
expression: `parent.postMessage(${JSON.stringify(action)}, ${JSON.stringify(`chrome-extension://${extensionId}`)})`,
644660
userGesture: false,
645661
})
646-
await expect(panel.locator('body')).toHaveAttribute('data-observed-actions', '1')
647-
await expect(panel.locator('body')).toHaveAttribute('data-action-queries', '0')
662+
await expect
663+
.poll(async () => {
664+
const { result } = await parentCdp.send('Runtime.evaluate', {
665+
expression:
666+
'[document.body.dataset.observedActions, document.body.dataset.actionQueries]',
667+
returnByValue: true,
668+
userGesture: false,
669+
})
670+
return result.value
671+
})
672+
.toEqual(['1', '0'])
648673
assert((await snapshots()).length === before, 'Captured without a user gesture')
649674
await app.evaluate(({ action, targetOrigin }) => parent.postMessage(action, targetOrigin), {
650675
action: { ...action, sessionId: generateId() },
@@ -668,6 +693,87 @@ try {
668693
await parentCdp.detach()
669694
}
670695
)
696+
await check('Ordinary Sim and encoded remote pages attach without sensitive fields', async () => {
697+
for (const url of [
698+
`${origin}/workspace/example`,
699+
`${origin}/extension-notes`,
700+
`${origin}/workspace/%E2%9C%93`,
701+
`${pageOrigin}/ticket/100%done`,
702+
]) {
703+
const before = (await snapshots()).length
704+
await page.goto(`${url}?token=URL_SECRET#FRAGMENT_SECRET`)
705+
await page.bringToFront()
706+
await capture()
707+
await expect.poll(async () => (await snapshots()).length).toBe(before + 1)
708+
const snapshot = (await snapshots()).at(-1)
709+
assert(snapshot?.url === url, 'Page URL retained private parameters')
710+
assert(snapshot.text.includes('visible-reference-482'), 'Visible page content is missing')
711+
assert(!snapshot.text.includes('SENTINEL'), 'Sensitive page content was captured')
712+
}
713+
})
714+
await check('Embedded chat and malformed Sim paths fail closed', async () => {
715+
for (const path of [
716+
'/extension',
717+
'/extension/workspace',
718+
'/extension/o/organization',
719+
'/%65xtension',
720+
'/%65xtension/workspace',
721+
'/extension%2fworkspace',
722+
'/extension%ZZ/workspace',
723+
]) {
724+
await page.goto(`${origin}/workspace/example`)
725+
await page.bringToFront()
726+
const previous = (await snapshots()).length
727+
await capture()
728+
await expect.poll(async () => (await snapshots()).length).toBe(previous + 1)
729+
await expect(panel.getByRole('status')).toHaveText('')
730+
const before = (await snapshots()).length
731+
await page.goto(`${origin}${path}`)
732+
await page.bringToFront()
733+
await capture()
734+
await panel.getByRole('status').filter({ hasText: 'This page cannot be attached.' }).waitFor()
735+
assert((await snapshots()).length === before, 'An excluded Sim path was captured')
736+
}
737+
})
738+
await check(
739+
'Revealed credentials stay excluded from exact and surrounding selections',
740+
async () => {
741+
await page.goto(`${origin}/workspace/example`)
742+
await page.bringToFront()
743+
const before = (await snapshots()).length
744+
await capture()
745+
await expect.poll(async () => (await snapshots()).length).toBe(before + 1)
746+
await expect(panel.getByRole('status')).toHaveText('')
747+
await page
748+
.locator('#secret-reveal code')
749+
.first()
750+
.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(panel.getByRole('status')).toContainText('could not be read')
758+
assert((await snapshots()).length === before + 1, 'A revealed credential was attached')
759+
await page.locator('#secret-reveal').evaluate((element) => {
760+
const range = document.createRange()
761+
range.selectNodeContents(element)
762+
window.getSelection()?.removeAllRanges()
763+
window.getSelection()?.addRange(range)
764+
})
765+
await capture('Selection')
766+
await expect.poll(async () => (await snapshots()).length).toBe(before + 2)
767+
const snapshot = (await snapshots()).at(-1)
768+
assert(snapshot, 'Missing surrounding selection snapshot')
769+
assert(snapshot.text.includes('Credential instructions'), 'Public selection text is missing')
770+
assert(snapshot.text.includes('visible-code-control'), 'Ordinary selected code was excluded')
771+
assert(
772+
!snapshot.text.includes('SECRET_REVEAL_SENTINEL'),
773+
'A surrounding selection leaked the credential'
774+
)
775+
}
776+
)
671777
await check(
672778
'Site access stays modal without violating the packaged security policy',
673779
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/(landing)/privacy/privacy-content.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function richText(content: string): ReactNode {
4444
export const PRIVACY_CONFIG: LegalPageConfig = {
4545
title: 'Privacy Policy',
4646
description: 'Sim Studio, Inc. · Operating the Sim platform (sim.ai)',
47-
lastUpdated: 'October 31, 2026',
47+
lastUpdated: 'November 2, 2026',
4848
intro: [
4949
{
5050
kind: 'paragraph',
@@ -833,6 +833,12 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
833833
'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.'
834834
),
835835
},
836+
{
837+
kind: 'paragraph',
838+
content: richText(
839+
'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.'
840+
),
841+
},
836842
{
837843
kind: 'subheading',
838844
text: 'Chrome Web Store Limited Use',

‎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)