Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,6 @@ log.txt

# Testing iteration temp files
tmp/

# Coding agent artifacts
.agent/
1 change: 1 addition & 0 deletions apps/agent/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ BROWSEROS_BINARY=/Applications/BrowserOS.app/Contents/MacOS/BrowserOS
VITE_PUBLIC_POSTHOG_KEY=
VITE_PUBLIC_POSTHOG_HOST=
VITE_PUBLIC_SENTRY_DSN=
VITE_PUBLIC_INTERCOM_APP_ID=

# BrowserOS API URL
VITE_PUBLIC_BROWSEROS_API=https://api.browseros.com
Expand Down
11 changes: 7 additions & 4 deletions apps/agent/entrypoints/app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Toaster } from '@/components/ui/sonner'
import { AnalyticsProvider } from '@/lib/analytics/AnalyticsProvider.tsx'
import { AuthProvider } from '@/lib/auth/AuthProvider'
import { QueryProvider } from '@/lib/graphql/QueryProvider'
import { IntercomProvider } from '@/lib/intercom/IntercomProvider'
import { sentryRootErrorHandler } from '@/lib/sentry/sentryRootErrorHandler.ts'
import { App } from './App'

Expand All @@ -17,10 +18,12 @@ if ($root) {
<AuthProvider>
<QueryProvider>
<AnalyticsProvider>
<ThemeProvider>
<App />
<Toaster />
</ThemeProvider>
<IntercomProvider>
<ThemeProvider>
<App />
<Toaster />
</ThemeProvider>
</IntercomProvider>
</AnalyticsProvider>
</QueryProvider>
</AuthProvider>
Expand Down
17 changes: 17 additions & 0 deletions apps/agent/entrypoints/intercom.sandbox/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<style>
html, body {
margin: 0;
padding: 0;
background: transparent;
overflow: hidden;
}
</style>
</head>
<body>
<script src="./main.ts" type="module"></script>
</body>
</html>
101 changes: 101 additions & 0 deletions apps/agent/entrypoints/intercom.sandbox/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { IntercomParentMessage } from '../../lib/intercom/intercom'

type IntercomFn = ((...args: unknown[]) => void) & { q?: unknown[][] }

declare global {
interface Window {
Intercom: IntercomFn
intercomSettings: Record<string, unknown>
}
}

function sendToParent(message: { type: string }) {
parent.postMessage(message, '*')
}

function initIntercomStub() {
Comment on lines +13 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

postMessage targetOrigin too broad
The sandbox sends events via parent.postMessage(message, '*'), which allows any embedding origin to receive Intercom state events if this page is ever embedded outside the extension context. Since the parent is expected to be the extension page, pass an explicit targetOrigin (or at least validate event.origin on the parent side and restrict event.source).

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/agent/entrypoints/intercom.sandbox/main.ts
Line: 13:16

Comment:
**postMessage targetOrigin too broad**
The sandbox sends events via `parent.postMessage(message, '*')`, which allows any embedding origin to receive Intercom state events if this page is ever embedded outside the extension context. Since the parent is expected to be the extension page, pass an explicit `targetOrigin` (or at least validate `event.origin` on the parent side and restrict `event.source`).


How can I resolve this? If you propose a fix, please make it concise.

const i = ((...args: unknown[]) => {
i.c(args)
}) as ((...args: unknown[]) => void) & {
q: unknown[][]
c: (args: unknown[]) => void
}
i.q = []
i.c = (args: unknown[]) => {
i.q.push(args)
}
window.Intercom = i as IntercomFn
}

function loadIntercomScript(appId: string): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = `https://widget.intercom.io/widget/${appId}`
script.async = true
script.onload = () => resolve()
script.onerror = () => reject(new Error('Failed to load Intercom script'))
document.head.appendChild(script)
})
}

let booted = false

async function handleBoot(appId: string) {
if (booted) return
booted = true

initIntercomStub()
await loadIntercomScript(appId)

window.Intercom('boot', {
app_id: appId,
vertical_padding: 0,
horizontal_padding: 0,
})

window.Intercom('onShow', () => {
sendToParent({ type: 'intercom:messenger-opened' })
})

window.Intercom('onHide', () => {
sendToParent({ type: 'intercom:messenger-closed' })
})

sendToParent({ type: 'intercom:ready' })
}

function handleUpdate(userId: string, email: string, name: string) {
if (!window.Intercom) return
window.Intercom('update', {
user_id: userId,
email,
name,
})
}

function handleShutdown() {
if (!window.Intercom) return
window.Intercom('shutdown')
}

window.addEventListener('message', (event: MessageEvent) => {
const data = event.data as IntercomParentMessage
if (
!data ||
typeof data.type !== 'string' ||
!data.type.startsWith('intercom:')
)
return

switch (data.type) {
case 'intercom:boot':
handleBoot(data.appId)
break
case 'intercom:update':
handleUpdate(data.userId, data.email, data.name)
break
case 'intercom:shutdown':
handleShutdown()
break
}
})
1 change: 1 addition & 0 deletions apps/agent/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const EnvSchema = z.object({
VITE_PUBLIC_POSTHOG_KEY: z.string().optional(),
VITE_PUBLIC_POSTHOG_HOST: z.string().optional(),
VITE_PUBLIC_SENTRY_DSN: z.string().optional(),
VITE_PUBLIC_INTERCOM_APP_ID: z.string().optional(),
VITE_PUBLIC_BROWSEROS_API: z.string().optional(),
PROD: z.boolean(),
})
Expand Down
119 changes: 119 additions & 0 deletions apps/agent/lib/intercom/IntercomProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { FC, PropsWithChildren } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useSessionInfo } from '@/lib/auth/sessionStorage'
Comment on lines +1 to +3
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sandbox boot may be missed
If sessionInfo resolves before the iframe onLoad fires, the identity sync effect posts intercom:update while iframeWindowRef.current is still null, and there’s no retry after boot. In that scenario, Intercom will boot without user identity until a later session change occurs. Consider sending an intercom:update after intercom:ready (or after handleIframeLoad) when sessionInfo.user is already available.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/agent/lib/intercom/IntercomProvider.tsx
Line: 1:3

Comment:
**Sandbox boot may be missed**
If `sessionInfo` resolves before the iframe `onLoad` fires, the identity sync effect posts `intercom:update` while `iframeWindowRef.current` is still `null`, and there’s no retry after boot. In that scenario, Intercom will boot without user identity until a later session change occurs. Consider sending an `intercom:update` after `intercom:ready` (or after `handleIframeLoad`) when `sessionInfo.user` is already available.


How can I resolve this? If you propose a fix, please make it concise.

import { env } from '@/lib/env'
import { sentry } from '@/lib/sentry/sentry'
import {
INTERCOM_LAUNCHER_HEIGHT,
INTERCOM_LAUNCHER_WIDTH,
INTERCOM_MESSENGER_HEIGHT,
INTERCOM_MESSENGER_WIDTH,
isIntercomSandboxEvent,
} from './intercom'

export const IntercomProvider: FC<PropsWithChildren> = ({ children }) => {
const appId = env.VITE_PUBLIC_INTERCOM_APP_ID
if (!appId) return <>{children}</>

return <IntercomProviderInner appId={appId}>{children}</IntercomProviderInner>
}

const IntercomProviderInner: FC<PropsWithChildren<{ appId: string }>> = ({
appId,
children,
}) => {
const iframeRef = useRef<HTMLIFrameElement>(null)
const iframeWindowRef = useRef<Window | null>(null)
const [isMessengerOpen, setIsMessengerOpen] = useState(false)
const { sessionInfo, isLoading } = useSessionInfo()

const handleIframeLoad = () => {
Comment on lines +27 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

postMessage accepts any origin
postMessage(..., '*') combined with a sandbox that also posts with '*' means any frame/window can spoof intercom:* messages to this page. Even though the listener checks type, it doesn’t verify event.source === iframeRef.current?.contentWindow (or an expected origin), so a malicious page could toggle state or trigger shutdown/update. Tighten validation to only accept messages from the sandbox iframe window.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/agent/lib/intercom/IntercomProvider.tsx
Line: 27:30

Comment:
**postMessage accepts any origin**
`postMessage(..., '*')` combined with a sandbox that also posts with `'*'` means any frame/window can spoof `intercom:*` messages to this page. Even though the listener checks `type`, it doesn’t verify `event.source === iframeRef.current?.contentWindow` (or an expected origin), so a malicious page could toggle state or trigger shutdown/update. Tighten validation to only accept messages from the sandbox iframe window.


How can I resolve this? If you propose a fix, please make it concise.

iframeWindowRef.current = iframeRef.current?.contentWindow ?? null
try {
iframeWindowRef.current?.postMessage(
{ type: 'intercom:boot', appId },
'*',
)
} catch (error) {
sentry.captureException(error, {
extra: { message: 'Failed to send boot command to Intercom sandbox' },
})
}
}

useEffect(() => {
const handler = (event: MessageEvent) => {
if (!isIntercomSandboxEvent(event.data)) return

switch (event.data.type) {
case 'intercom:ready':
break
case 'intercom:messenger-opened':
setIsMessengerOpen(true)
break
case 'intercom:messenger-closed':
setIsMessengerOpen(false)
break
}
}

window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}, [])

useEffect(() => {
if (isLoading) return

try {
if (sessionInfo.user) {
iframeWindowRef.current?.postMessage(
{
type: 'intercom:update',
userId: sessionInfo.user.id,
email: sessionInfo.user.email,
name: sessionInfo.user.name,
},
'*',
)
} else {
iframeWindowRef.current?.postMessage({ type: 'intercom:shutdown' }, '*')
}
} catch (error) {
sentry.captureException(error, {
extra: { message: 'Failed to sync identity with Intercom sandbox' },
})
}
}, [sessionInfo.user, isLoading])

const width = isMessengerOpen
? INTERCOM_MESSENGER_WIDTH
: INTERCOM_LAUNCHER_WIDTH
const height = isMessengerOpen
? INTERCOM_MESSENGER_HEIGHT
: INTERCOM_LAUNCHER_HEIGHT

return (
<>
{children}
<iframe
ref={iframeRef}
title="Intercom Messenger"
src={chrome.runtime.getURL('intercom.html')}
onLoad={handleIframeLoad}
allow="microphone"
style={{
position: 'fixed',
bottom: 20,
right: 20,
zIndex: 9999,
border: 'none',
background: 'transparent',
width,
height,
transition: 'width 0.2s ease, height 0.2s ease',
pointerEvents: 'auto',
}}
/>
</>
)
}
57 changes: 57 additions & 0 deletions apps/agent/lib/intercom/intercom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export type IntercomBootCommand = {
type: 'intercom:boot'
appId: string
}

export type IntercomUpdateCommand = {
type: 'intercom:update'
userId: string
email: string
name: string
}

export type IntercomShutdownCommand = {
type: 'intercom:shutdown'
}

export type IntercomParentMessage =
| IntercomBootCommand
| IntercomUpdateCommand
| IntercomShutdownCommand

export type IntercomReadyEvent = {
type: 'intercom:ready'
}

export type IntercomMessengerOpenedEvent = {
type: 'intercom:messenger-opened'
}

export type IntercomMessengerClosedEvent = {
type: 'intercom:messenger-closed'
}

export type IntercomSandboxEvent =
| IntercomReadyEvent
| IntercomMessengerOpenedEvent
| IntercomMessengerClosedEvent

export const INTERCOM_LAUNCHER_WIDTH = 80
export const INTERCOM_LAUNCHER_HEIGHT = 80
export const INTERCOM_MESSENGER_WIDTH = 420
export const INTERCOM_MESSENGER_HEIGHT = 650

const KNOWN_SANDBOX_EVENT_TYPES = new Set([
'intercom:ready',
'intercom:messenger-opened',
'intercom:messenger-closed',
])

export function isIntercomSandboxEvent(
data: unknown,
): data is IntercomSandboxEvent {
if (typeof data !== 'object' || data === null) return false
const msg = data as { type?: unknown }
if (typeof msg.type !== 'string') return false
return KNOWN_SANDBOX_EVENT_TYPES.has(msg.type)
}
11 changes: 11 additions & 0 deletions apps/agent/wxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ export default defineConfig({
'https://duckduckgo.com/*',
'https://suggest.yandex.com/*',
],
content_security_policy: {
sandbox:
"sandbox allow-scripts allow-forms allow-popups allow-modals; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://widget.intercom.io https://js.intercomcdn.com https://app.intercom.io; " +
"connect-src 'self' https://*.intercom.io https://*.intercomcdn.com https://*.intercom-messenger.com wss://*.intercom-messenger.com wss://*.intercom.io https://uploads.intercomusercontent.com; " +
"frame-src 'self' https://share.intercom.io https://intercom-sheets.com https://www.youtube.com https://player.vimeo.com; " +
"img-src 'self' blob: data: https://*.intercomcdn.com https://static.intercomassets.com https://uploads.intercomusercontent.com https://gifs.intercomcdn.com; " +
"font-src 'self' https://js.intercomcdn.com https://fonts.intercomcdn.com; " +
"style-src 'self' 'unsafe-inline'; " +
"media-src 'self' https://js.intercomcdn.com",
},
},
vite: () => ({
build: {
Expand Down
Loading