diff --git a/.github/scripts/http-e2e.sh b/.github/scripts/http-e2e.sh
index ab384903c0d..3443959ef4d 100755
--- a/.github/scripts/http-e2e.sh
+++ b/.github/scripts/http-e2e.sh
@@ -145,6 +145,7 @@ case "$group" in
export DB_TX_TRIPWIRE=throw
export NEXT_PUBLIC_CHAT_DISABLED=true
export SIM_LINKS=true
+ export PEOPLE=true
start_app scim 3017 SCIM record-http-status
SCIM_E2E_BASE_URL="$local_app_url" \
SCIM_E2E_DATABASE_URL="$DATABASE_URL" \
@@ -161,6 +162,11 @@ case "$group" in
SIM_LINKS_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
SIM_LINKS_E2E_REPORT_PATH="$report_dir/sim-links-e2e-report.json" \
bun run test:sim-links:e2e
+ PEOPLE_E2E_BASE_URL="$local_app_url" \
+ PEOPLE_E2E_DATABASE_URL="$DATABASE_URL" \
+ PEOPLE_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
+ PEOPLE_E2E_REPORT_PATH="$report_dir/people-e2e-report.json" \
+ bun run test:people:e2e
;;
# The search suites serve their own fixtures in-process and need no app. They share this group
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 75277ba3f97..c2d2c3a5e66 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -114,6 +114,7 @@ jobs:
migrate:
permissions:
contents: read
+ id-token: write
name: migrate
needs: [checks]
# Explicit need results instead of the implicit success(): a skipped job
@@ -133,6 +134,8 @@ jobs:
STAGING_DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }}
STAGING_MIGRATION_DATABASE_URL: ${{ secrets.STAGING_MIGRATION_DATABASE_URL }}
+ TS_OAUTH_CLIENT_ID: ${{ secrets.TS_OAUTH_CLIENT_ID }}
+ TS_AUDIENCE: ${{ secrets.TS_AUDIENCE }}
# Same ordering for dev (schema push before the dev image lands in ECR)
migrate-dev:
@@ -146,6 +149,8 @@ jobs:
environment: dev
secrets:
DEV_DATABASE_URL: ${{ secrets.DEV_DATABASE_URL }}
+ TS_OAUTH_CLIENT_ID: ${{ secrets.TS_OAUTH_CLIENT_ID }}
+ TS_AUDIENCE: ${{ secrets.TS_AUDIENCE }}
# Dev: build immutable images alongside the schema push and Trigger upload.
image-dev:
@@ -1166,6 +1171,9 @@ jobs:
# Process docs embeddings (only when docs change, after images are promoted)
docs:
+ permissions:
+ contents: read
+ id-token: write
name: docs
needs: [promote, docs-changes]
# Explicit results: see migrate's comment.
@@ -1178,6 +1186,8 @@ jobs:
secrets:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ TS_OAUTH_CLIENT_ID: ${{ secrets.TS_OAUTH_CLIENT_ID }}
+ TS_AUDIENCE: ${{ secrets.TS_AUDIENCE }}
# Create GitHub Release (only for version commits on main, after all builds complete)
release:
diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml
index b34719e1bb1..b7d43ae85c1 100644
--- a/.github/workflows/desktop-e2e.yml
+++ b/.github/workflows/desktop-e2e.yml
@@ -115,6 +115,7 @@ jobs:
PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/desktop-smoke.json
BACKGROUND_EXECUTOR_REPORT_PATH: test-results/background-executor-report.json
TERMINAL_CANCEL_REPORT_PATH: test-results/terminal-cancel-report.json
+ CONTINUE_SIGN_IN_REPORT_PATH: test-results/continue-sign-in-report.json
- name: Upload test results
if: failure()
@@ -192,6 +193,7 @@ jobs:
env:
BACKGROUND_EXECUTOR_REPORT_PATH: test-results/background-executor-report.json
TERMINAL_CANCEL_REPORT_PATH: test-results/terminal-cancel-report.json
+ CONTINUE_SIGN_IN_REPORT_PATH: test-results/continue-sign-in-report.json
- name: Upload test results
if: failure()
diff --git a/.github/workflows/docs-negotiation.yml b/.github/workflows/docs-negotiation.yml
index 541d5af8e3f..64b55a001c7 100644
--- a/.github/workflows/docs-negotiation.yml
+++ b/.github/workflows/docs-negotiation.yml
@@ -226,9 +226,16 @@ jobs:
if: always() && steps.build.outcome == 'success'
run: |
mkdir -p apps/docs/test-results
+ FEEDBACK_TLS_DIR=$(mktemp -d)
+ export DOCS_FEEDBACK_TEST_CERT="$FEEDBACK_TLS_DIR/cert.pem"
+ export DOCS_FEEDBACK_TEST_KEY="$FEEDBACK_TLS_DIR/key.pem"
+ openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=127.0.0.1' -addext 'subjectAltName=IP:127.0.0.1' -keyout "$DOCS_FEEDBACK_TEST_KEY" -out "$DOCS_FEEDBACK_TEST_CERT" 2>/dev/null
+ export NODE_EXTRA_CA_CERTS="$DOCS_FEEDBACK_TEST_CERT"
+ export DOCS_FEEDBACK_WEBHOOK_URL=https://127.0.0.1:3443/feedback
+ export DOCS_FEEDBACK_WEBHOOK_TOKEN=$(openssl rand -hex 32)
bun run --cwd apps/docs start --port 3001 >apps/docs/test-results/docs-server.log 2>&1 &
DOCS_SERVER_PID=$!
- trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true' EXIT
+ trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true; rm -rf "$FEEDBACK_TLS_DIR"' EXIT
STATUS=0
bun run --cwd apps/docs test:negotiation:e2e || STATUS=1
bun run --cwd apps/docs scripts/test-docs-interfaces-e2e.ts || STATUS=1
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 99ec8cf1ce8..b11e5b4f7fd 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -7,6 +7,10 @@ on:
required: false
OPENAI_API_KEY:
required: false
+ TS_OAUTH_CLIENT_ID:
+ required: false
+ TS_AUDIENCE:
+ required: false
workflow_dispatch:
inputs:
publish:
@@ -40,6 +44,10 @@ jobs:
concurrency:
group: docs-search-index-production
cancel-in-progress: false
+ permissions:
+ contents: read
+ # Tailscale workload identity federation exchanges this job's OIDC token.
+ id-token: write
steps:
- name: Record verified deployment and backup
@@ -68,6 +76,15 @@ jobs:
provider: ${{ vars.CI_PROVIDER }}
node-version: '24'
+ # Database IP restrictions admit the tailnet's app connector; see migrate.yml.
+ - name: Join the tailnet
+ if: vars.CI_DB_TAILNET == 'true'
+ uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd # v4.2.0
+ with:
+ oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
+ audience: ${{ secrets.TS_AUDIENCE }}
+ tags: tag:ci
+
- name: Process docs embeddings
working-directory: ./apps/docs
env:
diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml
index 4975ca60334..fcad4344b40 100644
--- a/.github/workflows/migrate.yml
+++ b/.github/workflows/migrate.yml
@@ -18,6 +18,10 @@ on:
required: false
STAGING_MIGRATION_DATABASE_URL:
required: false
+ TS_OAUTH_CLIENT_ID:
+ required: false
+ TS_AUDIENCE:
+ required: false
workflow_dispatch:
inputs:
environment:
@@ -46,6 +50,10 @@ jobs:
concurrency:
group: migrate-${{ inputs.environment }}
cancel-in-progress: false
+ permissions:
+ contents: read
+ # Tailscale workload identity federation exchanges this job's OIDC token.
+ id-token: write
steps:
# An environment only migrates from its own branch, so a manual dispatch
@@ -73,6 +81,18 @@ jobs:
exit 1
fi
+ # Database IP restrictions admit the tailnet's app connector, never a
+ # runner's address. The action already passes --accept-routes, which
+ # Linux nodes need to use app connector routes; repeating it in args
+ # makes `tailscale up` reject the duplicate flag.
+ - name: Join the tailnet
+ if: vars.CI_DB_TAILNET == 'true'
+ uses: tailscale/github-action@d1b6cd204f8dceda5b3eaad7f1f767be390056cd # v4.2.0
+ with:
+ oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
+ audience: ${{ secrets.TS_AUDIENCE }}
+ tags: tag:ci
+
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
with:
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 7f9415e3fdc..ce6f3212a71 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -171,6 +171,11 @@ tabs and popups select their session from the destination origin. Sign-out and a
or server changes use the existing browser teardown. Browser views retain their own
permission/download policy, SSRF guards, and minimal preload with no `simDesktop` API.
+**Continue in Chrome** (browser menu, plus a hint on known sign-in hosts) opens the active
+https tab in a chosen Chromium-family profile and **Bring session back** imports only that
+page's registrable-domain cookies from it; the rationale and gating live on
+`SimDesktopBrowserImportApi.continueSignIn` in `@sim/desktop-bridge`.
+
Generated HTML remains inside its `allow-scripts` sandbox. The driver can inspect and
interact with inline frames through isolated Chromium worlds, including out-of-process
frames; it does not grant those pages access to the parent app. These shell changes
diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist
index 51ec6a97a57..edf7bcaf4b9 100644
--- a/apps/desktop/build/entitlements.mac.plist
+++ b/apps/desktop/build/entitlements.mac.plist
@@ -14,10 +14,5 @@
Hardened Runtime denies the camera no matter what the user allowed. -->
com.apple.security.device.camera
-
- com.apple.security.device.bluetooth
-
diff --git a/apps/desktop/e2e-durations.json b/apps/desktop/e2e-durations.json
index 2557a2730a8..e82a13f8ac3 100644
--- a/apps/desktop/e2e-durations.json
+++ b/apps/desktop/e2e-durations.json
@@ -7,6 +7,7 @@
"chat-panel.spec.ts": 31.08,
"check-report.spec.ts": 3.78,
"computer-use.spec.ts": 2.07,
+ "continue-sign-in.spec.ts": 5.8,
"desktop-tools-live-sim.spec.ts": 0.0,
"local-files.spec.ts": 27.68,
"packaged-smoke.spec.ts": 0.0,
diff --git a/apps/desktop/e2e/browser-page-parity.spec.ts b/apps/desktop/e2e/browser-page-parity.spec.ts
new file mode 100644
index 00000000000..ec1c1256536
--- /dev/null
+++ b/apps/desktop/e2e/browser-page-parity.spec.ts
@@ -0,0 +1,459 @@
+import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
+import { createServer, type ServerResponse } from 'node:http'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import {
+ type ElectronApplication,
+ _electron as electron,
+ expect,
+ type Page,
+ test,
+} from '@playwright/test'
+import type { BrowserPageDialog, BrowserToolName } from '@sim/browser-protocol'
+import type { SimDesktopApi } from '@sim/desktop-bridge'
+import { getErrorMessage } from '@sim/utils/errors'
+import { recordCheck } from './check-report'
+
+/**
+ * The embedded browser against what a user expects of Chrome: the page menu,
+ * history and tab keys, Escape on a slow load, and a video's fullscreen
+ * button. Each test drives the real shell and native tab views with trusted
+ * input, and lands in a JSON report at BROWSER_PAGE_PARITY_REPORT_PATH
+ * (test-results/browser-page-parity-report.json by default) as it finishes.
+ */
+
+const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
+const SCOPE = 'browser-page-parity-e2e'
+const IS_MAC = process.platform === 'darwin'
+const SHELL_FIXTURE = '
Sim fixture Browser parity fixture '
+/** A 2x2 PNG, so a saved copy is checked byte for byte. */
+const PNG = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFklEQVR4nGP8z8DwnwEIGBkZGRgYAAAhBAEB/0bFxgAAAABJRU5ErkJggg==',
+ 'base64'
+)
+const PAGES: Record = {
+ '/form': `form
+`,
+ '/image':
+ 'image ',
+ '/links': 'links Landing ',
+ '/fullscreen': `fullscreen
+Fullscreen
+`,
+}
+
+type Bridge = typeof globalThis & {
+ simDesktop: SimDesktopApi
+ pageDialog?: BrowserPageDialog | null
+ boundsTimer?: number
+}
+/** What the suite installs in the main process. */
+type MainFixture = typeof globalThis & {
+ agentMenus?: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[][]
+ visibleView?: () => Electron.WebContentsView | undefined
+}
+
+const reportPath = () =>
+ process.env.BROWSER_PAGE_PARITY_REPORT_PATH ??
+ join(test.info().project.outputDir, 'browser-page-parity-report.json')
+
+/** Runs one check and adds its outcome to the report as soon as it is known. */
+async function check(name: string, body: () => Promise): Promise {
+ const startedAt = Date.now()
+ const record = (status: 'passed' | 'failed', error?: unknown) =>
+ recordCheck(reportPath(), 'browser-page-parity', {
+ name,
+ status,
+ durationMs: Date.now() - startedAt,
+ retry: test.info().retry,
+ ...(error === undefined ? {} : { error: getErrorMessage(error) }),
+ })
+ try {
+ await body()
+ record('passed')
+ } catch (error) {
+ record('failed', error)
+ throw error
+ }
+}
+
+/** A test whose outcome is also one check in the suite's report. */
+function checkedTest(name: string, body: () => Promise): void {
+ test(name, () => check(name, body))
+}
+
+test.describe('browser page parity with Chrome', () => {
+ let app: ElectronApplication | undefined
+ let shell: Page
+ let site = ''
+ let userData = ''
+ let downloads = ''
+ /** Held responses for the slow page, released when the suite ends. */
+ const held = new Set()
+ /** The Referer each load of the link target arrived with. */
+ const referrers: (string | undefined)[] = []
+ /** The Referer each request for the image arrived with. */
+ const imageReferrers: (string | undefined)[] = []
+ const calls = new Map()
+ const server = createServer(async (request, response) => {
+ const path = new URL(request.url ?? '/', 'http://localhost').pathname
+ if (path === '/api/desktop/tool/authorize') {
+ let body = ''
+ for await (const chunk of request) body += chunk.toString()
+ const authorization = calls.get(JSON.parse(body).toolCallId)
+ response.writeHead(authorization ? 200 : 403, { 'Content-Type': 'application/json' })
+ response.end(JSON.stringify(authorization ?? {}))
+ return
+ }
+ if (path === '/pixel.png') {
+ imageReferrers.push(request.headers.referer)
+ // Hotlink-protected, like many image hosts: a request without the page as referrer fails.
+ if (!request.headers.referer) {
+ response.writeHead(403)
+ response.end()
+ return
+ }
+ response.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' })
+ response.end(PNG)
+ return
+ }
+ if (path === '/landing') referrers.push(request.headers.referer)
+ if (path === '/slow') {
+ held.add(response)
+ return
+ }
+ // The app origin serves the shell; the same server on localhost is the web.
+ const isSite = request.headers.host?.startsWith('localhost') === true
+ response.writeHead(200, {
+ 'Content-Type': 'text/html',
+ ...(isSite ? {} : { 'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; Path=/' }),
+ })
+ response.end(
+ isSite
+ ? (PAGES[path] ?? `${path.slice(1)} ${path}
`)
+ : SHELL_FIXTURE
+ )
+ })
+
+ test.beforeAll(async () => {
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const address = server.address()
+ if (!address || typeof address === 'string') throw new Error('Missing fixture address')
+ const origin = `http://127.0.0.1:${address.port}`
+ site = origin.replace('127.0.0.1', 'localhost')
+ userData = mkdtempSync(join(tmpdir(), 'sim-browser-parity-e2e-'))
+ downloads = mkdtempSync(join(tmpdir(), 'sim-browser-parity-downloads-'))
+ app = await electron.launch({
+ args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
+ cwd: DESKTOP_DIR,
+ env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData },
+ })
+ // A dialog listener stops Playwright auto-dismissing a page's leave-site
+ // question, so the desktop's own handling decides it as in production.
+ const leaveDialogsToDesktop = (page: Page) => page.on('dialog', () => {})
+ app.context().pages().forEach(leaveDialogsToDesktop)
+ app.context().on('page', leaveDialogsToDesktop)
+ shell = await app.firstWindow()
+ await app.evaluate(({ app, BrowserWindow, Menu, WebContentsView }, downloads) => {
+ // Downloads default to the OS folder; keep the suite's files out of the user's.
+ app.setPath('downloads', downloads)
+ const host = BrowserWindow.getAllWindows()[0]
+ host.webContents.setBackgroundThrottling(false)
+ app.focus({ steal: true })
+ host.focus()
+ // A native menu blocks the main process while it is open; keep each page
+ // menu's items so a check can choose one the way a click would.
+ const capture = globalThis as MainFixture
+ capture.visibleView = () =>
+ BrowserWindow.getAllWindows()[0].contentView.children.find(
+ (child): child is Electron.WebContentsView =>
+ child instanceof WebContentsView && child.getVisible()
+ )
+ capture.agentMenus = []
+ const build = Menu.buildFromTemplate.bind(Menu)
+ Menu.buildFromTemplate = (template) => {
+ capture.agentMenus?.push(template)
+ const menu = build(template)
+ menu.popup = () => {}
+ return menu
+ }
+ }, downloads)
+ await expect(shell.getByRole('heading')).toHaveText('Browser parity fixture')
+ await shell.evaluate(async (scope) => {
+ const bridge = globalThis as Bridge
+ const api = bridge.simDesktop.browserAgent
+ await api.activateScope(scope)
+ api.onPageState((state) => {
+ bridge.pageDialog = state.dialog ?? null
+ })
+ const updateBounds = () =>
+ api.setPanelBounds(
+ { x: 0, y: 80, width: innerWidth, height: innerHeight - 80 },
+ null,
+ scope
+ )
+ updateBounds()
+ bridge.boundsTimer = window.setInterval(updateBounds, 200)
+ }, SCOPE)
+ await execute('browser_open_url', { url: `${site}/start` })
+ await panelAction({ action: 'switch-tab', tabId: '1' })
+ await panelAction({ action: 'enable-page-dialogs' })
+ })
+
+ test.afterAll(async () => {
+ for (const response of held) response.destroy()
+ await app?.close()
+ server.closeAllConnections()
+ await new Promise((resolve) => server.close(() => resolve()))
+ rmSync(userData, { recursive: true, force: true })
+ rmSync(downloads, { recursive: true, force: true })
+ })
+
+ let callCount = 0
+ async function execute(tool: BrowserToolName, args: Record) {
+ const callId = `browser-parity-${++callCount}`
+ calls.set(callId, { chatId: SCOPE, toolName: tool, args })
+ const result = await shell.evaluate(
+ ({ callId, tool, args, scope }) =>
+ (globalThis as Bridge).simDesktop.browserAgent.executeTool(callId, tool, args, scope),
+ { callId, tool, args, scope: SCOPE }
+ )
+ expect(result.ok, result.error).toBe(true)
+ }
+
+ /** Browser-chrome actions are user gestures, so each follows a real click in Sim. */
+ async function panelAction(action: Record) {
+ await shell.getByRole('heading').click()
+ await shell.evaluate(
+ ({ action, scope }) =>
+ (globalThis as Bridge).simDesktop.browserAgent.panelAction(
+ action as unknown as Parameters[0],
+ scope
+ ),
+ { action, scope: SCOPE }
+ )
+ }
+
+ /** Reads one property of the page on screen from the main process. */
+ function visiblePage(read: string): Promise {
+ if (!app) throw new Error('App not launched')
+ return app.evaluate(({ BrowserWindow }, read) => {
+ const view = (globalThis as MainFixture).visibleView?.()
+ if (!view) return null
+ const contents = view.webContents
+ const values: Record = {
+ url: contents.getURL(),
+ title: contents.getTitle(),
+ loading: contents.isLoadingMainFrame(),
+ zoom: contents.getZoomFactor(),
+ bounds: view.getBounds(),
+ windowFullScreen: BrowserWindow.getAllWindows()[0].isFullScreen(),
+ }
+ return values[read]
+ }, read) as Promise
+ }
+
+ const pageUrl = () => visiblePage('url')
+ const pageTitle = () => visiblePage('title')
+ const pageDialog = () => shell.evaluate(() => (globalThis as Bridge).pageDialog ?? null)
+
+ /** Trusted input into the page on screen, the way the user's own mouse and keys arrive. */
+ async function inPage(
+ action:
+ | { kind: 'click' | 'right-click'; selector: string; offsetX?: number }
+ | { kind: 'type'; text: string }
+ | { kind: 'key'; keyCode: string; modifiers?: string[] }
+ | { kind: 'script'; script: string }
+ ): Promise {
+ if (!app) throw new Error('App not launched')
+ return app.evaluate(async (_electron, action) => {
+ const view = (globalThis as MainFixture).visibleView?.()
+ if (!view) throw new Error('No visible page')
+ const contents = view.webContents
+ if (action.kind === 'script') return contents.executeJavaScript(action.script)
+ if (action.kind === 'type') {
+ for (const character of action.text) {
+ contents.sendInputEvent({ type: 'char', keyCode: character })
+ }
+ return null
+ }
+ if (action.kind === 'key') {
+ const input = {
+ keyCode: action.keyCode,
+ modifiers: action.modifiers ?? [],
+ } as Electron.KeyboardInputEvent
+ contents.focus()
+ contents.sendInputEvent({ ...input, type: 'keyDown' })
+ contents.sendInputEvent({ ...input, type: 'keyUp' })
+ return null
+ }
+ const rect: { x: number; y: number; height: number } = await contents.executeJavaScript(
+ `(() => { const { x, y, height } = document.querySelector(${JSON.stringify(action.selector)}).getBoundingClientRect(); return { x, y, height } })()`
+ )
+ const point = {
+ x: Math.round(rect.x + (action.offsetX ?? 10)),
+ y: Math.round(rect.y + Math.min(rect.height / 2, 20)),
+ }
+ const button = action.kind === 'click' ? 'left' : 'right'
+ contents.sendInputEvent({ type: 'mouseDown', ...point, button, clickCount: 1 })
+ contents.sendInputEvent({ type: 'mouseUp', ...point, button, clickCount: 1 })
+ return null
+ }, action)
+ }
+
+ /** Right-clicks `selector` and returns the page menu's item labels. */
+ async function openPageMenu(selector: string, offsetX?: number): Promise {
+ if (!app) throw new Error('App not launched')
+ const menus = () =>
+ app?.evaluate(() => (globalThis as MainFixture).agentMenus?.length ?? 0) ?? 0
+ const before = await menus()
+ await inPage({ kind: 'right-click', selector, offsetX })
+ await expect.poll(menus).toBeGreaterThan(before)
+ return app.evaluate(() =>
+ ((globalThis as MainFixture).agentMenus?.at(-1) ?? [])
+ .filter((item) => item.type !== 'separator')
+ .map((item) => item.label ?? '')
+ )
+ }
+
+ /** Chooses an item from the page menu opened last. */
+ async function choosePageMenuItem(label: string): Promise {
+ if (!app) throw new Error('App not launched')
+ await app.evaluate((_electron, label) => {
+ const item = (globalThis as MainFixture).agentMenus
+ ?.at(-1)
+ ?.find((entry) => entry.label === label)
+ if (!item?.click) throw new Error(`The page menu has no "${label}" item`)
+ ;(item.click as () => void)()
+ }, label)
+ }
+
+ async function navigate(path: string): Promise {
+ await panelAction({ action: 'navigate', url: `${site}${path}` })
+ await expect.poll(pageUrl).toBe(`${site}${path}`)
+ await expect.poll(() => visiblePage('loading')).toBe(false)
+ }
+
+ checkedTest("the page menu's Back asks before leaving unsaved changes", async () => {
+ await navigate('/next')
+ await navigate('/form')
+ await inPage({ kind: 'click', selector: '#draft' })
+ await inPage({ kind: 'type', text: 'draft' })
+ await expect.poll(() => inPage({ kind: 'script', script: 'draft.value' })).toBe('draft')
+
+ await openPageMenu('#draft', 120)
+ await choosePageMenuItem('Back')
+ await expect.poll(pageDialog).toMatchObject({ kind: 'beforeunload' })
+ const stay = await pageDialog()
+ await panelAction({ action: 'respond-dialog', requestId: stay?.requestId, allowed: false })
+ await expect.poll(pageDialog).toBeNull()
+ expect(await pageUrl()).toBe(`${site}/form`)
+ expect(await inPage({ kind: 'script', script: 'draft.value' })).toBe('draft')
+
+ await openPageMenu('#draft', 120)
+ await choosePageMenuItem('Back')
+ await expect.poll(pageDialog).toMatchObject({ kind: 'beforeunload' })
+ const leave = await pageDialog()
+ await panelAction({ action: 'respond-dialog', requestId: leave?.requestId, allowed: true })
+ await expect.poll(pageUrl).toBe(`${site}/next`)
+ })
+
+ checkedTest(
+ "the page menu's Save Image lands through the browser's downloads, with the page as referrer",
+ async () => {
+ for (const file of readdirSync(downloads)) rmSync(join(downloads, file), { force: true })
+ await navigate('/image')
+ await expect
+ .poll(() => inPage({ kind: 'script', script: 'document.getElementById("pic").complete' }))
+ .toBe(true)
+ const labels = await openPageMenu('#pic')
+ expect(labels).toEqual(
+ expect.arrayContaining(['Open Image in New Tab', 'Copy Image', 'Copy Image Address'])
+ )
+ imageReferrers.length = 0
+ await choosePageMenuItem('Save Image')
+ await expect.poll(() => readdirSync(downloads)).toEqual(['pixel.png'])
+ expect(readFileSync(join(downloads, 'pixel.png')).equals(PNG)).toBe(true)
+ expect(imageReferrers).toEqual([`${site}/image`])
+ }
+ )
+
+ checkedTest("the page menu's Open Link in New Tab sends the page as referrer", async () => {
+ referrers.length = 0
+ await navigate('/links')
+ await openPageMenu('#link')
+ await choosePageMenuItem('Open Link in New Tab')
+ await expect.poll(() => referrers).toEqual([`${site}/links`])
+ })
+
+ checkedTest('tab keys, and history keys off macOS, typed in the page follow Chrome', async () => {
+ await navigate('/one')
+ await execute('browser_open_tab', { url: `${site}/two` })
+ await execute('browser_open_tab', { url: `${site}/three` })
+ const tabs = await shell.evaluate(
+ async (scope) =>
+ (await (globalThis as Bridge).simDesktop.browserAgent.activateScope(scope)).tabs,
+ SCOPE
+ )
+ // Earlier checks may have left tabs open; the keys move to the neighbours in strip order.
+ const urls = tabs.map((tab) => tab.url)
+ const index = urls.indexOf(`${site}/one`)
+ const next = urls[(index + 1) % urls.length]
+ const previous = urls[(index - 1 + urls.length) % urls.length]
+ expect(urls.length).toBeGreaterThan(2)
+ await panelAction({ action: 'switch-tab', tabId: tabs[index]?.tabId })
+ await expect.poll(pageUrl).toBe(`${site}/one`)
+
+ if (IS_MAC) {
+ await inPage({ kind: 'key', keyCode: 'Right', modifiers: ['meta', 'alt'] })
+ await expect.poll(pageUrl).toBe(next)
+ await inPage({ kind: 'key', keyCode: '[', modifiers: ['meta', 'shift'] })
+ await expect.poll(pageUrl).toBe(`${site}/one`)
+ await inPage({ kind: 'key', keyCode: '[', modifiers: ['meta', 'shift'] })
+ await expect.poll(pageUrl).toBe(previous)
+ await inPage({ kind: 'key', keyCode: ']', modifiers: ['meta', 'shift'] })
+ await expect.poll(pageUrl).toBe(`${site}/one`)
+ await inPage({ kind: 'key', keyCode: 'Left', modifiers: ['meta', 'alt'] })
+ await expect.poll(pageUrl).toBe(previous)
+ } else {
+ await inPage({ kind: 'key', keyCode: 'PageDown', modifiers: ['control'] })
+ await expect.poll(pageUrl).toBe(next)
+ await inPage({ kind: 'key', keyCode: 'PageUp', modifiers: ['control'] })
+ await expect.poll(pageUrl).toBe(`${site}/one`)
+ await navigate('/one-later')
+ await inPage({ kind: 'key', keyCode: 'Left', modifiers: ['alt'] })
+ await expect.poll(pageUrl).toBe(`${site}/one`)
+ await inPage({ kind: 'key', keyCode: 'Right', modifiers: ['alt'] })
+ await expect.poll(pageUrl).toBe(`${site}/one-later`)
+ }
+ })
+
+ checkedTest('Escape stops a page that is still loading', async () => {
+ await navigate('/before-slow')
+ await panelAction({ action: 'navigate', url: `${site}/slow` })
+ await expect.poll(() => visiblePage('loading')).toBe(true)
+ await inPage({ kind: 'key', keyCode: 'Escape' })
+ await expect.poll(() => visiblePage('loading'), { timeout: 3_000 }).toBe(false)
+ })
+
+ checkedTest("a page's fullscreen fills the browser panel and Escape leaves it", async () => {
+ await navigate('/fullscreen')
+ const bounds = await visiblePage('bounds')
+ await inPage({ kind: 'click', selector: '#go' })
+ await expect.poll(pageTitle).toBe('entered')
+ expect(await visiblePage('windowFullScreen')).toBe(false)
+ expect(await visiblePage('bounds')).toEqual(bounds)
+ await inPage({ kind: 'key', keyCode: 'Escape' })
+ await expect.poll(pageTitle).toBe('exited')
+ expect(await visiblePage('windowFullScreen')).toBe(false)
+ })
+})
diff --git a/apps/desktop/e2e/browser-sign-in-prompts.spec.ts b/apps/desktop/e2e/browser-sign-in-prompts.spec.ts
new file mode 100644
index 00000000000..d52d13cd97d
--- /dev/null
+++ b/apps/desktop/e2e/browser-sign-in-prompts.spec.ts
@@ -0,0 +1,560 @@
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { type ElectronApplication, _electron as electron, expect, test } from '@playwright/test'
+import type {
+ BrowserAppLaunchNotice,
+ BrowserPagePrompt,
+ BrowserToolName,
+} from '@sim/browser-protocol'
+import type { SimDesktopApi } from '@sim/desktop-bridge'
+import { getErrorMessage } from '@sim/utils/errors'
+import { sleep } from '@sim/utils/helpers'
+import { build } from 'esbuild'
+import postcss from 'postcss'
+import loadPostcssConfig from 'postcss-load-config'
+
+const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
+const SCOPE = 'browser-sign-in-prompts-e2e'
+const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url))
+const SHELL_FIXTURE =
+ 'Sim fixture Browser sign-in fixture
'
+const CREDENTIALS = `Basic ${Buffer.from('alice:s3cret').toString('base64')}`
+/** Long enough for a refused request to have surfaced if it were going to. */
+const QUIET_MS = 1_500
+
+type Bridge = typeof globalThis & {
+ simDesktop: SimDesktopApi
+ pagePrompt?: BrowserPagePrompt | null
+ launchNotice?: BrowserAppLaunchNotice | null
+ boundsTimer?: number
+}
+
+type MainGlobals = typeof globalThis & { openedExternal?: string[] }
+
+/**
+ * A browser tab's sign-in challenges and links to other apps belong to the
+ * user: Sim shows the question for the tab that asked, only a click in Sim
+ * answers it, and nothing is asked of an older renderer that cannot show it.
+ * These checks drive the real shell, native tab views, and Sim's prompt UI.
+ */
+test('sign-in and app-launch prompts wait for the user on the page that asked', async () => {
+ test.setTimeout(180_000)
+ const reportPath =
+ process.env.DESKTOP_BROWSER_SIGNIN_REPORT_PATH ??
+ test.info().outputPath('browser-sign-in-prompts.json')
+ const checks: {
+ name: string
+ status: 'passed' | 'failed'
+ durationMs: number
+ error?: string
+ }[] = []
+ const check = async (name: string, run: () => Promise) => {
+ const started = Date.now()
+ try {
+ await test.step(name, run)
+ checks.push({ name, status: 'passed', durationMs: Date.now() - started })
+ } catch (error) {
+ checks.push({
+ name,
+ status: 'failed',
+ durationMs: Date.now() - started,
+ error: getErrorMessage(error),
+ })
+ throw error
+ }
+ }
+ const browserComponents = join(
+ SIM_DIR,
+ 'app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session'
+ )
+ const bundle = await build({
+ stdin: {
+ contents: `import { createElement } from 'react';
+import { createRoot } from 'react-dom/client';
+import { useBrowserSessionStore as store } from ${JSON.stringify(join(SIM_DIR, 'stores/browser-session/store.ts'))};
+import { BrowserAppLaunchNoticeBar, BrowserPagePromptModal } from ${JSON.stringify(join(browserComponents, 'browser-page-prompt.tsx'))};
+import { useBrowserPanelOcclusion } from ${JSON.stringify(join(browserComponents, 'browser-panel-occlusion.ts'))};
+const scope = ${JSON.stringify(SCOPE)};
+const api = globalThis.simDesktop.browserAgent;
+api.onPageState(state => store.getState().setPageState(state));
+store.subscribe(state => {
+ globalThis.pagePrompt = state.sessions[scope]?.pageState?.prompt ?? null;
+ globalThis.launchNotice = state.sessions[scope]?.pageState?.appLaunchNotice ?? null;
+});
+function Fixture() {
+ const page = store(state => state.sessions[scope]?.pageState);
+ useBrowserPanelOcclusion(scope, page?.tabId ?? null, true, () => document.getElementById('native-panel')?.getBoundingClientRect() ?? null);
+ const respond = (requestId, response) => api.panelAction({ action: 'respond-prompt', requestId, response }, scope);
+ return createElement('div', null,
+ page?.appLaunchNotice ? createElement(BrowserAppLaunchNoticeBar, { key: page.appLaunchNotice.requestId, notice: page.appLaunchNotice, onRespond: respond }) : null,
+ createElement(BrowserPagePromptModal, { prompt: page?.prompt, open: Boolean(page?.prompt), onRespond: respond }));
+}
+createRoot(document.getElementById('root')).render(createElement(Fixture));`,
+ resolveDir: SIM_DIR,
+ loader: 'tsx',
+ },
+ bundle: true,
+ write: false,
+ outfile: test.info().outputPath('fixture.js'),
+ external: ['node:async_hooks'],
+ banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' },
+ format: 'iife',
+ platform: 'browser',
+ tsconfig: join(SIM_DIR, 'tsconfig.json'),
+ define: { 'process.env.NODE_ENV': '"development"' },
+ })
+ const config = await loadPostcssConfig({}, SIM_DIR)
+ const stylesheet = join(SIM_DIR, 'app/_styles/globals.css')
+ const css = await postcss(config.plugins).process(readFileSync(stylesheet, 'utf8'), {
+ from: stylesheet,
+ })
+ const calls = new Map()
+ const challenges: string[] = []
+ let otherPort = 0
+ const handleSite = (request: IncomingMessage, response: ServerResponse, port: number) => {
+ const path = new URL(request.url ?? '/', 'http://localhost').pathname
+ if (path === '/basic') {
+ challenges.push(`${port}${path}`)
+ if (request.headers.authorization === CREDENTIALS) {
+ response.writeHead(200, { 'Content-Type': 'text/html' })
+ response.end('secret ')
+ return
+ }
+ response.writeHead(401, {
+ 'Content-Type': 'text/html',
+ 'WWW-Authenticate': 'Basic realm="Fixture realm"',
+ })
+ response.end('denied ')
+ return
+ }
+ response.writeHead(200, { 'Content-Type': 'text/html' })
+ if (path === '/subresource') {
+ response.end(
+ `sub `
+ )
+ return
+ }
+ if (path === '/links') {
+ response.end(`links
+Join meeting
+Diagnostics
+Unhandled
+Mail
+`)
+ return
+ }
+ if (path === '/auto') {
+ response.end(
+ `auto `
+ )
+ return
+ }
+ response.end('next ')
+ }
+ const server: Server = createServer(async (request, response) => {
+ const path = new URL(request.url ?? '/', 'http://localhost').pathname
+ if (path === '/fixture.js' || path === '/fixture.css') {
+ response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css')
+ response.end(
+ path.endsWith('.js')
+ ? bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text
+ : css.css
+ )
+ return
+ }
+ if (path === '/api/desktop/tool/authorize') {
+ let body = ''
+ for await (const chunk of request) body += chunk.toString()
+ const authorization = calls.get(JSON.parse(body).toolCallId)
+ response.writeHead(authorization ? 200 : 403, { 'Content-Type': 'application/json' })
+ response.end(JSON.stringify(authorization ?? {}))
+ return
+ }
+ // The app origin serves the shell; the same server on localhost is the web.
+ if (request.headers.host?.startsWith('localhost')) {
+ const address = server.address()
+ handleSite(request, response, typeof address === 'object' && address ? address.port : 0)
+ return
+ }
+ response.writeHead(200, {
+ 'Content-Type': 'text/html',
+ 'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; Path=/',
+ })
+ response.end(SHELL_FIXTURE)
+ })
+ const otherSite: Server = createServer((request, response) =>
+ handleSite(request, response, otherPort)
+ )
+ const userData = mkdtempSync(join(tmpdir(), 'sim-browser-signin-e2e-'))
+ let app: ElectronApplication | undefined
+ let passed = false
+ try {
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ await new Promise((resolve) => otherSite.listen(0, '127.0.0.1', resolve))
+ const address = server.address()
+ const otherAddress = otherSite.address()
+ if (
+ !address ||
+ typeof address === 'string' ||
+ !otherAddress ||
+ typeof otherAddress === 'string'
+ )
+ throw new Error('Missing fixture address')
+ otherPort = otherAddress.port
+ const origin = `http://127.0.0.1:${address.port}`
+ const site = `http://localhost:${address.port}`
+ const crossSite = `http://localhost:${otherPort}`
+ const shellApp = await electron.launch({
+ args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
+ cwd: DESKTOP_DIR,
+ env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData },
+ })
+ app = shellApp
+ // Launching real apps is out of scope: record what the shell would hand the OS,
+ // and register a fixture handler for the schemes the page links to.
+ await shellApp.evaluate(({ app, shell }) => {
+ const main = globalThis as MainGlobals
+ main.openedExternal = []
+ shell.openExternal = async (url: string) => {
+ main.openedExternal?.push(url)
+ }
+ app.getApplicationNameForProtocol = (url: string) =>
+ /^(simfixture|ms-msdt|mailto):/i.test(url) ? 'Fixture Meetings' : ''
+ })
+ const openedExternal = () =>
+ shellApp.evaluate(() => [...((globalThis as MainGlobals).openedExternal ?? [])])
+ const shell = await shellApp.firstWindow()
+ await shellApp.evaluate(({ app, BrowserWindow }) => {
+ const host = BrowserWindow.getAllWindows()[0]
+ if (!host) throw new Error('Missing host window')
+ app.focus({ steal: true })
+ host.focus()
+ })
+ await expect(shell.getByRole('heading')).toHaveText('Browser sign-in fixture')
+ await shell.evaluate(async (scope) => {
+ const bridge = globalThis as Bridge
+ const api = bridge.simDesktop.browserAgent
+ await api.activateScope(scope)
+ const updateBounds = () =>
+ api.setPanelBounds(
+ { x: 0, y: 120, width: innerWidth, height: innerHeight - 120 },
+ null,
+ scope
+ )
+ updateBounds()
+ bridge.boundsTimer = window.setInterval(updateBounds, 200)
+ }, SCOPE)
+
+ let callCount = 0
+ const startTool = (tool: BrowserToolName, args: Record) => {
+ const callId = `browser-signin-${++callCount}`
+ calls.set(callId, { chatId: SCOPE, toolName: tool, args })
+ return shell.evaluate(
+ ({ callId, tool, args, scope }) =>
+ (globalThis as Bridge).simDesktop.browserAgent.executeTool(callId, tool, args, scope),
+ { callId, tool, args, scope: SCOPE }
+ )
+ }
+ const execute = async (tool: BrowserToolName, args: Record) => {
+ const result = await startTool(tool, args)
+ expect(result.ok, result.error).toBe(true)
+ return result.result
+ }
+ /** Browser-chrome actions are user gestures, so each follows a real click in Sim. */
+ const panelAction = async (action: Record) => {
+ await shell.getByRole('heading').click()
+ await shell.evaluate(
+ ({ action, scope }) =>
+ (globalThis as Bridge).simDesktop.browserAgent.panelAction(
+ action as unknown as Parameters[0],
+ scope
+ ),
+ { action, scope: SCOPE }
+ )
+ }
+ const pagePrompt = () => shell.evaluate(() => (globalThis as Bridge).pagePrompt ?? null)
+ const launchNotice = () => shell.evaluate(() => (globalThis as Bridge).launchNotice ?? null)
+ /** The page currently at `url`; `read` is `title` or a script evaluated in it. */
+ const pageAt = (url: string, read: string) =>
+ shellApp.evaluate(
+ async ({ webContents }, { url, read }) => {
+ const contents = webContents
+ .getAllWebContents()
+ .find((candidate) => !candidate.isDestroyed() && candidate.getURL() === url)
+ if (!contents) return null
+ if (read === 'title') return contents.getTitle() as T
+ return (await contents.executeJavaScript(read, true)) as T
+ },
+ { url, read }
+ )
+ const titleAt = (url: string) => () => pageAt(url, 'title')
+ const navigate = async (url: string) => {
+ await panelAction({ action: 'navigate', url })
+ await expect.poll(() => pageAt(url, 'title')).not.toBeNull()
+ }
+ /** Trusted input into the page, the way the user's own click arrives and focuses it. */
+ const userClick = (url: string, selector: string) =>
+ shellApp.evaluate(
+ async ({ webContents }, { url, selector }) => {
+ const contents = webContents
+ .getAllWebContents()
+ .find((candidate) => !candidate.isDestroyed() && candidate.getURL() === url)
+ if (!contents) throw new Error(`No page at ${url}`)
+ const rect: { x: number; y: number } = await contents.executeJavaScript(
+ `(() => { const { x, y } = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); return { x, y } })()`
+ )
+ const point = { x: Math.round(rect.x + 3), y: Math.round(rect.y + 3) }
+ contents.focus()
+ contents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
+ contents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
+ },
+ { url, selector }
+ )
+ /** Types into the visible sign-in prompt the way the user does: the password field unlocks on focus. */
+ const signInAs = async (username: string, password: string) => {
+ const dialog = shell.getByRole('dialog')
+ await expect(dialog).toHaveCount(1)
+ await expect(async () => {
+ await dialog.getByRole('textbox', { name: 'Username' }).fill(username)
+ await dialog.getByRole('textbox', { name: 'Password' }).click()
+ await shell.keyboard.press('ControlOrMeta+A')
+ await shell.keyboard.type(password)
+ await expect(dialog.getByRole('textbox', { name: 'Username' })).toHaveValue(username, {
+ timeout: 1_000,
+ })
+ await expect(dialog.getByRole('textbox', { name: 'Password' })).toHaveValue(password, {
+ timeout: 1_000,
+ })
+ }).toPass({ timeout: 10_000 })
+ }
+ const expectQuiet = async (openedBefore: string[]) => {
+ await sleep(QUIET_MS)
+ expect(await pagePrompt()).toBeNull()
+ expect(await launchNotice()).toBeNull()
+ expect(await openedExternal()).toEqual(openedBefore)
+ }
+
+ await execute('browser_open_url', { url: `${site}/next` })
+ await panelAction({ action: 'switch-tab', tabId: '1' })
+ await expect.poll(titleAt(`${site}/next`)).toBe('next')
+
+ await check('an older renderer leaves sign-in challenges and app links refused', async () => {
+ await panelAction({ action: 'enable-page-dialogs' })
+ await navigate(`${site}/basic`)
+ await expect.poll(titleAt(`${site}/basic`)).toBe('denied')
+ expect(await pagePrompt()).toBeNull()
+ await navigate(`${site}/links`)
+ await expect.poll(titleAt(`${site}/links`)).toBe('links')
+ await userClick(`${site}/links`, '#app')
+ await expectQuiet([])
+ })
+
+ await panelAction({ action: 'enable-page-dialogs', pagePrompts: true })
+
+ await check(
+ 'basic auth asks for the page, re-asks after a wrong password, then signs in',
+ async () => {
+ await panelAction({ action: 'navigate', url: `${site}/basic` })
+ await expect.poll(pagePrompt).toMatchObject({
+ kind: 'http-auth',
+ origin: site,
+ proxy: false,
+ insecure: true,
+ retry: false,
+ })
+ await expect(shell.getByText('Your connection to this site is not private.')).toBeVisible()
+ await signInAs('alice', 'wrong')
+ await shell.getByRole('button', { name: 'Sign in', exact: true }).click()
+ await expect.poll(pagePrompt).toMatchObject({ kind: 'http-auth', retry: true })
+ await expect(shell.getByText('Incorrect username or password.')).toBeVisible()
+ await signInAs('alice', 's3cret')
+ await shell.getByRole('button', { name: 'Sign in', exact: true }).click()
+ await expect.poll(titleAt(`${site}/basic`)).toBe('secret')
+ expect(await pagePrompt()).toBeNull()
+ }
+ )
+
+ await check('a cross-origin subresource challenge never asks', async () => {
+ const before = challenges.length
+ await navigate(`${site}/subresource`)
+ await expect.poll(titleAt(`${site}/subresource`)).toBe('img-failed')
+ expect(challenges.slice(before)).toContain(`${otherPort}/basic`)
+ expect(await pagePrompt()).toBeNull()
+ })
+
+ await check(
+ "the agent's sign-in challenge waits for the user instead of being answered",
+ async () => {
+ const navigation = startTool('browser_navigate', { url: `${crossSite}/basic` })
+ await expect.poll(pagePrompt).toMatchObject({ kind: 'http-auth', origin: crossSite })
+ await sleep(QUIET_MS)
+ expect(await pagePrompt()).toMatchObject({ kind: 'http-auth', retry: false })
+ await signInAs('alice', 's3cret')
+ await shell.getByRole('button', { name: 'Sign in', exact: true }).click()
+ const result = await navigation
+ expect(result.ok, result.error).toBe(true)
+ await expect.poll(titleAt(`${crossSite}/basic`)).toBe('secret')
+ }
+ )
+
+ await check("the agent's clicks never open another app", async () => {
+ await navigate(`${site}/links`)
+ const opened = await openedExternal()
+ const snapshot = await execute('browser_snapshot', {})
+ const ref = /link "Join meeting" \[ref=(\d+)\]/.exec(
+ String((snapshot as { outline?: string }).outline)
+ )?.[1]
+ expect(ref, 'snapshot lists the Join meeting link').toBeTruthy()
+ const result = await execute('browser_click', { elementId: Number(ref) })
+ await expectQuiet(opened)
+ const notices = [
+ ...((result as { notices?: string[] }).notices ?? []),
+ ...(((await execute('browser_snapshot', {})) as { notices?: string[] }).notices ?? []),
+ ]
+ expect(notices.join('\n')).toContain('never open from agent actions')
+ })
+
+ await check('a link to another app asks after a click and opens only on Open', async () => {
+ await userClick(`${site}/links`, '#app')
+ await expect.poll(pagePrompt).toMatchObject({
+ kind: 'external-app',
+ origin: site,
+ appName: 'Fixture Meetings',
+ canAlwaysAllow: false,
+ })
+ const open = shell.getByRole('button', { name: 'Open', exact: true })
+ await expect(open).toBeVisible()
+ expect(await openedExternal()).toEqual([])
+ await expect(open).toBeEnabled()
+ await open.click()
+ await expect.poll(openedExternal).toEqual(['simfixture://join?id=7'])
+ expect(await pagePrompt()).toBeNull()
+ })
+
+ await check('without a click, a page only offers to open the app', async () => {
+ await navigate(`${site}/auto`)
+ await expect.poll(launchNotice).toMatchObject({ appName: 'Fixture Meetings', origin: site })
+ expect(await pagePrompt()).toBeNull()
+ expect(await openedExternal()).toEqual(['simfixture://join?id=7'])
+ await shell.getByRole('button', { name: 'Open', exact: true }).click()
+ await expect.poll(openedExternal).toEqual(['simfixture://join?id=7', 'simfixture://auto'])
+ await expect.poll(launchNotice).toBeNull()
+ })
+
+ await check('a page cannot keep asking to open apps', async () => {
+ await navigate(`${site}/links`)
+ await userClick(`${site}/links`, '#app')
+ await expectQuiet(['simfixture://join?id=7', 'simfixture://auto'])
+ })
+
+ await check(
+ 'denied and unhandled schemes never open, mail links open after a click',
+ async () => {
+ await shell.evaluate(
+ (scope) => (globalThis as Bridge).simDesktop.browserAgent.openTab?.(scope),
+ SCOPE
+ )
+ const opened = await openedExternal()
+ // A fresh document per link: one page's refused launch must not mask the next.
+ let links = ''
+ for (const [index, selector] of ['#denied', '#unhandled'].entries()) {
+ links = `${site}/links?tab=2&link=${index}`
+ await navigate(links)
+ await userClick(links, selector)
+ await expectQuiet(opened)
+ }
+ await userClick(links, '#mail')
+ await expect.poll(openedExternal).toEqual([...opened, 'mailto:a@example.com?subject=Hi'])
+ expect(await pagePrompt()).toBeNull()
+ }
+ )
+
+ await check('a passkey sign-in lets the user choose the account', async () => {
+ const passkeyPage = `${site}/next?passkey`
+ await navigate(passkeyPage)
+ await shellApp.evaluate(async ({ webContents }) => {
+ const contents = webContents
+ .getAllWebContents()
+ .find((candidate) => candidate.getURL().includes('/next?passkey'))
+ if (!contents) throw new Error('No passkey page')
+ if (!contents.debugger.isAttached()) contents.debugger.attach('1.3')
+ await contents.debugger.sendCommand('WebAuthn.enable', { enableUI: false })
+ await contents.debugger.sendCommand('WebAuthn.addVirtualAuthenticator', {
+ options: {
+ protocol: 'ctap2',
+ transport: 'internal',
+ hasResidentKey: true,
+ hasUserVerification: true,
+ isUserVerified: true,
+ automaticPresenceSimulation: true,
+ },
+ })
+ })
+ const created = await pageAt(
+ passkeyPage,
+ `(async () => {
+ const make = (id, name) => navigator.credentials.create({ publicKey: {
+ challenge: new Uint8Array(16), rp: { id: 'localhost', name: 'Fixture' },
+ user: { id: new TextEncoder().encode(id), name, displayName: name },
+ pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
+ authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
+ } })
+ await make('user-ada', 'ada@example.com')
+ await make('user-grace', 'grace@example.com')
+ globalThis.passkeyOutcome = 'pending'
+ navigator.credentials.get({ publicKey: {
+ challenge: new Uint8Array(16), rpId: 'localhost', userVerification: 'preferred',
+ } }).then((credential) => new TextDecoder().decode(credential.response.userHandle),
+ (error) => 'error:' + error.name).then((outcome) => { globalThis.passkeyOutcome = outcome })
+ return 'created'
+ })()`
+ )
+ expect(created).toBe('created')
+ await expect.poll(pagePrompt).toMatchObject({
+ kind: 'passkey-account',
+ relyingPartyId: 'localhost',
+ })
+ const prompt = await pagePrompt()
+ expect(
+ prompt?.kind === 'passkey-account' && prompt.accounts.map((account) => account.name).sort()
+ ).toEqual(['ada@example.com', 'grace@example.com'])
+ // A stray Enter must never send an identity: nothing is preselected and Enter does not
+ // continue. It dismisses when the prompt has keyboard focus and is ignored when it does
+ // not (as on a CI runner without window focus); either way, refuse and ask again.
+ const passkeyOutcome = () => pageAt(passkeyPage, 'globalThis.passkeyOutcome')
+ await shell.keyboard.press('Enter')
+ await sleep(QUIET_MS)
+ expect(['pending', 'error:NotAllowedError']).toContain(await passkeyOutcome())
+ if ((await passkeyOutcome()) === 'pending') {
+ await shell.getByRole('button', { name: 'Cancel', exact: true }).click()
+ }
+ await expect.poll(passkeyOutcome).toBe('error:NotAllowedError')
+ await pageAt(
+ passkeyPage,
+ `globalThis.passkeyOutcome = 'pending'
+ navigator.credentials.get({ publicKey: {
+ challenge: new Uint8Array(16), rpId: 'localhost', userVerification: 'preferred',
+ } }).then((credential) => new TextDecoder().decode(credential.response.userHandle),
+ (error) => 'error:' + error.name).then((outcome) => { globalThis.passkeyOutcome = outcome }); 'asked'`
+ )
+ await expect.poll(pagePrompt).toMatchObject({ kind: 'passkey-account' })
+ const dialog = shell.getByRole('dialog')
+ await dialog.getByRole('button').filter({ hasText: 'Choose an account' }).click()
+ await shell.getByRole('menuitemradio', { name: 'grace@example.com' }).click()
+ await shell.getByRole('button', { name: 'Continue', exact: true }).click()
+ await expect.poll(passkeyOutcome).toBe('user-grace')
+ expect(await pagePrompt()).toBeNull()
+ })
+ passed = true
+ } finally {
+ mkdirSync(dirname(reportPath), { recursive: true })
+ writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2))
+ await app?.close()
+ await new Promise((resolve) => server.close(() => resolve()))
+ await new Promise((resolve) => otherSite.close(() => resolve()))
+ rmSync(userData, { recursive: true, force: true })
+ }
+})
diff --git a/apps/desktop/e2e/browser-tools.spec.ts b/apps/desktop/e2e/browser-tools.spec.ts
index 3b573ad9df3..92e5e18d429 100644
--- a/apps/desktop/e2e/browser-tools.spec.ts
+++ b/apps/desktop/e2e/browser-tools.spec.ts
@@ -1,5 +1,5 @@
import { mkdtempSync } from 'node:fs'
-import { createServer, type Server } from 'node:http'
+import { createServer, type IncomingMessage, type Server } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -62,6 +62,36 @@ const CAPABILITIES_FIXTURE = `Capabilities fixture
const POPUP_FIXTURE = `Authorize fixture
Allow `
+/** Sign-in and payment popup patterns: blank-then-navigate, and form posts into a new tab. */
+const popupsFixture = (
+ appOrigin: string,
+ popupOrigin: string
+) => `Popups fixture
+
+
+`
+
+async function readBody(request: IncomingMessage): Promise {
+ let body = ''
+ for await (const chunk of request) body += chunk.toString()
+ return body
+}
+
+const echoPage = (method: string | undefined, body: string) =>
+ `${method} ${body} `
+
const UPLOAD_TARGET_FIXTURE = `Upload target fixture
`)
+ })
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const address = server.address()
+ if (!address || typeof address === 'string') throw new Error('Missing fixture address')
+ origin = `http://127.0.0.1:${address.port}`
+ site = `http://localhost:${address.port}`
+ })
+
+ test.beforeEach(async () => {
+ home = mkdtempSync(join(tmpdir(), 'sim-continue-home-'))
+ userData = mkdtempSync(join(tmpdir(), 'sim-continue-e2e-'))
+ app = await electron.launch({
+ args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
+ cwd: DESKTOP_DIR,
+ env: {
+ ...process.env,
+ HOME: home,
+ SIM_DESKTOP_ORIGIN: origin,
+ SIM_DESKTOP_USER_DATA: userData,
+ },
+ })
+ const page = await app.firstWindow()
+ await expect(page.getByRole('heading')).toHaveText('Continue fixture')
+ await page.evaluate(async (scope) => {
+ const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
+ await api.browserAgent.activateScope(scope)
+ api.browserAgent.setPanelBounds({ x: 0, y: 120, width: 800, height: 400 }, null, scope)
+ }, SCOPE)
+ })
+
+ test.afterEach(async () => {
+ const testInfo = test.info()
+ recordCheck(process.env.CONTINUE_SIGN_IN_REPORT_PATH, 'continue-sign-in', {
+ name: testInfo.title,
+ status: testInfo.status ?? 'unknown',
+ durationMs: testInfo.duration,
+ retry: testInfo.retry,
+ })
+ await app?.close()
+ app = undefined
+ rmSync(userData, { recursive: true, force: true })
+ rmSync(home, { recursive: true, force: true })
+ })
+
+ test.afterAll(async () => {
+ await new Promise((resolve, reject) =>
+ server.close((error) => (error ? reject(error) : resolve()))
+ )
+ })
+
+ test('advertises the capability, but offers no browser when none is installed', async () => {
+ const page = await app?.firstWindow()
+ const surface = await page?.evaluate(async () => {
+ const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
+ return {
+ continueSignIn: typeof api.browserImport?.continueSignIn,
+ bringSignInBack: typeof api.browserImport?.bringSignInBack,
+ pending: await api.browserImport?.getPendingSignIn?.('continue-sign-in-fixture'),
+ profiles: await api.browserImport?.listChromeProfiles(),
+ }
+ })
+
+ expect(surface).toEqual({
+ continueSignIn: 'function',
+ bringSignInBack: 'function',
+ pending: null,
+ profiles: [],
+ })
+ })
+
+ test('refuses page script that acts without the user', async () => {
+ const page = await app?.firstWindow()
+ const results = await page?.evaluate(async (scope) => {
+ const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
+ return {
+ continued: await api.browserImport?.continueSignIn?.('chrome:Default', scope),
+ broughtBack: await api.browserImport?.bringSignInBack?.(scope),
+ }
+ }, SCOPE)
+
+ expect(results).toEqual({
+ continued: { error: 'unknown' },
+ broughtBack: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' },
+ })
+ })
+
+ test('reads the page from the tab itself and hands over only https', async () => {
+ const page = await app?.firstWindow()
+ if (!page || !app) throw new Error('App did not launch')
+ const read = (key: string) => page.evaluate((name) => Reflect.get(globalThis, name), key)
+
+ await page.locator('#open').click()
+ await expect.poll(() => read('opened')).toMatchObject({ activeTabId: expect.any(String) })
+ await expect
+ .poll(() =>
+ app?.evaluate(
+ ({ webContents }, target) =>
+ webContents.getAllWebContents().some((contents) => contents.getURL() === target),
+ `${site}/login`
+ )
+ )
+ .toBe(true)
+
+ await page.locator('#continue').click()
+ await expect.poll(() => read('continued')).toEqual({ error: 'unsupported-page' })
+
+ await page.locator('#back').click()
+ await expect
+ .poll(() => read('broughtBack'))
+ .toEqual({ cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' })
+ })
+})
diff --git a/apps/desktop/e2e/fixtures/updater.ts b/apps/desktop/e2e/fixtures/updater.ts
index 33fea2ba5dd..a964dfcc979 100644
--- a/apps/desktop/e2e/fixtures/updater.ts
+++ b/apps/desktop/e2e/fixtures/updater.ts
@@ -2,23 +2,43 @@ import { writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join, relative } from 'node:path'
import type { DesktopUpdateState } from '@sim/desktop-bridge'
-import { app, autoUpdater as nativeUpdater, net } from 'electron'
+import { app, BrowserWindow, autoUpdater as nativeUpdater, net } from 'electron'
import { MacUpdater } from 'electron-updater'
+import { countWindowsWithUnsavedChanges, describeInFlightWork } from '@/main/in-flight-work'
import { initUpdater } from '@/main/updater'
+/** What a window's page does on `beforeunload`, shaped like the web app's own listeners. */
+type FixtureUnloadListener = 'none' | 'passive' | 'unsaved-edits'
+
+type FixtureDialog = Pick<
+ Electron.MessageBoxOptions,
+ 'title' | 'message' | 'buttons' | 'defaultId' | 'cancelId'
+>
+
declare global {
var desktopUpdaterFixture: {
check(): void
install(): void
finishStaging(): void
failStaging(): void
+ setWork(work: {
+ terminalCommands: string[]
+ browserToolRunning: boolean
+ unloadListener: FixtureUnloadListener
+ /** Adds a second window whose renderer is blocked past the shell's probe deadline. */
+ hungWindow?: boolean
+ }): Promise
read(): {
state: DesktopUpdateState
nativeArchive: string | null
installed: string[]
events: { name: string; data: unknown }[]
+ dialogs: FixtureDialog[]
}
}
+ /** Filled by the spec's `showShellDialog` stub, which answers with `updaterFixtureDialogResponse`. */
+ var updaterFixtureDialogs: FixtureDialog[]
+ var updaterFixtureDialogResponse: number
}
const directory = process.env.SIM_UPDATER_FIXTURE_DIR
@@ -26,7 +46,10 @@ const origin = process.env.SIM_UPDATER_FIXTURE_ORIGIN
if (!directory || !origin) throw new Error('Updater fixture configuration is missing')
app.setPath('userData', join(directory, 'user-data'))
-void app.whenReady().then(() => {
+globalThis.updaterFixtureDialogs = []
+globalThis.updaterFixtureDialogResponse = 0
+
+void app.whenReady().then(async () => {
const configPath = join(directory, 'updater.yml')
const cache = relative(join(homedir(), 'Library', 'Caches'), join(directory, 'cache'))
writeFileSync(configPath, `updaterCacheDirName: ${JSON.stringify(cache)}\n`)
@@ -55,6 +78,14 @@ void app.whenReady().then(() => {
installed.push(nativeArchive)
}
+ // A real renderer, so the unsaved-edits probe runs against Chromium's own event dispatch.
+ const page = new BrowserWindow({ show: false })
+ page.webContents.on('will-prevent-unload', (event) => event.preventDefault())
+ await page.loadURL('about:blank')
+ let terminalCommands: string[] = []
+ let browserToolRunning = false
+ let hungPage: BrowserWindow | null = null
+
const updater = new MacUpdater()
updater.forceDevUpdateConfig = true
updater.disableDifferentialDownload = true
@@ -68,6 +99,18 @@ void app.whenReady().then(() => {
canSelfUpdate: async () => true,
probeOriginFeed: async () => false,
installStatePath: join(directory, 'update-install.json'),
+ describeInFlightWork: async () =>
+ describeInFlightWork({
+ terminalCommands,
+ uncheckedTerminalCount: 0,
+ computerUseApp: null,
+ browserToolRunning,
+ agentWorkRunning: false,
+ downloadCount: 0,
+ ...(await countWindowsWithUnsavedChanges(
+ hungPage ? [page.webContents, hungPage.webContents] : [page.webContents]
+ )),
+ }),
})
globalThis.desktopUpdaterFixture = {
@@ -78,6 +121,43 @@ void app.whenReady().then(() => {
nativeUpdater.emit('update-downloaded', {}, '', nativeArchive, new Date(), '')
},
failStaging: () => nativeUpdater.emit('error', new Error('Native verification failed')),
- read: () => ({ state: handle.getState(), nativeArchive, installed, events }),
+ setWork: async (work) => {
+ terminalCommands = work.terminalCommands
+ browserToolRunning = work.browserToolRunning
+ if (hungPage) {
+ // Let the blocked renderer finish first; destroying it mid-loop stalls the app's exit.
+ await hungPage.webContents.executeJavaScript('0')
+ hungPage.destroy()
+ hungPage = null
+ }
+ if (work.hungWindow) {
+ hungPage = new BrowserWindow({ show: false })
+ await hungPage.loadURL('about:blank')
+ await hungPage.webContents.executeJavaScript(`window.addEventListener('beforeunload', () => {
+ const until = Date.now() + 1500
+ while (Date.now() < until) {}
+ })`)
+ }
+ await page.webContents.executeJavaScript(`(() => {
+ for (const listener of window.fixtureUnloadListeners ?? []) {
+ window.removeEventListener('beforeunload', listener)
+ }
+ window.fixtureUnloadListeners = []
+ const kind = ${JSON.stringify(work.unloadListener)}
+ if (kind === 'none') return
+ // 'passive' runs on unload without holding the page, like the chat's handoff cleanup.
+ const listener =
+ kind === 'unsaved-edits' ? (event) => event.preventDefault() : () => {}
+ window.addEventListener('beforeunload', listener)
+ window.fixtureUnloadListeners.push(listener)
+ })()`)
+ },
+ read: () => ({
+ state: handle.getState(),
+ nativeArchive,
+ installed,
+ events,
+ dialogs: globalThis.updaterFixtureDialogs,
+ }),
}
})
diff --git a/apps/desktop/e2e/updater.spec.ts b/apps/desktop/e2e/updater.spec.ts
index ac0be66e98a..65bdaabffcf 100644
--- a/apps/desktop/e2e/updater.spec.ts
+++ b/apps/desktop/e2e/updater.spec.ts
@@ -10,7 +10,7 @@ import { build } from 'esbuild'
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
-test('the real MacUpdater waits for native staging, replaces old builds, and retries failed staging', async () => {
+test('the real MacUpdater waits for native staging, replaces old builds, retries failed staging, and restarts on one click', async () => {
test.skip(process.platform !== 'darwin', 'Squirrel.Mac lifecycle')
const directory = mkdtempSync(join(tmpdir(), 'sim-updater-e2e-'))
const reportPath =
@@ -40,6 +40,7 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret
}
}
let snapshot: unknown
+ let completed = false
const server = createServer((request, response) => {
const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
requests.push({ path, status: 200 })
@@ -72,15 +73,18 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret
tsconfig: join(DESKTOP_DIR, 'tsconfig.json'),
plugins: [
{
- name: 'approve-fixture-restart',
+ name: 'record-fixture-dialogs',
setup(builder) {
builder.onResolve({ filter: /^@\/main\/dialogs$/ }, () => ({
path: 'dialog',
namespace: 'fixture',
}))
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({
- contents:
- 'export async function showShellDialog() { return { response: 1, checkboxChecked: false } }',
+ contents: `export async function showShellDialog(...args) {
+ const { title, message, buttons, defaultId, cancelId } = args.at(-1)
+ globalThis.updaterFixtureDialogs.push({ title, message, buttons, defaultId, cancelId })
+ return { response: globalThis.updaterFixtureDialogResponse, checkboxChecked: false }
+ }`,
}))
},
},
@@ -129,7 +133,7 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret
expect((await read()).state).toEqual({ status: 'error', version: '2.1.0' })
})
- await check('cached retry installs only the verified replacement', async () => {
+ await check('cached retry stages only the verified replacement', async () => {
await shell.evaluate(() => globalThis.desktopUpdaterFixture.check())
await expect
.poll(async () => (await read()).state)
@@ -137,9 +141,77 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret
await expect.poll(async () => (await read()).nativeArchive).toBe('2.1.0')
await shell.evaluate(() => globalThis.desktopUpdaterFixture.finishStaging())
expect((await read()).state).toEqual({ status: 'ready', version: '2.1.0' })
- await shell.evaluate(() => globalThis.desktopUpdaterFixture.install())
- await expect.poll(async () => (await read()).installed).toEqual(['2.1.0'])
})
+
+ await check(
+ 'restart asks only about real in-flight work, then installs on one click',
+ async () => {
+ /** Sets the work in progress, clicks Restart to update, and answers any prompt with `response`. */
+ const installWith = async (
+ work: {
+ terminalCommands?: string[]
+ browserToolRunning?: boolean
+ unloadListener: 'passive' | 'unsaved-edits'
+ hungWindow?: boolean
+ },
+ response: number
+ ) => {
+ await shell.evaluate(
+ async (
+ _electron,
+ [{ terminalCommands = [], browserToolRunning = false, ...rest }, answer]
+ ) => {
+ await globalThis.desktopUpdaterFixture.setWork({
+ terminalCommands,
+ browserToolRunning,
+ ...rest,
+ })
+ globalThis.updaterFixtureDialogResponse = answer
+ globalThis.desktopUpdaterFixture.install()
+ },
+ [work, response] as const
+ )
+ }
+ const expectNotNowPrompt = async (count: number, message: string) => {
+ await expect.poll(async () => (await read()).dialogs.length).toBe(count)
+ expect((await read()).dialogs.at(-1)).toEqual({
+ title: 'Restart to install Sim 2.1.0?',
+ message,
+ buttons: ['Restart and Update', 'Not Now'],
+ defaultId: 0,
+ cancelId: 1,
+ })
+ // The decline settles asynchronously; nothing may install, and the update stays ready.
+ await expect
+ .poll(async () => (await read()).state)
+ .toEqual({ status: 'ready', version: '2.1.0' })
+ expect((await read()).installed).toEqual([])
+ }
+
+ await installWith(
+ { terminalCommands: ['cd /repo && claude "fix the build"'], unloadListener: 'passive' },
+ 1
+ )
+ await expectNotNowPrompt(1, 'claude is still running in a terminal.')
+
+ await installWith({ unloadListener: 'unsaved-edits' }, 1)
+ await expectNotNowPrompt(2, 'A window has unsaved changes.')
+
+ await installWith(
+ { browserToolRunning: true, unloadListener: 'passive', hungWindow: true },
+ 1
+ )
+ await expectNotNowPrompt(
+ 3,
+ "An agent is using the browser. A window isn't responding and may have unsaved changes."
+ )
+
+ // Answering Not Now again would leave nothing installed if a prompt appeared.
+ await installWith({ unloadListener: 'passive' }, 1)
+ await expect.poll(async () => (await read()).installed).toEqual(['2.1.0'])
+ expect((await read()).dialogs).toHaveLength(3)
+ }
+ )
snapshot = await read()
await check(
'the next process distinguishes an incomplete update from a successful install',
@@ -182,6 +254,7 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret
})
}
)
+ completed = true
} finally {
if (!snapshot && app)
snapshot = await app
@@ -192,7 +265,7 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret
reportPath,
JSON.stringify(
{
- passed: checks.length === 4 && checks.every((check) => check.status === 'passed'),
+ passed: completed && checks.every((check) => check.status === 'passed'),
checks,
requests,
snapshot,
diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml
index c9219a003db..a52cc14977e 100644
--- a/apps/desktop/electron-builder.yml
+++ b/apps/desktop/electron-builder.yml
@@ -72,7 +72,6 @@ mac:
NSScreenCaptureUsageDescription: Mothership captures approved app windows to understand and verify computer-use actions.
NSMicrophoneUsageDescription: Sim uses your microphone for voice input in Chat and for meetings you join in the built-in browser.
NSCameraUsageDescription: Sim uses your camera for meetings you join in the built-in browser, such as Google Meet.
- NSBluetoothAlwaysUsageDescription: Sim uses Bluetooth to complete passkey sign-ins with a nearby phone in the built-in browser.
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
notarize: true
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 92826c3aec4..94e75d5bf65 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -41,8 +41,10 @@
"@sim/utils": "workspace:*",
"@xterm/headless": "6.0.0",
"electron-updater": "6.8.9",
+ "lru-cache": "11.5.3",
"micromatch": "4.0.8",
"safe-regex2": "5.1.2",
+ "tldts": "7.4.18",
"pdf-lib": "1.17.1",
"@sim/auth-ui": "workspace:*"
},
diff --git a/apps/desktop/src/main/browser-agent/context-menu.test.ts b/apps/desktop/src/main/browser-agent/context-menu.test.ts
index 44058879f89..113f1738f05 100644
--- a/apps/desktop/src/main/browser-agent/context-menu.test.ts
+++ b/apps/desktop/src/main/browser-agent/context-menu.test.ts
@@ -29,7 +29,18 @@ type Page = Parameters[1]
type Handlers = Parameters[2]
function params(overrides: Partial = {}): Params {
- return { selectionText: '', linkURL: '', isEditable: false, editFlags: EDIT_FLAGS, ...overrides }
+ return {
+ selectionText: '',
+ linkURL: '',
+ isEditable: false,
+ editFlags: EDIT_FLAGS,
+ misspelledWord: '',
+ dictionarySuggestions: [],
+ mediaType: 'none',
+ srcURL: '',
+ hasImageContents: false,
+ ...overrides,
+ }
}
function page(overrides: Partial = {}): Page {
@@ -46,13 +57,20 @@ function page(overrides: Partial = {}): Page {
function handlers(): Handlers {
return {
addToChat: vi.fn(),
+ replaceMisspelling: vi.fn(),
+ addToDictionary: vi.fn(),
+ undo: vi.fn(),
+ redo: vi.fn(),
+ cut: vi.fn(),
copy: vi.fn(),
paste: vi.fn(),
- back: vi.fn(),
- forward: vi.fn(),
- reload: vi.fn(),
+ pasteAndMatchStyle: vi.fn(),
+ selectAll: vi.fn(),
+ navigate: vi.fn(),
openTab: vi.fn(),
copyLink: vi.fn(),
+ copyImage: vi.fn(),
+ saveImage: vi.fn(),
setZoomFactor: vi.fn(),
}
}
@@ -82,11 +100,16 @@ describe('buildAgentContextMenuTemplate', () => {
})
describe('attachAgentContextMenu', () => {
+ const navigationHost = {
+ history: () => ({ canGoBack: false, canGoForward: false }),
+ navigate: vi.fn(),
+ }
type ContextMenuListener = (event: unknown, params: Params) => void
it('suppresses one agent context menu and immediately allows the next menu', async () => {
const contents = new WebContentsView().webContents
attachAgentContextMenu(contents, {
+ ...navigationHost,
addToChat: vi.fn(),
openTab: vi.fn(),
defaultZoomFactor: () => BASE_ZOOM_FACTOR,
@@ -110,6 +133,7 @@ describe('attachAgentContextMenu', () => {
async (inputEvent) => {
const contents = new WebContentsView().webContents
attachAgentContextMenu(contents, {
+ ...navigationHost,
addToChat: vi.fn(),
openTab: vi.fn(),
defaultZoomFactor: () => BASE_ZOOM_FACTOR,
diff --git a/apps/desktop/src/main/browser-agent/context-menu.ts b/apps/desktop/src/main/browser-agent/context-menu.ts
index de9688e6592..4043da75842 100644
--- a/apps/desktop/src/main/browser-agent/context-menu.ts
+++ b/apps/desktop/src/main/browser-agent/context-menu.ts
@@ -10,18 +10,21 @@
* while the page keeps rendering, so none of that applies.
*
* It is also what a browser's page menu is everywhere else, and unlike the
- * terminal's hidden textarea, the roles here act on a real page: `copy` and
+ * terminal's hidden textarea, the edit items act on a real page: `copy` and
* `paste` go to the frame that was clicked.
*/
import { resolveDesktopZoom } from '@sim/desktop-bridge'
-import type { ContextMenuParams, MenuItemConstructorOptions, WebContents } from 'electron'
+import type { ContextMenuParams, MenuItemConstructorOptions, Referrer, WebContents } from 'electron'
import { clipboard, Menu } from 'electron'
import {
clearAgentContextMenu,
consumeAgentContextMenu,
isDispatchingAgentInput,
} from '@/main/browser-agent/cdp'
+import { menuReferrer, referrerForRequest } from '@/main/browser-agent/referrer'
+import { spellingItems } from '@/main/context-menu'
+import { parseHttpUrl } from '@/main/navigation'
/**
* Page-zoom ladder for the embedded browser, in Chromium's absolute zoom
@@ -78,7 +81,15 @@ export function steppedZoomFactor(current: number, direction: 1 | -1): number {
/** The parts of a right-click the menu acts on. */
type AgentContextMenuParams = Pick<
ContextMenuParams,
- 'selectionText' | 'linkURL' | 'isEditable' | 'editFlags'
+ | 'selectionText'
+ | 'linkURL'
+ | 'isEditable'
+ | 'editFlags'
+ | 'misspelledWord'
+ | 'dictionarySuggestions'
+ | 'mediaType'
+ | 'srcURL'
+ | 'hasImageContents'
>
/** What the page can currently do, read at the moment of the click. */
@@ -89,34 +100,62 @@ interface AgentPageContext {
defaultZoomFactor: number
}
+/** A history move or reload started from browser chrome rather than by the page. */
+export type PageNavigation = 'back' | 'forward' | 'reload'
+
+/**
+ * Edit actions are explicit calls on this page rather than menu roles: a role
+ * acts on whichever contents holds focus, and a right-click need not focus the page.
+ */
interface AgentContextMenuHandlers {
addToChat(text: string): void
+ replaceMisspelling(word: string): void
+ addToDictionary(word: string): void
+ undo(): void
+ redo(): void
+ cut(): void
copy(): void
paste(): void
- back(): void
- forward(): void
- reload(): void
+ pasteAndMatchStyle(): void
+ selectAll(): void
+ navigate(kind: PageNavigation): void
openTab(url: string): void
copyLink(url: string): void
+ copyImage(): void
+ saveImage(url: string): void
setZoomFactor(factor: number): void
}
export interface AgentContextMenuHost {
/** Attaches selected page text to the chat that owns this browser tab. */
addToChat(text: string): void
- /** Opens a link from the page in another tab of the same browser. */
- openTab(url: string): void
+ /** Opens a URL from the page in another tab of the same browser. */
+ openTab(url: string, referrer: Referrer): void
/** Returns the device's current default page zoom factor. */
defaultZoomFactor(): number
+ history(): Pick
+ /** Runs through the user-navigation path, so a page's leave-site question reaches the user. */
+ navigate(kind: PageNavigation): void
+}
+
+const isWebUrl = (url: string) => parseHttpUrl(url) !== null
+
+/** Ends the template with one separator, never two in a row or one at the top. */
+function endGroup(template: MenuItemConstructorOptions[]): void {
+ if (template.length > 0 && template[template.length - 1].type !== 'separator') {
+ template.push({ type: 'separator' })
+ }
}
/**
* Builds the page menu. Unlike the main window's menu this is never empty —
- * navigation and zoom always apply, and only the clipboard and link items
- * depend on what was clicked.
+ * navigation and zoom always apply, and only the spelling, clipboard, link, and
+ * image items depend on what was clicked.
*
- * Only http(s) links get link items: the actions open a browser tab or copy an
- * address, and neither means anything for a `javascript:` or `mailto:` target.
+ * Only http(s) links and image sources get address items: the actions open a
+ * browser tab, download, or copy an address, and none of them means anything
+ * for a `javascript:`, `data:`, or `blob:` target. Copy Image needs no address,
+ * so it is offered for any loaded image.
*/
export function buildAgentContextMenuTemplate(
params: AgentContextMenuParams,
@@ -124,8 +163,13 @@ export function buildAgentContextMenuTemplate(
handlers: AgentContextMenuHandlers
): MenuItemConstructorOptions[] {
const template: MenuItemConstructorOptions[] = []
- const linkUrl = /^https?:\/\//i.test(params.linkURL) ? params.linkURL : ''
+ const linkUrl = isWebUrl(params.linkURL) ? params.linkURL : ''
+ const imageUrl = isWebUrl(params.srcURL) ? params.srcURL : ''
const selectionText = params.selectionText
+ const flags = params.editFlags
+
+ template.push(...spellingItems(params, handlers))
+ endGroup(template)
if (selectionText.trim()) {
template.push(
@@ -142,20 +186,47 @@ export function buildAgentContextMenuTemplate(
)
}
- if (selectionText.trim()) {
- template.push({ label: 'Copy', click: () => handlers.copy() })
- }
- if (params.isEditable && params.editFlags.canPaste) {
- template.push({ label: 'Paste', click: () => handlers.paste() })
+ if (params.mediaType === 'image') {
+ const copyImage: MenuItemConstructorOptions[] = params.hasImageContents
+ ? [{ label: 'Copy Image', click: () => handlers.copyImage() }]
+ : []
+ template.push(
+ ...(imageUrl
+ ? [
+ { label: 'Open Image in New Tab', click: () => handlers.openTab(imageUrl) },
+ { label: 'Save Image', click: () => handlers.saveImage(imageUrl) },
+ ...copyImage,
+ { label: 'Copy Image Address', click: () => handlers.copyLink(imageUrl) },
+ ]
+ : copyImage)
+ )
+ endGroup(template)
}
- if (template.length > 0 && template[template.length - 1].type !== 'separator') {
- template.push({ type: 'separator' })
+
+ if (params.isEditable) {
+ template.push(
+ { label: 'Undo', enabled: flags.canUndo, click: () => handlers.undo() },
+ { label: 'Redo', enabled: flags.canRedo, click: () => handlers.redo() },
+ { type: 'separator' },
+ { label: 'Cut', enabled: flags.canCut, click: () => handlers.cut() },
+ { label: 'Copy', enabled: flags.canCopy, click: () => handlers.copy() },
+ { label: 'Paste', enabled: flags.canPaste, click: () => handlers.paste() },
+ {
+ label: 'Paste and Match Style',
+ enabled: flags.canPaste,
+ click: () => handlers.pasteAndMatchStyle(),
+ },
+ { label: 'Select All', enabled: flags.canSelectAll, click: () => handlers.selectAll() }
+ )
+ } else if (selectionText.trim()) {
+ template.push({ label: 'Copy', click: () => handlers.copy() })
}
+ endGroup(template)
template.push(
- { label: 'Back', enabled: page.canGoBack, click: () => handlers.back() },
- { label: 'Forward', enabled: page.canGoForward, click: () => handlers.forward() },
- { label: 'Reload', click: () => handlers.reload() },
+ { label: 'Back', enabled: page.canGoBack, click: () => handlers.navigate('back') },
+ { label: 'Forward', enabled: page.canGoForward, click: () => handlers.navigate('forward') },
+ { label: 'Reload', click: () => handlers.navigate('reload') },
{ type: 'separator' }
)
@@ -198,20 +269,31 @@ export function attachAgentContextMenu(contents: WebContents, host: AgentContext
const template = buildAgentContextMenuTemplate(
params,
{
- canGoBack: contents.navigationHistory.canGoBack(),
- canGoForward: contents.navigationHistory.canGoForward(),
+ ...host.history(),
zoomFactor: contents.getZoomFactor(),
defaultZoomFactor: host.defaultZoomFactor(),
},
{
addToChat: (text) => host.addToChat(text),
+ replaceMisspelling: (word) => contents.replaceMisspelling(word),
+ addToDictionary: (word) => contents.session.addWordToSpellCheckerDictionary(word),
+ undo: () => contents.undo(),
+ redo: () => contents.redo(),
+ cut: () => contents.cut(),
copy: () => contents.copy(),
paste: () => contents.paste(),
- back: () => contents.navigationHistory.goBack(),
- forward: () => contents.navigationHistory.goForward(),
- reload: () => contents.reload(),
- openTab: (url) => host.openTab(url),
+ pasteAndMatchStyle: () => contents.pasteAndMatchStyle(),
+ selectAll: () => contents.selectAll(),
+ navigate: (kind) => host.navigate(kind),
+ openTab: (url) => host.openTab(url, menuReferrer(params)),
copyLink: (url) => clipboard.writeText(url),
+ copyImage: () => contents.copyImageAt(params.x, params.y),
+ // A download, so the browser's download limits and request guards apply. Electron maps a
+ // Referer header into the download's referrer, so referrer-protected images still save.
+ saveImage: (url) => {
+ const referrer = referrerForRequest(menuReferrer(params), url)
+ contents.downloadURL(url, referrer ? { headers: { Referer: referrer } } : undefined)
+ },
setZoomFactor: (factor) => contents.setZoomFactor(factor),
}
)
diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts
index 03ec50d8d57..fccd280a9ec 100644
--- a/apps/desktop/src/main/browser-agent/driver.test.ts
+++ b/apps/desktop/src/main/browser-agent/driver.test.ts
@@ -804,7 +804,7 @@ describe('executeTool', () => {
const popup = session.activeTab()?.view.webContents
if (!popup) throw new Error('Expected user popup tab')
expect(session.automationTab()?.view.webContents).toBe(source)
- expect(popup.loadURL).toHaveBeenCalledWith('https://user-popup.example/')
+ expect(popup.loadURL).toHaveBeenCalledWith('https://user-popup.example/', expect.anything())
})
it('keeps agent-opened target-blank tabs agent-owned after dispatch ends', async () => {
@@ -823,7 +823,7 @@ describe('executeTool', () => {
expect(session.activeTab()?.view.webContents).toBe(source)
session.setAutomationActive(false)
expect(session.automationTab()?.view.webContents).toBe(popup)
- expect(popup.loadURL).toHaveBeenCalledWith('https://agent-popup.example/')
+ expect(popup.loadURL).toHaveBeenCalledWith('https://agent-popup.example/', expect.anything())
})
it('does not report a timed-out restored tab as ready to the model', async () => {
@@ -1150,6 +1150,31 @@ describe('executeTool', () => {
}
})
+ it('reports a browser action as running from queueing until it settles', async () => {
+ vi.useFakeTimers()
+ try {
+ await driver.executeTool('chat-test', 'browser_open_tab', {})
+ expect(driver.isBrowserToolRunning()).toBe(false)
+ const queued = driver.captureBrowserToolQueueBoundary('chat-test')
+ expect(driver.isBrowserToolRunning()).toBe(true)
+ if (queued) driver.releaseBrowserToolQueueBoundary(queued)
+ expect(driver.isBrowserToolRunning()).toBe(false)
+ const contents = session.requireTab().view.webContents
+ vi.mocked(contents.getURL).mockReturnValue('https://example.com/')
+ vi.mocked(contents.executeJavaScript).mockImplementation(() => new Promise(() => {}))
+
+ const held = driver.executeTool('chat-test', 'browser_snapshot', {}, 'held-running')
+ await vi.advanceTimersByTimeAsync(0)
+ expect(driver.isBrowserToolRunning()).toBe(true)
+
+ driver.cancelTool('chat-test', 'held-running')
+ await held
+ expect(driver.isBrowserToolRunning()).toBe(false)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('sanitizes hostile tab titles before returning them across the tool boundary', async () => {
await driver.executeTool('chat-test', 'browser_open_tab', {})
const contents = session.requireTab().view.webContents
diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts
index 595d7369c05..d0ada80bf5f 100644
--- a/apps/desktop/src/main/browser-agent/driver.ts
+++ b/apps/desktop/src/main/browser-agent/driver.ts
@@ -81,6 +81,11 @@ import {
setFocusedInputValue,
typeIntoElement,
} from '@/main/browser-agent/page-functions'
+import {
+ appLaunchNoticeForContents,
+ pagePromptForContents,
+ respondToPagePrompt,
+} from '@/main/browser-agent/page-prompts'
import { isPanelVisible, panelWindow } from '@/main/browser-agent/panel'
import {
withFailedPostActionObservation,
@@ -457,6 +462,11 @@ function releaseBrowserToolAdmission(state: DriverScopeState, admission: symbol)
activeBrowserToolAdmissions.delete(admission)
}
+/** Whether any chat has a browser action queued or running in this process. */
+export function isBrowserToolRunning(): boolean {
+ return activeBrowserToolAdmissions.size > 0 || pendingBrowserToolQueueBoundaries.size > 0
+}
+
function retireDriverScopeState(state: DriverScopeState): void {
state.disposed = true
state.toolQueueCancellationEpoch++
@@ -555,6 +565,8 @@ function pageStateFor(contents: WebContents, tabId: string): BrowserPageState {
const issue = session.pageIssueForContents(contents)
const mediaPermissionRequest = session.mediaPermissionRequestForContents(contents)
const dialog = session.pageDialogForContents(contents)
+ const prompt = pagePromptForContents(contents)
+ const appLaunchNotice = appLaunchNoticeForContents(contents)
return {
scopeId: session.getBrowserScopeId(),
tabId,
@@ -566,6 +578,8 @@ function pageStateFor(contents: WebContents, tabId: string): BrowserPageState {
...(issue ? { issue } : {}),
...(mediaPermissionRequest ? { mediaPermissionRequest } : {}),
...(dialog ? { dialog } : {}),
+ ...(prompt ? { prompt } : {}),
+ ...(appLaunchNotice ? { appLaunchNotice } : {}),
}
}
@@ -804,6 +818,7 @@ export function initDriver(
})
},
onDownloadsChanged: (state) => callbacks.onDownloadsChanged?.(state),
+ onAgentNotice: recordNotice,
},
getMainWindow,
persistence,
@@ -5500,6 +5515,11 @@ export async function handlePanelAction(
}
if (action.action === 'enable-page-dialogs') {
session.enablePageDialogs()
+ if (action.pagePrompts === true) session.enablePagePrompts()
+ return
+ }
+ if (action.action === 'respond-prompt') {
+ respondToPagePrompt(session.getBrowserScopeId(), action)
return
}
if (action.action === 'respond-dialog') {
diff --git a/apps/desktop/src/main/browser-agent/known-sessions.ts b/apps/desktop/src/main/browser-agent/known-sessions.ts
index e248faf0ad0..9a98d8e268c 100644
--- a/apps/desktop/src/main/browser-agent/known-sessions.ts
+++ b/apps/desktop/src/main/browser-agent/known-sessions.ts
@@ -4,6 +4,7 @@ import type {
BrowserKnownSessionsState,
BrowserSessionEvidence,
} from '@sim/browser-protocol'
+import { isSameOrSubdomain } from '@/main/browser-import/site-scope'
import type { BrowserKnownSiteSetting, ConfigStore, DesktopSettings } from '@/main/config'
const MAX_STORED_SITES = 100
@@ -107,7 +108,7 @@ function recordsEqual(
function cookieMatchesHost(cookieDomain: string, hostname: string): boolean {
const domain = normalizePublicHostname(cookieDomain)
- return domain !== null && (hostname === domain || hostname.endsWith(`.${domain}`))
+ return domain !== null && isSameOrSubdomain(hostname, domain)
}
/**
diff --git a/apps/desktop/src/main/browser-agent/page-prompts.test.ts b/apps/desktop/src/main/browser-agent/page-prompts.test.ts
new file mode 100644
index 00000000000..0c97f95fc3d
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/page-prompts.test.ts
@@ -0,0 +1,430 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+
+import type { BrowserPagePrompt, BrowserPagePromptResponse } from '@sim/browser-protocol'
+import type {
+ AuthenticationResponseDetails,
+ AuthInfo,
+ Certificate,
+ Session,
+ WebContents,
+ WebFrameMain,
+} from 'electron'
+import { app, webContents as electronWebContents, shell } from 'electron'
+import {
+ appLaunchNoticeForContents,
+ handleBrowserLogin,
+ handleSelectClientCertificate,
+ handleSelectPasskeyAccount,
+ httpAuthChallenge,
+ noteMainFrameNavigation,
+ pagePromptForContents,
+ requestExternalLaunch,
+ resetPagePromptDecisions,
+ respondToPagePrompt,
+ setPagePromptHost,
+} from '@/main/browser-agent/page-prompts'
+import { registerAgentWebContents } from '@/main/browser-agent/registry'
+
+const PAGE = 'https://meet.example'
+
+interface Context {
+ promptsEnabled: boolean
+ onScreen: boolean
+ userActivated: boolean
+ agentActedLast: boolean
+ agentDrives: boolean
+}
+
+let context: Context
+
+function agentContents(session: Session = {} as Session): WebContents {
+ const contents = {
+ isDestroyed: () => false,
+ getURL: () => `${PAGE}/j/1`,
+ mainFrame: { frameTreeNodeId: 1 },
+ session,
+ } as WebContents
+ registerAgentWebContents(contents)
+ return contents
+}
+
+function shownPrompt(contents: WebContents): BrowserPagePrompt {
+ const prompt = pagePromptForContents(contents)
+ if (!prompt) throw new Error('No prompt shown')
+ return prompt
+}
+
+function answer(contents: WebContents, response: BrowserPagePromptResponse, scopeId = 'chat') {
+ respondToPagePrompt(scopeId, {
+ action: 'respond-prompt',
+ requestId: shownPrompt(contents).requestId,
+ response,
+ })
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ context = {
+ promptsEnabled: true,
+ onScreen: true,
+ userActivated: false,
+ agentActedLast: false,
+ agentDrives: false,
+ }
+ setPagePromptHost({
+ tabContext: () => ({ scopeId: 'chat', ...context }),
+ publish: () => {},
+ focus: () => {},
+ noticeForAgent: () => {},
+ })
+})
+
+afterEach(() => {
+ setPagePromptHost(null)
+ resetPagePromptDecisions()
+ vi.useRealTimers()
+})
+
+describe('httpAuthChallenge', () => {
+ const url = 'https://intranet.example:8443/reports'
+ const page = { url, isRequestForNavigation: true }
+ const server = { isProxy: false, host: 'intranet.example', port: 8443 }
+
+ it("asks about the tab's own top-level navigation, challenged by the host it requested", () => {
+ expect(httpAuthChallenge(page, server, url)).toEqual({
+ origin: 'https://intranet.example:8443',
+ proxy: false,
+ insecure: false,
+ })
+ expect(
+ httpAuthChallenge(
+ { url: 'http://router.local/', isRequestForNavigation: true },
+ { isProxy: false, host: 'router.local', port: 80 },
+ 'http://router.local/'
+ )
+ ).toMatchObject({ insecure: true })
+ })
+
+ it('cancels frame, subresource, and cross-origin challenges', () => {
+ expect(httpAuthChallenge(page, server, 'https://intranet.example:8443/')).toBeNull()
+ expect(httpAuthChallenge(page, server, undefined)).toBeNull()
+ expect(httpAuthChallenge({ ...page, isRequestForNavigation: false }, server, url)).toBeNull()
+ expect(httpAuthChallenge(page, { ...server, host: 'evil.example' }, url)).toBeNull()
+ expect(httpAuthChallenge(page, { ...server, port: 443 }, url)).toBeNull()
+ })
+
+ it('asks about a proxy for any request', () => {
+ expect(
+ httpAuthChallenge(
+ { url: 'https://example.com/pixel.png', isRequestForNavigation: false },
+ { isProxy: true, host: 'proxy.corp', port: 3128 },
+ undefined
+ )
+ ).toEqual({ origin: 'proxy.corp:3128', proxy: true, insecure: false })
+ })
+})
+
+describe('HTTP sign-in', () => {
+ const url = 'https://intranet.example/'
+ const login = (contents: WebContents, firstAuthAttempt = true) => {
+ const callback = vi.fn()
+ handleBrowserLogin(
+ { preventDefault: () => {} },
+ contents,
+ { url, isRequestForNavigation: true, firstAuthAttempt } as AuthenticationResponseDetails,
+ {
+ isProxy: false,
+ scheme: 'basic',
+ host: 'intranet.example',
+ port: 443,
+ realm: '',
+ } as AuthInfo,
+ callback
+ )
+ return callback
+ }
+
+ it('stops asking a page that keeps reloading into the same challenge', () => {
+ const contents = agentContents()
+ for (let attempt = 0; attempt < 3; attempt++) {
+ noteMainFrameNavigation(contents, url)
+ const callback = login(contents)
+ expect(pagePromptForContents(contents)).toMatchObject({ kind: 'http-auth' })
+ answer(contents, { allowed: false })
+ expect(callback).toHaveBeenCalledExactlyOnceWith()
+ }
+ noteMainFrameNavigation(contents, url)
+ expect(login(contents)).toHaveBeenCalledExactlyOnceWith()
+ expect(pagePromptForContents(contents)).toBeUndefined()
+
+ vi.advanceTimersByTime(60_000)
+ login(contents)
+ expect(pagePromptForContents(contents)).toMatchObject({ kind: 'http-auth' })
+ })
+
+ it('passes on only the credentials of an accepted answer', () => {
+ const contents = agentContents()
+ noteMainFrameNavigation(contents, url)
+ const callback = login(contents)
+ answer(contents, { allowed: true, kind: 'http-auth', username: 'ada', password: 'pw' })
+ expect(callback).toHaveBeenCalledExactlyOnceWith('ada', 'pw')
+ })
+
+ it('cancels instead of leaving the page waiting when an accepted answer is malformed', () => {
+ const contents = agentContents()
+ noteMainFrameNavigation(contents, url)
+ const callback = login(contents)
+ // double-cast-allowed: an IPC payload is untyped; this is a renderer's malformed answer
+ answer(contents, { allowed: true, kind: 'http-auth' } as unknown as BrowserPagePromptResponse)
+ expect(callback).toHaveBeenCalledExactlyOnceWith()
+ expect(pagePromptForContents(contents)).toBeUndefined()
+ })
+})
+
+describe('client certificates', () => {
+ const certificate = (fingerprint: string, subjectName: string) =>
+ ({ fingerprint, subjectName, issuerName: 'Corp CA', validExpiry: 2_000_000_000 }) as Certificate
+ const first = certificate('sha256/first', 'Personal')
+ const work = certificate('sha256/work', 'Work badge')
+
+ function request(contents: WebContents | null, list: Certificate[], url = 'https://mtls.corp') {
+ const event = { preventDefault: vi.fn() }
+ const callback = vi.fn()
+ handleSelectClientCertificate(event, contents, url, list, callback)
+ return { event, callback }
+ }
+
+ it("leaves the main window on Electron's default", () => {
+ const { event, callback } = request({ isDestroyed: () => false } as WebContents, [first])
+ expect(event.preventDefault).not.toHaveBeenCalled()
+ expect(callback).not.toHaveBeenCalled()
+ })
+
+ it('sends no certificate when there is none, or no renderer to ask', () => {
+ const contents = agentContents()
+ const empty = request(contents, [])
+ expect(empty.event.preventDefault).toHaveBeenCalled()
+ expect(empty.callback).toHaveBeenCalledExactlyOnceWith()
+
+ context.promptsEnabled = false
+ expect(request(contents, [first, work]).callback).toHaveBeenCalledExactlyOnceWith()
+ })
+
+ it('sends only the picked certificate, remembers it per host, and forgets it on reset', () => {
+ const session = {} as Session
+ const contents = agentContents(session)
+ const { callback } = request(contents, [first, work])
+ expect(shownPrompt(contents)).toMatchObject({ kind: 'client-certificate', host: 'mtls.corp' })
+ answer(contents, { allowed: true, kind: 'client-certificate', choiceId: 'sha256/work' })
+ expect(callback).toHaveBeenCalledExactlyOnceWith(work)
+
+ expect(request(agentContents(session), [first, work]).callback).toHaveBeenCalledWith(work)
+ expect(
+ request(agentContents(session), [first, work], 'https://other.corp').callback
+ ).not.toHaveBeenCalled()
+
+ const otherSession = {} as Session
+ const otherContents = agentContents(otherSession)
+ request(otherContents, [first])
+ answer(otherContents, { allowed: true, kind: 'client-certificate', choiceId: 'sha256/first' })
+
+ resetPagePromptDecisions(session)
+ expect(request(agentContents(session), [first, work]).callback).not.toHaveBeenCalled()
+ expect(request(agentContents(otherSession), [first]).callback).toHaveBeenCalledWith(first)
+
+ resetPagePromptDecisions()
+ expect(request(agentContents(otherSession), [first]).callback).not.toHaveBeenCalled()
+ })
+
+ it("remembers Don't send, but not a closed picker or a prompt nobody answered", () => {
+ const declined = agentContents()
+ const refused = request(declined, [first])
+ answer(declined, { allowed: false, remember: true })
+ expect(refused.callback).toHaveBeenCalledExactlyOnceWith()
+ expect(request(agentContents(declined.session), [first]).callback).toHaveBeenCalledWith()
+
+ const closed = agentContents()
+ const dismissed = request(closed, [first])
+ answer(closed, { allowed: false })
+ expect(dismissed.callback).toHaveBeenCalledExactlyOnceWith()
+ expect(request(agentContents(closed.session), [first]).callback).not.toHaveBeenCalled()
+
+ const ignored = agentContents()
+ const unanswered = request(ignored, [first])
+ vi.advanceTimersByTime(120_000)
+ expect(unanswered.callback).toHaveBeenCalledExactlyOnceWith()
+ expect(request(agentContents(ignored.session), [first]).callback).not.toHaveBeenCalled()
+ })
+
+ it('sends nothing for an unknown choice, an off-screen answer, or another chat', () => {
+ const forged = agentContents()
+ const unknown = request(forged, [first])
+ answer(forged, { allowed: true, kind: 'client-certificate', choiceId: 'sha256/forged' })
+ expect(unknown.callback).toHaveBeenCalledExactlyOnceWith()
+
+ const elsewhere = agentContents()
+ const otherChat = request(elsewhere, [first])
+ answer(elsewhere, { allowed: true, kind: 'client-certificate', choiceId: 'sha256/first' }, 'x')
+ expect(otherChat.callback).not.toHaveBeenCalled()
+
+ context.onScreen = false
+ const hidden = agentContents()
+ const offScreen = request(hidden, [first])
+ answer(hidden, { allowed: true, kind: 'client-certificate', choiceId: 'sha256/first' })
+ expect(offScreen.callback).toHaveBeenCalledExactlyOnceWith()
+ })
+})
+
+describe('app links', () => {
+ const topFrame = (origin = PAGE) => ({ parent: null, frameTreeNodeId: 1, origin }) as WebFrameMain
+
+ beforeEach(() => {
+ app.getApplicationNameForProtocol = vi.fn(() => 'Zoom')
+ vi.mocked(shell.openExternal).mockClear()
+ context.userActivated = true
+ })
+
+ it('refuses a launch a frame initiated, even one targeting the top document', () => {
+ const contents = agentContents()
+ const frame = {
+ parent: topFrame(),
+ frameTreeNodeId: 2,
+ origin: 'https://ads.example',
+ } as WebFrameMain
+ requestExternalLaunch(contents, 'zoommtg://zoom.us/join', frame)
+ requestExternalLaunch(contents, 'zoommtg://zoom.us/join', topFrame('https://ads.example'))
+ requestExternalLaunch(contents, 'zoommtg://zoom.us/join', null)
+ expect(pagePromptForContents(contents)).toBeUndefined()
+ expect(appLaunchNoticeForContents(contents)).toBeUndefined()
+ })
+
+ it('asks after the top document is clicked, and otherwise only offers passively', () => {
+ const clicked = agentContents()
+ requestExternalLaunch(clicked, 'zoommtg://zoom.us/join', topFrame())
+ expect(pagePromptForContents(clicked)).toMatchObject({ kind: 'external-app', appName: 'Zoom' })
+
+ context.userActivated = false
+ const unclicked = agentContents()
+ requestExternalLaunch(unclicked, 'zoommtg://zoom.us/join', topFrame())
+ expect(pagePromptForContents(unclicked)).toBeUndefined()
+ expect(appLaunchNoticeForContents(unclicked)).toMatchObject({ origin: PAGE, appName: 'Zoom' })
+ })
+
+ it('opens nothing, not even a clicked mail link, without a renderer that shows prompts', async () => {
+ context.promptsEnabled = false
+ requestExternalLaunch(agentContents(), 'mailto:ada@example.com', topFrame())
+ await vi.advanceTimersByTimeAsync(0)
+ expect(shell.openExternal).not.toHaveBeenCalled()
+ })
+
+ it('opens only the first link while its prompt is pending', async () => {
+ const contents = agentContents()
+ requestExternalLaunch(contents, 'zoommtg://zoom.us/join?id=1', topFrame())
+ requestExternalLaunch(contents, 'zoommtg://zoom.us/join?id=2', topFrame())
+ expect(appLaunchNoticeForContents(contents)).toBeUndefined()
+ answer(contents, { allowed: true, kind: 'external-app', alwaysAllow: false })
+ await vi.advanceTimersByTimeAsync(0)
+ expect(shell.openExternal).toHaveBeenCalledExactlyOnceWith('zoommtg://zoom.us/join?id=1')
+ })
+
+ it('forgets "Always allow" on reset', async () => {
+ const contents = agentContents()
+ requestExternalLaunch(contents, 'zoommtg://zoom.us/join?id=1', topFrame())
+ answer(contents, { allowed: true, kind: 'external-app', alwaysAllow: true })
+ resetPagePromptDecisions()
+ vi.advanceTimersByTime(20_000)
+ requestExternalLaunch(contents, 'zoommtg://zoom.us/join?id=2', topFrame())
+ await vi.advanceTimersByTimeAsync(0)
+ expect(pagePromptForContents(contents)).toMatchObject({ kind: 'external-app' })
+ expect(shell.openExternal).toHaveBeenCalledExactlyOnceWith('zoommtg://zoom.us/join?id=1')
+ })
+})
+
+describe('accepting an answer', () => {
+ const topFrame = { parent: null, frameTreeNodeId: 1, origin: PAGE } as WebFrameMain
+
+ /** What a renderer could send over IPC without `allowed: true`, which the gesture gate keys on. */
+ function withoutAllowed(response: Record): BrowserPagePromptResponse {
+ // double-cast-allowed: an IPC payload is untyped; this is the shape a hostile renderer could send
+ return response as unknown as BrowserPagePromptResponse
+ }
+
+ it.each([
+ [
+ 'client-certificate',
+ { kind: 'client-certificate', choiceId: 'sha256/first' },
+ (contents: WebContents, callback: () => void) =>
+ handleSelectClientCertificate(
+ { preventDefault: () => {} },
+ contents,
+ 'https://mtls.corp',
+ [{ fingerprint: 'sha256/first', subjectName: 'Personal' } as Certificate],
+ callback
+ ),
+ ],
+ [
+ 'passkey-account',
+ { kind: 'passkey-account', choiceId: 'credential-1' },
+ (contents: WebContents, callback: () => void) => {
+ vi.mocked(electronWebContents.fromFrame).mockReturnValue(contents)
+ handleSelectPasskeyAccount(
+ {},
+ {
+ relyingPartyId: 'meet.example',
+ accounts: [{ credentialId: 'credential-1', name: 'ada', userHandle: 'u1' }],
+ frame: topFrame,
+ },
+ callback
+ )
+ },
+ ],
+ [
+ 'http-auth',
+ { kind: 'http-auth', username: 'ada', password: 'pw' },
+ (contents: WebContents, callback: () => void) => {
+ noteMainFrameNavigation(contents, 'https://intranet.example/')
+ handleBrowserLogin(
+ { preventDefault: () => {} },
+ contents,
+ {
+ url: 'https://intranet.example/',
+ isRequestForNavigation: true,
+ firstAuthAttempt: true,
+ } as AuthenticationResponseDetails,
+ { isProxy: false, scheme: 'basic', host: 'intranet.example', port: 443 } as AuthInfo,
+ callback
+ )
+ },
+ ],
+ ])('sends nothing for a %s answer without allowed: true', (_kind, response, ask) => {
+ const contents = agentContents()
+ const callback = vi.fn()
+ ask(contents, callback)
+ answer(contents, withoutAllowed(response))
+ expect(callback).toHaveBeenCalledExactlyOnceWith()
+ })
+
+ it.each([
+ ['an app prompt', { kind: 'external-app', alwaysAllow: true }, 'prompt'],
+ ['an app notice', { kind: 'app-launch-notice' }, 'notice'],
+ ])('opens nothing for %s answer without allowed: true', async (_label, response, slot) => {
+ app.getApplicationNameForProtocol = vi.fn(() => 'Zoom')
+ vi.mocked(shell.openExternal).mockClear()
+ context.userActivated = slot === 'prompt'
+ const contents = agentContents()
+ requestExternalLaunch(contents, 'zoommtg://zoom.us/join', topFrame)
+ const requestId =
+ slot === 'prompt'
+ ? shownPrompt(contents).requestId
+ : appLaunchNoticeForContents(contents)?.requestId
+ respondToPagePrompt('chat', {
+ action: 'respond-prompt',
+ requestId,
+ response: withoutAllowed(response),
+ })
+ await vi.advanceTimersByTimeAsync(0)
+ expect(shell.openExternal).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/desktop/src/main/browser-agent/page-prompts.ts b/apps/desktop/src/main/browser-agent/page-prompts.ts
new file mode 100644
index 00000000000..822c703ba84
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/page-prompts.ts
@@ -0,0 +1,603 @@
+/**
+ * Sign-in and app-launch decisions a browser tab's page waits on: HTTP
+ * authentication, passkey account choice, client certificates, and links to
+ * other apps. Sim's renderer shows each one for the exact tab that asked; only
+ * a user-activated renderer response answers it, and every request is refused
+ * when the renderer cannot show it, does not answer in time, or the page moves
+ * on to another document.
+ */
+import type {
+ BrowserAppLaunchNotice,
+ BrowserPagePrompt,
+ BrowserPagePromptResponse,
+ BrowserPanelAction,
+} from '@sim/browser-protocol'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { truncate } from '@sim/utils/string'
+import type {
+ AuthenticationResponseDetails,
+ AuthInfo,
+ Certificate,
+ Session,
+ WebAuthnAccount,
+ WebContents,
+ WebFrameMain,
+} from 'electron'
+import { app, webContents as electronWebContents } from 'electron'
+import { LRUCache } from 'lru-cache'
+import { isAgentWebContents } from '@/main/browser-agent/registry'
+import { withinWindow } from '@/main/input-activity'
+import { appLinkTarget, openExternalSafe, parseHttpUrl } from '@/main/navigation'
+
+const logger = createLogger('BrowserPagePrompts')
+
+/** Long enough to type a password; a request never waits on a renderer beyond it. */
+const PAGE_PROMPT_TIMEOUT_MS = 120_000
+const APP_LAUNCH_NOTICE_TIMEOUT_MS = 60_000
+/** Prompts per tab and origin per window, across reloads as well as rejected attempts. */
+const MAX_HTTP_AUTH_PROMPTS = 3
+const HTTP_AUTH_PROMPT_WINDOW_MS = 60_000
+const MAX_CREDENTIAL_LENGTH = 1_024
+const MAX_DISPLAY_TEXT_LENGTH = 120
+const MAX_PROMPT_CHOICES = 50
+const LAUNCH_RATE_WINDOW_MS = 20_000
+const MAX_LAUNCHES_PER_WINDOW = 2
+const MAX_REMEMBERED_DECISIONS = 200
+/** Controls and bidi overrides that let untrusted text break or reorder a line. */
+const UNTRUSTED_TEXT_CONTROLS = /[\u0000-\u001f\u007f-\u009f--]/g
+
+interface PageTabContext {
+ scopeId: string
+ /** The chat's renderer declared that it shows page prompts. */
+ promptsEnabled: boolean
+ /** The visible page of the visible chat. */
+ onScreen: boolean
+ /** The user's real click or keypress reached this document moments ago, after any agent input. */
+ userActivated: boolean
+ /** The agent's input is being delivered, or was the latest input in this tab. */
+ agentActedLast: boolean
+ /** The agent drives this tab and the user has not claimed it. */
+ agentDrives: boolean
+}
+
+/** The session's side of a tab; injected to keep the dependency one-way. */
+interface PagePromptHost {
+ tabContext(contents: WebContents): PageTabContext | null
+ publish(contents: WebContents): void
+ focus(contents: WebContents): void
+ noticeForAgent(contents: WebContents, notice: string): void
+}
+
+type RequestKind = BrowserPagePrompt['kind'] | 'app-launch-notice'
+/** `null` is a cancellation: timeout, navigation, or an answer the user could not have given. */
+type RequestAnswer = (response: BrowserPagePromptResponse | null) => void
+type WithoutRequestId = View extends unknown ? Omit : never
+
+interface PendingRequest {
+ id: string
+ contents: WebContents
+ kind: RequestKind
+ view: BrowserPagePrompt | BrowserAppLaunchNotice
+ key: string
+ answers: RequestAnswer[]
+ timer: ReturnType
+}
+
+interface TabRequests {
+ prompt?: PendingRequest
+ notice?: PendingRequest
+}
+
+let host: PagePromptHost | null = null
+const pendingById = new Map()
+const pendingByContents = new WeakMap()
+const mainFrameNavigations = new WeakMap()
+const recentAttemptsByContents = new WeakMap>()
+/** A host's remembered certificate, or `'declined'` for an explicit "Don't send". */
+type CertificateDecision = { fingerprint: string } | 'declined'
+let certificateDecisions = new WeakMap>()
+const alwaysAllowedLaunches = new LRUCache({ max: MAX_REMEMBERED_DECISIONS })
+
+export function setPagePromptHost(next: PagePromptHost | null): void {
+ host = next
+ if (next) return
+ for (const entry of [...pendingById.values()]) settle(entry, null)
+}
+
+/** Forgets remembered certificate and app-launch choices, as a sign-out or data wipe must. */
+export function resetPagePromptDecisions(session?: Session): void {
+ if (session) certificateDecisions.delete(session)
+ else certificateDecisions = new WeakMap()
+ alwaysAllowedLaunches.clear()
+}
+
+/** The live list of this tab's recent attempts under `key`, pruned to the window. */
+function recentAttempts(contents: WebContents, key: string, windowMs: number): number[] {
+ const byKey =
+ recentAttemptsByContents.get(contents) ??
+ new LRUCache({ max: MAX_REMEMBERED_DECISIONS })
+ recentAttemptsByContents.set(contents, byKey)
+ const recent = (byKey.get(key) ?? []).filter((at) => withinWindow(at, windowMs))
+ byKey.set(key, recent)
+ return recent
+}
+
+/** Untrusted page or server text, reduced to one bounded display line. */
+function displayText(value: unknown, maxLength = MAX_DISPLAY_TEXT_LENGTH): string {
+ if (typeof value !== 'string') return ''
+ return truncate(
+ value.replace(UNTRUSTED_TEXT_CONTROLS, ' ').replace(/\s+/g, ' ').trim(),
+ maxLength
+ )
+}
+
+/** Distinct, bounded choices keyed by a non-empty string id. */
+function uniqueChoices- (
+ items: readonly Item[],
+ idOf: (item: Item) => unknown,
+ toChoice: (id: string, item: Item) => Choice
+): Choice[] {
+ const seen = new Set
()
+ const choices: Choice[] = []
+ for (const item of items) {
+ const id = idOf(item)
+ if (typeof id !== 'string' || !id || id.length > MAX_CREDENTIAL_LENGTH || seen.has(id)) continue
+ seen.add(id)
+ choices.push(toChoice(id, item))
+ if (choices.length >= MAX_PROMPT_CHOICES) break
+ }
+ return choices
+}
+
+/**
+ * Answers Electron exactly once. `ask` returns true once it owns the answer;
+ * otherwise, and on any error, the request is refused.
+ */
+function withPrompt(
+ label: string,
+ callback: (...args: Args | []) => void,
+ ask: (answer: (...args: Args | []) => void) => boolean
+): void {
+ let answered = false
+ const answer = (...args: Args | []) => {
+ if (answered) return
+ answered = true
+ try {
+ callback(...args)
+ } catch (error) {
+ logger.warn(`Could not answer ${label}`, { error: getErrorMessage(error) })
+ }
+ }
+ let owned = false
+ try {
+ owned = ask(answer)
+ } catch (error) {
+ logger.warn(`Could not ask about ${label}`, { error: getErrorMessage(error) })
+ } finally {
+ if (!owned) answer()
+ }
+}
+
+/**
+ * Holds a request for the user. A tab shows one prompt and one notice at a
+ * time: a new notice replaces the old one, and a prompt only joins an identical
+ * pending challenge when `joinable`. False when it cannot be shown.
+ */
+function hold(
+ contents: WebContents,
+ kind: RequestKind,
+ view: WithoutRequestId | WithoutRequestId,
+ key: string,
+ answer: RequestAnswer,
+ { joinable = false, focus = false }: { joinable?: boolean; focus?: boolean } = {}
+): 'held' | 'joined' | false {
+ if (!host || contents.isDestroyed() || !host.tabContext(contents)?.promptsEnabled) return false
+ const requests = pendingByContents.get(contents) ?? {}
+ pendingByContents.set(contents, requests)
+ const slot = kind === 'app-launch-notice' ? 'notice' : 'prompt'
+ const existing = requests[slot]
+ if (existing && slot === 'prompt') {
+ if (!joinable || existing.key !== key) return false
+ existing.answers.push(answer)
+ return 'joined'
+ }
+ if (existing) settle(existing, null)
+ const id = generateId()
+ const entry: PendingRequest = {
+ id,
+ contents,
+ kind,
+ view: { ...view, requestId: id } as BrowserPagePrompt | BrowserAppLaunchNotice,
+ key,
+ answers: [answer],
+ timer: setTimeout(
+ () => settle(entry, null),
+ slot === 'notice' ? APP_LAUNCH_NOTICE_TIMEOUT_MS : PAGE_PROMPT_TIMEOUT_MS
+ ),
+ }
+ pendingById.set(id, entry)
+ requests[slot] = entry
+ host.publish(contents)
+ if (focus) host.focus(contents)
+ return 'held'
+}
+
+function settle(entry: PendingRequest, response: BrowserPagePromptResponse | null): void {
+ if (pendingById.get(entry.id) !== entry) return
+ pendingById.delete(entry.id)
+ const requests = pendingByContents.get(entry.contents)
+ if (requests?.prompt === entry) requests.prompt = undefined
+ if (requests?.notice === entry) requests.notice = undefined
+ clearTimeout(entry.timer)
+ if (!entry.contents.isDestroyed()) host?.publish(entry.contents)
+ for (const answer of entry.answers) {
+ try {
+ answer(response)
+ } catch (error) {
+ logger.warn('Browser page prompt answer failed', { error: getErrorMessage(error) })
+ // A malformed response must still release the page, which otherwise waits forever.
+ if (response !== null) {
+ try {
+ answer(null)
+ } catch {}
+ }
+ }
+ }
+}
+
+/** The decision this tab's page waits on, for its page state. */
+export function pagePromptForContents(contents: WebContents): BrowserPagePrompt | undefined {
+ return pendingByContents.get(contents)?.prompt?.view as BrowserPagePrompt | undefined
+}
+
+export function appLaunchNoticeForContents(
+ contents: WebContents
+): BrowserAppLaunchNotice | undefined {
+ return pendingByContents.get(contents)?.notice?.view as BrowserAppLaunchNotice | undefined
+}
+
+/** Refuses what the previous document asked for: the tab moved on, crashed, or closed. */
+export function cancelPagePrompts(contents: WebContents): void {
+ const requests = pendingByContents.get(contents)
+ if (requests?.prompt) settle(requests.prompt, null)
+ if (requests?.notice) settle(requests.notice, null)
+}
+
+/**
+ * Tracks the URL the tab's top-level navigation is requesting (null once it
+ * commits, fails, or the tab stops loading), so HTTP authentication can tell its
+ * own challenge from a frame's.
+ */
+export function noteMainFrameNavigation(contents: WebContents, url: string | null): void {
+ if (url) mainFrameNavigations.set(contents, url)
+ else mainFrameNavigations.delete(contents)
+}
+
+/** The renderer's answer for chat `scopeId`; only an on-screen answer of the request's own kind accepts. */
+export function respondToPagePrompt(scopeId: string, action: BrowserPanelAction): void {
+ const entry = typeof action.requestId === 'string' ? pendingById.get(action.requestId) : undefined
+ if (!entry || entry.contents.isDestroyed()) return
+ const context = host?.tabContext(entry.contents)
+ if (!context || context.scopeId !== scopeId) return
+ const { response } = action
+ if (response?.allowed === false) {
+ settle(entry, response)
+ return
+ }
+ const accepted = response?.allowed === true && response.kind === entry.kind && context.onScreen
+ settle(entry, accepted ? response : null)
+}
+
+/**
+ * A challenge worth asking the user about, or null to cancel it: only the
+ * tab's own top-level navigation, challenged by the host and port it
+ * requested, or a proxy. Frames, subresources, and cross-origin challenges
+ * never get a prompt.
+ */
+export function httpAuthChallenge(
+ details: Pick,
+ authInfo: Pick,
+ mainFrameUrl: string | undefined
+): { origin: string; proxy: boolean; insecure: boolean } | null {
+ const challengedHost = authInfo.host.toLowerCase().replace(/^\[|\]$/g, '')
+ if (!challengedHost || !Number.isInteger(authInfo.port) || authInfo.port <= 0) return null
+ if (authInfo.isProxy) {
+ const hostLabel = challengedHost.includes(':') ? `[${challengedHost}]` : challengedHost
+ return { origin: `${hostLabel}:${authInfo.port}`, proxy: true, insecure: false }
+ }
+ if (!details.isRequestForNavigation || details.url !== mainFrameUrl) return null
+ const url = parseHttpUrl(details.url)
+ if (!url) return null
+ const requestedPort = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
+ const requestedHost = url.hostname.toLowerCase().replace(/^\[|\]$/g, '')
+ if (requestedHost !== challengedHost || requestedPort !== authInfo.port) return null
+ return { origin: url.origin, proxy: false, insecure: url.protocol === 'http:' }
+}
+
+/**
+ * `app.on('login')` for agent browser tabs. Other contents keep Electron's
+ * default, which cancels: the main window's proxy prompt needs a shell form.
+ * The prompt never takes focus, since a page can trigger it on its own.
+ */
+export function handleBrowserLogin(
+ event: { preventDefault(): void },
+ contents: WebContents | null,
+ details: AuthenticationResponseDetails,
+ authInfo: AuthInfo,
+ callback: (username?: string, password?: string) => void
+): void {
+ if (!contents || contents.isDestroyed() || !isAgentWebContents(contents)) return
+ event.preventDefault()
+ withPrompt('HTTP sign-in', callback, (answer) => {
+ const challenge = httpAuthChallenge(details, authInfo, mainFrameNavigations.get(contents))
+ if (!challenge) return false
+ const prompts = recentAttempts(
+ contents,
+ `http-auth|${challenge.origin}`,
+ HTTP_AUTH_PROMPT_WINDOW_MS
+ )
+ if (prompts.length >= MAX_HTTP_AUTH_PROMPTS) return false
+ const held = hold(
+ contents,
+ 'http-auth',
+ { kind: 'http-auth', ...challenge, retry: !details.firstAuthAttempt },
+ `http-auth|${challenge.proxy}|${challenge.origin}|${authInfo.scheme}`,
+ (response) => {
+ if (
+ response?.allowed === true &&
+ response.kind === 'http-auth' &&
+ typeof response.username === 'string' &&
+ typeof response.password === 'string' &&
+ response.username.length <= MAX_CREDENTIAL_LENGTH &&
+ response.password.length <= MAX_CREDENTIAL_LENGTH
+ ) {
+ answer(response.username, response.password)
+ } else {
+ answer()
+ }
+ },
+ { joinable: true }
+ )
+ if (held === 'held') {
+ prompts.push(Date.now())
+ if (host?.tabContext(contents)?.agentDrives) {
+ host.noticeForAgent(
+ contents,
+ `${challenge.origin} asked for a username and password. The user was asked to sign in in the browser panel; wait for them instead of retrying or entering credentials.`
+ )
+ }
+ }
+ return Boolean(held)
+ })
+}
+
+/**
+ * `select-webauthn-account` for every session a browser tab uses, the borrowed
+ * app session included. Without a listener Electron cancels every discoverable
+ * passkey sign-in; requests from any non-browser contents still cancel. The
+ * prompt never takes focus: a page can ask without the user's involvement.
+ */
+export function handleSelectPasskeyAccount(
+ _event: unknown,
+ details: { relyingPartyId: string; accounts: WebAuthnAccount[]; frame: WebFrameMain | null },
+ callback: (credentialId?: string | null) => void
+): void {
+ withPrompt('a passkey account choice', callback, (answer) => {
+ const contents = details.frame ? electronWebContents.fromFrame(details.frame) : undefined
+ if (!contents || contents.isDestroyed() || !isAgentWebContents(contents)) return false
+ const relyingPartyId = displayText(details.relyingPartyId, 253)
+ const accounts = uniqueChoices(
+ details.accounts ?? [],
+ (account) => account?.credentialId,
+ (id, account) => ({
+ id,
+ name: displayText(account.name),
+ displayName: displayText(account.displayName),
+ })
+ )
+ if (accounts.length === 0 || !relyingPartyId) return false
+ const held = hold(
+ contents,
+ 'passkey-account',
+ { kind: 'passkey-account', relyingPartyId, accounts },
+ `passkey|${relyingPartyId}|${accounts.map((account) => account.id).join(',')}`,
+ (response) => {
+ const chosen =
+ response?.allowed === true && response.kind === 'passkey-account'
+ ? accounts.find((account) => account.id === response.choiceId)
+ : undefined
+ if (chosen) answer(chosen.id)
+ else answer()
+ },
+ { joinable: true }
+ )
+ return Boolean(held)
+ })
+}
+
+/**
+ * `app.on('select-client-certificate')` for agent browser tabs. Electron's
+ * default sends the first certificate to any site that asks; a browser tab
+ * sends one only after the user picks it, and the prompt never takes focus,
+ * since a subresource can ask. The main window keeps the default.
+ */
+export function handleSelectClientCertificate(
+ event: { preventDefault(): void },
+ contents: WebContents | null,
+ url: string,
+ certificates: Certificate[],
+ callback: (certificate?: Certificate) => void
+): void {
+ if (!contents || contents.isDestroyed() || !isAgentWebContents(contents)) return
+ event.preventDefault()
+ withPrompt('a client certificate choice', callback, (answer) => {
+ const requestUrl = parseHttpUrl(url)
+ const requestHost = requestUrl?.protocol === 'https:' ? requestUrl.host.toLowerCase() : null
+ const choices = uniqueChoices(
+ certificates,
+ (certificate) => certificate?.fingerprint,
+ (id, certificate) => ({
+ id,
+ subject: displayText(certificate.subjectName || certificate.subject?.commonName),
+ issuer: displayText(certificate.issuerName || certificate.issuer?.commonName),
+ expiresAt: Number.isFinite(certificate.validExpiry) ? certificate.validExpiry * 1000 : 0,
+ })
+ )
+ if (!requestHost || choices.length === 0) return false
+ const decisions =
+ certificateDecisions.get(contents.session) ??
+ new LRUCache({ max: MAX_REMEMBERED_DECISIONS })
+ certificateDecisions.set(contents.session, decisions)
+ const remembered = decisions.get(requestHost)
+ if (remembered === 'declined') return false
+ const rememberedCertificate = remembered
+ ? certificates.find((certificate) => certificate.fingerprint === remembered.fingerprint)
+ : undefined
+ if (rememberedCertificate) {
+ answer(rememberedCertificate)
+ return true
+ }
+ const held = hold(
+ contents,
+ 'client-certificate',
+ { kind: 'client-certificate', host: requestHost, certificates: choices },
+ `client-certificate|${requestHost}`,
+ (response) => {
+ if (response?.allowed === false) {
+ // Only an explicit "Don't send" sticks; closing the picker just cancels this request.
+ if (response.remember === true) decisions.set(requestHost, 'declined')
+ answer()
+ return
+ }
+ const certificate =
+ response?.allowed === true &&
+ response.kind === 'client-certificate' &&
+ typeof response.choiceId === 'string'
+ ? certificates.find((candidate) => candidate.fingerprint === response.choiceId)
+ : undefined
+ if (!certificate) {
+ answer()
+ return
+ }
+ decisions.set(requestHost, { fingerprint: certificate.fingerprint })
+ answer(certificate)
+ },
+ { joinable: true }
+ )
+ return Boolean(held)
+ })
+}
+
+/** The tab's own top-level document asked, not a frame inside it. */
+function isTopDocument(
+ contents: WebContents,
+ frame: WebFrameMain | null | undefined,
+ origin: string
+): boolean {
+ try {
+ return Boolean(
+ frame &&
+ frame.parent === null &&
+ frame.frameTreeNodeId === contents.mainFrame.frameTreeNodeId &&
+ frame.origin === origin
+ )
+ } catch {
+ return false
+ }
+}
+
+function launchAppLink(url: string): void {
+ void openExternalSafe(url, false, { appLink: true })
+}
+
+/**
+ * A browser tab's navigation to a non-web URL, with the frame that initiated
+ * it. Agent input never launches anything, and neither do frames; `window.open`
+ * never reaches here, since it names no initiating frame. The top document's
+ * request right after the user's own click asks them to confirm the app (mail
+ * links open directly); one without that click only gets a passive notice the
+ * user can act on. Nothing launches without a renderer that shows prompts, and
+ * a pending launch prompt refuses further requests.
+ */
+export function requestExternalLaunch(
+ contents: WebContents,
+ rawUrl: string,
+ initiator: WebFrameMain | null | undefined
+): void {
+ if (!host || contents.isDestroyed() || !isAgentWebContents(contents)) return
+ const target = appLinkTarget(rawUrl)
+ if (!target) {
+ logger.warn('Denied external app link in agent browser', {
+ scheme: displayText(rawUrl.split(':', 1)[0], 32),
+ })
+ return
+ }
+ let appName = ''
+ try {
+ appName = displayText(app.getApplicationNameForProtocol(target.url), 80)
+ } catch {}
+ const context = host.tabContext(contents)
+ if (!appName || !context?.promptsEnabled) return
+ if (context.agentActedLast) {
+ host.noticeForAgent(
+ contents,
+ `The page tried to open ${appName}. Links to other apps never open from agent actions; ask the user to open it themselves if the task needs it.`
+ )
+ return
+ }
+ const origin = parseHttpUrl(contents.getURL())?.origin
+ if (!origin) return
+ if (!isTopDocument(contents, initiator, origin)) {
+ logger.warn('Denied an app link from a frame in agent browser', { scheme: target.scheme })
+ return
+ }
+ const attempts = recentAttempts(contents, 'app-launch', LAUNCH_RATE_WINDOW_MS)
+ if (attempts.length >= MAX_LAUNCHES_PER_WINDOW) return
+ const launchKey = `${origin}|${target.scheme}`
+ if (context.userActivated && !context.agentDrives) {
+ if (target.scheme === 'mailto' || alwaysAllowedLaunches.has(launchKey)) {
+ attempts.push(Date.now())
+ launchAppLink(target.url)
+ return
+ }
+ const canAlwaysAllow = origin.startsWith('https:')
+ const held = hold(
+ contents,
+ 'external-app',
+ { kind: 'external-app', origin, appName, canAlwaysAllow },
+ `external-app|${launchKey}`,
+ (response) => {
+ if (response?.allowed !== true || response.kind !== 'external-app') return
+ if (response.alwaysAllow && canAlwaysAllow) {
+ alwaysAllowedLaunches.set(launchKey, true)
+ }
+ launchAppLink(target.url)
+ },
+ // The prompt answers the user's own click in the page, so it takes the focus that click had.
+ { focus: true }
+ )
+ if (held) attempts.push(Date.now())
+ return
+ }
+ const held = hold(
+ contents,
+ 'app-launch-notice',
+ { origin, appName },
+ `app-launch-notice|${launchKey}`,
+ (response) => {
+ if (response?.allowed === true && response.kind === 'app-launch-notice') {
+ launchAppLink(target.url)
+ }
+ }
+ )
+ if (!held) return
+ attempts.push(Date.now())
+ if (context.agentDrives) {
+ host.noticeForAgent(
+ contents,
+ `The page offered to open ${appName}. Only the user can open it, from the notice in the browser panel.`
+ )
+ }
+}
diff --git a/apps/desktop/src/main/browser-agent/referrer.test.ts b/apps/desktop/src/main/browser-agent/referrer.test.ts
new file mode 100644
index 00000000000..3f41335f3f5
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/referrer.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+
+import { referrerForRequest } from '@/main/browser-agent/referrer'
+
+describe('referrerForRequest', () => {
+ const PAGE = 'https://user:secret@news.example.com/story?id=7#comments'
+ const FULL = 'https://news.example.com/story?id=7'
+ const ORIGIN = 'https://news.example.com/'
+
+ it('sends the full URL same-origin and only the origin cross-origin by default', () => {
+ const referrer = { url: PAGE, policy: 'default' } as const
+ expect(referrerForRequest(referrer, 'https://news.example.com/img.png')).toBe(FULL)
+ expect(referrerForRequest(referrer, 'https://cdn.example.net/img.png')).toBe(ORIGIN)
+ })
+
+ it('never sends credentials or the fragment, even under unsafe-url', () => {
+ expect(
+ referrerForRequest({ url: PAGE, policy: 'unsafe-url' }, 'http://cdn.example.net/img.png')
+ ).toBe(FULL)
+ })
+
+ it('sends nothing from an https page to an http image unless the policy allows downgrades', () => {
+ const image = 'http://cdn.example.net/img.png'
+ for (const policy of [
+ 'default',
+ 'strict-origin-when-cross-origin',
+ 'strict-origin',
+ 'no-referrer-when-downgrade',
+ ] as const) {
+ expect(referrerForRequest({ url: PAGE, policy }, image)).toBeUndefined()
+ }
+ expect(referrerForRequest({ url: PAGE, policy: 'origin' }, image)).toBe(ORIGIN)
+ expect(referrerForRequest({ url: PAGE, policy: 'no-referrer-when-downgrade' }, FULL)).toBe(FULL)
+ })
+
+ it('honors no-referrer and same-origin', () => {
+ const crossOrigin = 'https://cdn.example.net/img.png'
+ expect(referrerForRequest({ url: PAGE, policy: 'no-referrer' }, FULL)).toBeUndefined()
+ expect(referrerForRequest({ url: PAGE, policy: 'same-origin' }, crossOrigin)).toBeUndefined()
+ expect(referrerForRequest({ url: PAGE, policy: 'same-origin' }, FULL)).toBe(FULL)
+ })
+
+ it('cuts a referrer longer than 4096 characters to its origin', () => {
+ const longPage = `https://news.example.com/story?q=${'x'.repeat(4096)}`
+ expect(
+ referrerForRequest({ url: longPage, policy: 'unsafe-url' }, 'https://cdn.example.net/img.png')
+ ).toBe(ORIGIN)
+ })
+
+ it('sends nothing for a page that is not on the web', () => {
+ expect(
+ referrerForRequest({ url: '', policy: 'unsafe-url' }, 'https://cdn.example.net/img.png')
+ ).toBeUndefined()
+ })
+})
diff --git a/apps/desktop/src/main/browser-agent/referrer.ts b/apps/desktop/src/main/browser-agent/referrer.ts
new file mode 100644
index 00000000000..f897144aad0
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/referrer.ts
@@ -0,0 +1,81 @@
+/**
+ * The referrer a page-menu action carries: the clicked frame's URL and Referrer-Policy, and the
+ * `Referer` that policy allows for a given request.
+ */
+import type { ContextMenuParams, Referrer } from 'electron'
+import { parseHttpUrl } from '@/main/navigation'
+
+const isWebUrl = (url: string) => parseHttpUrl(url) !== null
+
+const REFERRER_POLICIES: ReadonlySet = new Set([
+ 'default',
+ 'unsafe-url',
+ 'no-referrer-when-downgrade',
+ 'no-referrer',
+ 'origin',
+ 'strict-origin-when-cross-origin',
+ 'same-origin',
+ 'strict-origin',
+])
+
+function isReferrerPolicy(value: unknown): value is Referrer['policy'] {
+ return REFERRER_POLICIES.has(value)
+}
+
+/** Electron types `referrerPolicy` as a Referrer, but it arrives as the bare policy string. */
+export function menuReferrer(
+ params: Pick
+): Referrer {
+ const policy: unknown = params.referrerPolicy
+ const url = params.frameURL || params.pageURL
+ return {
+ url: isWebUrl(url) ? url : '',
+ policy: isReferrerPolicy(policy) ? policy : 'default',
+ }
+}
+
+/** Longest referrer sent in full; a longer one is cut to its origin, as browsers do. */
+const MAX_REFERRER_LENGTH = 4096
+const LOOPBACK_HOSTS: ReadonlySet = new Set(['localhost', '127.0.0.1', '[::1]'])
+
+function isPotentiallyTrustworthy(url: URL): boolean {
+ if (url.protocol === 'https:') return true
+ return LOOPBACK_HOSTS.has(url.hostname) || url.hostname.endsWith('.localhost')
+}
+
+/**
+ * The `Referer` a request from `referrer`'s document to `targetUrl` carries under its
+ * Referrer-Policy (W3C Referrer Policy, "determine request's referrer"), or undefined when it
+ * sends none. Chromium's `default` is `strict-origin-when-cross-origin`; Electron's `Referrer` has
+ * no `origin-when-cross-origin`, which {@link menuReferrer} already reads as `default`.
+ */
+export function referrerForRequest(referrer: Referrer, targetUrl: string): string | undefined {
+ const source = parseHttpUrl(referrer.url)
+ const target = parseHttpUrl(targetUrl)
+ if (!source || !target) return undefined
+ source.username = ''
+ source.password = ''
+ source.hash = ''
+ const origin = `${source.origin}/`
+ const full = source.href.length > MAX_REFERRER_LENGTH ? origin : source.href
+ const sameOrigin = source.origin === target.origin
+ const downgrade = isPotentiallyTrustworthy(source) && !isPotentiallyTrustworthy(target)
+
+ switch (referrer.policy) {
+ case 'no-referrer':
+ return undefined
+ case 'origin':
+ return origin
+ case 'unsafe-url':
+ return full
+ case 'same-origin':
+ return sameOrigin ? full : undefined
+ case 'no-referrer-when-downgrade':
+ return downgrade ? undefined : full
+ case 'strict-origin':
+ return downgrade ? undefined : origin
+ default:
+ if (downgrade) return undefined
+ return sameOrigin ? full : origin
+ }
+}
diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts
index 10cc182f244..6f1ceff17b0 100644
--- a/apps/desktop/src/main/browser-agent/session.test.ts
+++ b/apps/desktop/src/main/browser-agent/session.test.ts
@@ -12,6 +12,7 @@ vi.mock('node:dns/promises', () => ({
}))
import {
+ app,
BrowserWindow,
dialog,
session as electronSession,
@@ -19,6 +20,11 @@ import {
shell,
WebContentsView,
} from 'electron'
+import {
+ appLaunchNoticeForContents,
+ handleBrowserLogin,
+ pagePromptForContents,
+} from '@/main/browser-agent/page-prompts'
import * as panel from '@/main/browser-agent/panel'
import { agentAppOrigin, routeAgentNavigation } from '@/main/browser-agent/registry'
import * as sessionModule from '@/main/browser-agent/session'
@@ -32,7 +38,7 @@ function _setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
}
-type PopupHandler = (details: { url: string }) => {
+type PopupHandler = (details: { url: string; disposition?: string }) => {
action: string
outlivesOpener?: boolean
createWindow?: (options: Record) => unknown
@@ -352,6 +358,13 @@ describe('browser-agent session', () => {
session = freshSession(win)
})
+ it('ignores browser shortcuts and mouse buttons before any chat has opened a browser', () => {
+ session.disposeBrowserScope('chat-test')
+
+ expect(session.handleFocusedShortcut('back', win)).toBe(false)
+ expect(session.handleFocusedShortcut('close-tab', win)).toBe(false)
+ })
+
it('invalidates only top-frame starts and identifies same-document commits', () => {
const onTabNavigated = vi.fn()
session = freshSession(win, { onTabNavigated })
@@ -1250,6 +1263,11 @@ describe('browser-agent session', () => {
contents.loadURL.mockClear()
expect(openHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })
expect(contents.loadURL).not.toHaveBeenCalled()
+ // window.open names no initiating frame, so an app link from it is never offered either.
+ session.enablePagePrompts()
+ app.getApplicationNameForProtocol = vi.fn(() => 'Zoom')
+ expect(openHandler({ url: 'zoommtg://zoom.us/join' })).toEqual({ action: 'deny' })
+ expect(appLaunchNoticeForContents(contents as unknown as WebContents)).toBeUndefined()
})
it('opens a background-disposition popup by URL and keeps cross-scheme popups denied', () => {
@@ -1259,7 +1277,7 @@ describe('browser-agent session', () => {
openWindow({ url: 'https://example.com/later' }).createWindow?.({})
const popup = (session.activeTab()?.view as unknown as MockView).webContents
expect(popup).not.toBe(source)
- expect(popup.loadURL).toHaveBeenCalledWith('https://example.com/later')
+ expect(popup.loadURL).toHaveBeenCalledWith('https://example.com/later', expect.anything())
expect(openWindow({ url: 'javascript:alert(1)' })).toEqual({ action: 'deny' })
expect(session.listTabs()).toHaveLength(2)
})
@@ -1559,9 +1577,11 @@ describe('browser-agent session', () => {
it('leaves nothing of the signed-out user behind in the browser profile', async () => {
const clearStorageData = vi.fn(async () => {})
const clearCache = vi.fn(async () => {})
+ const clearAuthCache = vi.fn(async () => {})
vi.mocked(electronSession.fromPartition).mockReturnValue({
clearStorageData,
clearCache,
+ clearAuthCache,
} as unknown as ReturnType)
const { persistence, snapshots } = memoryBrowserPersistence()
session = freshSession(win, {}, persistence)
@@ -1580,6 +1600,8 @@ describe('browser-agent session', () => {
expect(snapshots.get('chat-test')).toMatchObject({ tabs: [], activeIndex: -1 })
expect(clearStorageData).toHaveBeenCalled()
expect(clearCache).toHaveBeenCalled()
+ // Chromium keeps typed HTTP sign-in credentials outside storage and cache.
+ expect(clearAuthCache).toHaveBeenCalled()
})
it('hides the panel when the renderer stops renewing its bounds lease', async () => {
@@ -1712,6 +1734,57 @@ describe('browser-agent session', () => {
})
})
+ it('asks about an HTTP sign-in only while its top-level navigation is still loading', () => {
+ const url = 'https://intranet.example/'
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ session.enablePagePrompts()
+ const emit = (eventName: string, ...args: unknown[]) => {
+ for (const [name, handler] of contents.on.mock.calls) {
+ if (name === eventName && typeof handler === 'function') handler(...args)
+ }
+ }
+ const signIn = () => {
+ const callback = vi.fn()
+ handleBrowserLogin(
+ { preventDefault: () => {} },
+ contents as unknown as WebContents,
+ { url, isRequestForNavigation: true, firstAuthAttempt: true } as never,
+ { isProxy: false, scheme: 'basic', host: 'intranet.example', port: 443, realm: '' },
+ callback
+ )
+ return callback
+ }
+
+ emit('did-start-navigation', { isMainFrame: true, isSameDocument: false, url })
+ const loading = signIn()
+ expect(loading).not.toHaveBeenCalled()
+ expect(pagePromptForContents(contents as unknown as WebContents)).toMatchObject({
+ kind: 'http-auth',
+ })
+
+ // The navigation is aborted: Chromium reports ERR_ABORTED, which records no failure.
+ emit('did-start-navigation', { isMainFrame: true, isSameDocument: false, url })
+ emit('did-stop-loading')
+ expect(signIn()).toHaveBeenCalledExactlyOnceWith()
+ })
+
+ it('counts a download as in flight for a restart only until it finishes', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({ filename: 'report.csv', totalBytes: 100 })
+
+ startMockDownload(contents, download)
+ await vi.waitFor(() => expect(download.item.resume).toHaveBeenCalledOnce())
+ expect(session.countProgressingBrowserDownloads()).toBe(1)
+
+ download.emitDone('completed')
+ await vi.waitFor(() => expect(session.countProgressingBrowserDownloads()).toBe(0))
+ })
+
it('never overwrites a file that takes the allocated name before the download claims it', async () => {
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
writeFileSync(join(directory, 'taken.bin'), 'user data')
@@ -2321,13 +2394,28 @@ describe('importAgentCookies', () => {
describe('first-party browser sessions', () => {
const origin = 'https://www.dev.sim.ai'
+ const appSession = new WebContentsView().webContents.session
function initialize(persistence?: sessionModule.BrowserSessionPersistence) {
return freshSession(null, {}, persistence, undefined, {
origin,
- session: new WebContentsView().webContents.session,
+ session: appSession,
})
}
+ it("opens a deferred blank popup in its opener's session", () => {
+ const session = initialize()
+ const opener = session.addAutomationTab(`${origin}/home`).view.webContents
+ Object.assign(opener, { session: appSession })
+ const openWindow = vi.mocked(opener.setWindowOpenHandler).mock.calls[0]?.[0] as PopupHandler
+
+ const popup = openWindow({ url: 'about:blank', disposition: 'background-tab' }).createWindow?.(
+ {}
+ ) as WebContents
+ expect(popup).not.toBe(opener)
+ expect(agentAppOrigin(popup)).toBe(origin)
+ expect(popup.loadURL).not.toHaveBeenCalled()
+ })
+
it('keeps a populated tab and its history when crossing the session boundary', () => {
const session = initialize()
const tab = session.addAutomationTab(`${origin}/home`)
diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts
index 752fca5bec9..e912baa5722 100644
--- a/apps/desktop/src/main/browser-agent/session.ts
+++ b/apps/desktop/src/main/browser-agent/session.ts
@@ -34,6 +34,7 @@ import type {
DownloadItem,
Input,
MenuItemConstructorOptions,
+ Referrer,
Session,
WebContents,
WebContentsViewConstructorOptions,
@@ -51,9 +52,17 @@ import { isDispatchingAgentInput } from '@/main/browser-agent/cdp'
import {
attachAgentContextMenu,
BASE_ZOOM_FACTOR,
+ type PageNavigation,
steppedZoomFactor,
} from '@/main/browser-agent/context-menu'
import type { BrowserCookieSignal } from '@/main/browser-agent/known-sessions'
+import {
+ cancelPagePrompts,
+ handleSelectPasskeyAccount,
+ noteMainFrameNavigation,
+ resetPagePromptDecisions,
+ setPagePromptHost,
+} from '@/main/browser-agent/page-prompts'
import {
activatePanelScope,
detachIfAttached,
@@ -80,10 +89,12 @@ import {
import { clearHostVerdictCache } from '@/main/browser-agent/url-guard'
import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store'
import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads'
-import { isAppOrigin } from '@/main/navigation'
+import { withinWindow } from '@/main/input-activity'
+import { isAppOrigin, isBlankPopupUrl, parseHttpUrl } from '@/main/navigation'
import {
type FocusedResourceShortcut,
isResourceTabSelectionShortcut,
+ type ResourceTabSelectionShortcut,
resourceTabTargetIndex,
zoomActionForShortcut,
} from '@/main/resource-shortcuts'
@@ -101,6 +112,7 @@ export interface BrowserAppSession {
let browserAppSession: BrowserAppSession | undefined
const configuredDownloadSessions = new WeakSet()
+const configuredPasskeySessions = new WeakSet()
const routedNavigations = new WeakMap()
class SessionError extends Error {}
@@ -118,8 +130,19 @@ export interface AgentTab {
pendingMediaPermission?: PendingMediaPermission
mediaPermissionGrant?: MediaPermissionGrant
lastRealUserGestureAt?: number
+ /**
+ * Last real click or keypress in this tab. Unlike the media gesture, navigation keeps it: a
+ * click on an app link starts a navigation, clearing that one, before the launch request.
+ */
+ lastUserActivationAt?: number
+ /** When the current document committed; activation before it belonged to another page. */
+ documentCommittedAt?: number
+ /** Last click or keypress the agent dispatched; activation belongs to whoever acted last. */
+ lastAgentInputAt?: number
/** The tab whose page opened this one; agent work returns there when this tab closes. */
openerTabId?: string
+ /** Closing this tab while active shows its opener, until the user switches tabs (as in Chrome). */
+ returnToOpenerOnClose?: boolean
/** A user action asked for this page to take focus once it is on screen. */
pendingUserFocus?: boolean
/** Electron owns the native alert or confirm until CDP reports it closed. */
@@ -190,6 +213,8 @@ export interface AgentSessionEvents {
onTabThemeChanged: (contents: WebContents, theme: BrowserTheme) => void
/** A download started, progressed, or finished inside one chat's browser. */
onDownloadsChanged?: (state: BrowserDownloadsState) => void
+ /** Something a page tried that the agent should learn from its next result in this chat. */
+ onAgentNotice?: (notice: string) => void
}
/**
@@ -221,6 +246,8 @@ const BACKGROUND_TAB_RESTORE_TIMEOUT_MS = 15_000
const FOREGROUND_TAB_RESTORE_TIMEOUT_MS = 20_000
const MEDIA_PERMISSION_GESTURE_WINDOW_MS = 10_000
const MEDIA_PERMISSION_PROMPT_TIMEOUT_MS = 30_000
+/** Chromium's transient user activation lifetime. */
+const USER_ACTIVATION_WINDOW_MS = 5_000
export type BrowserShortcut =
| 'focus-omnibox'
@@ -229,39 +256,79 @@ export type BrowserShortcut =
| 'find'
| 'reload'
| 'hard-reload'
+ | UserOnlyShortcut
+
+type UserOnlyShortcut = 'back' | 'forward' | 'previous-tab' | 'next-tab' | 'stop'
+/** Shortcuts that move the user's history, tabs, or load; keystrokes the agent sends never trigger them. */
+const USER_ONLY_SHORTCUTS: ReadonlySet = new Set([
+ 'back',
+ 'forward',
+ 'previous-tab',
+ 'next-tab',
+ 'stop',
+])
+
+function isUserOnlyShortcut(shortcut: BrowserShortcut): shortcut is UserOnlyShortcut {
+ return USER_ONLY_SHORTCUTS.has(shortcut)
+}
type BrowserShortcutInput = Pick<
Input,
- 'type' | 'key' | 'isAutoRepeat' | 'isComposing' | 'shift' | 'control' | 'alt' | 'meta'
+ 'type' | 'key' | 'code' | 'isAutoRepeat' | 'isComposing' | 'shift' | 'control' | 'alt' | 'meta'
>
/**
* Resolves browser-level shortcuts using Command on macOS and Control
- * elsewhere. Alt, composing, and repeated keystrokes stay with the page.
+ * elsewhere, following Chrome's bindings. Composing and repeated keystrokes,
+ * and every other Alt chord, stay with the page.
*
* Reload resolves here, from the page's own keystroke, rather than through the
* application menu: the menu can only guess which surface owns focus, and a
* wrong guess reloads all of Sim instead of the page.
+ *
+ * `stop` is any bare Escape; only a loading page acts on it, and the page
+ * still receives the key.
*/
export function browserShortcutForInput(
input: BrowserShortcutInput,
platform: NodeJS.Platform = process.platform
): BrowserShortcut | null {
- if (input.type !== 'keyDown' || input.isAutoRepeat || input.isComposing || input.alt) {
- return null
+ if (input.type !== 'keyDown' || input.isAutoRepeat || input.isComposing) return null
+ const isMac = platform === 'darwin'
+ const primaryModifier = isMac ? input.meta : input.control
+ const otherModifier = isMac ? input.control : input.meta
+
+ if (input.alt) {
+ if (input.shift || otherModifier) return null
+ const arrow = input.key === 'ArrowLeft' ? 'left' : input.key === 'ArrowRight' ? 'right' : null
+ if (!arrow) return null
+ // As in Chrome, these win even inside a text field.
+ if (isMac) {
+ if (!input.meta) return null
+ return arrow === 'left' ? 'previous-tab' : 'next-tab'
+ }
+ if (input.control) return null
+ return arrow === 'left' ? 'back' : 'forward'
}
- const primaryModifier = platform === 'darwin' ? input.meta : input.control
- const otherModifier = platform === 'darwin' ? input.control : input.meta
-
+ if (input.key === 'Escape' && !input.shift && !input.control && !input.meta) return 'stop'
if (input.key === 'F5' && !otherModifier) {
if (input.shift || input.control) return 'hard-reload'
return input.meta ? null : 'reload'
}
if (!primaryModifier || otherModifier) return null
+ if (!isMac && !input.shift && (input.key === 'PageUp' || input.key === 'PageDown')) {
+ return input.key === 'PageUp' ? 'previous-tab' : 'next-tab'
+ }
const key = input.key.toLowerCase()
if (key === 'r') return input.shift ? 'hard-reload' : 'reload'
- if (input.shift) return null
+ if (input.shift) {
+ if (!isMac) return null
+ // Physical key: Shift turns the bracket into a brace on most layouts.
+ if (input.code === 'BracketLeft') return 'previous-tab'
+ if (input.code === 'BracketRight') return 'next-tab'
+ return null
+ }
switch (key) {
case 'l':
return 'focus-omnibox'
@@ -298,6 +365,8 @@ interface BrowserScopeState {
browserChromeFocused: boolean
/** The renderer shows page dialogs, so the user can be asked instead of the shell answering. */
pageDialogsEnabled: boolean
+ /** The renderer shows sign-in and app-launch prompts; until then the shell refuses them. */
+ pagePromptsEnabled: boolean
automationActive: boolean
automationNeedsAttention: boolean
/**
@@ -332,6 +401,7 @@ function createBrowserScopeState(): BrowserScopeState {
focusedBrowserClearTimer: null,
browserChromeFocused: false,
pageDialogsEnabled: false,
+ pagePromptsEnabled: false,
automationActive: false,
automationNeedsAttention: false,
findingTabId: null,
@@ -572,6 +642,15 @@ function activeBrowserDownloadCount(scopeId?: string): number {
return count
}
+/** Agent-browser downloads still receiving bytes, which a restart would cut off. */
+export function countProgressingBrowserDownloads(): number {
+ let count = 0
+ for (const active of activeBrowserDownloads) {
+ if (active.download.state === 'progressing') count += 1
+ }
+ return count
+}
+
function withBrowserDownloadTimeout(
operation: Promise,
timeoutMs: number,
@@ -1066,6 +1145,47 @@ export function initSession(
getMainWindow = mainWindowProvider
browserSessionPersistence = persistence ?? null
browserDownloadSettings = downloadSettings ?? null
+ setPagePromptHost({
+ tabContext: (contents) => {
+ const scoped = scopedTabForContents(contents)
+ if (!scoped) return null
+ const { scopeId, tab } = scoped
+ const onScreen = tabIsOnScreen(scopeId, tab)
+ const agentActedLast =
+ isDispatchingAgentInput(contents) ||
+ (tab.lastAgentInputAt !== undefined &&
+ tab.lastAgentInputAt >= (tab.lastUserActivationAt ?? 0) &&
+ withinWindow(tab.lastAgentInputAt, USER_ACTIVATION_WINDOW_MS))
+ const userAt = tab.lastUserActivationAt
+ return {
+ scopeId,
+ promptsEnabled: browserScopeStates.get(scopeId)?.pagePromptsEnabled === true,
+ onScreen,
+ userActivated:
+ onScreen &&
+ userAt !== undefined &&
+ userAt > Math.max(tab.documentCommittedAt ?? 0, tab.lastAgentInputAt ?? 0) &&
+ withinWindow(userAt, USER_ACTIVATION_WINDOW_MS),
+ agentActedLast,
+ agentDrives: withBrowserScope(scopeId, () => agentOwnsPopupFrom(contents)),
+ }
+ },
+ publish: (contents) => {
+ const scopeId = browserScopeIdForContents(contents)
+ if (scopeId) withBrowserScope(scopeId, () => events?.onPageStateChanged(contents))
+ },
+ focus: (contents) => {
+ const scoped = scopedTabForContents(contents)
+ const win = panelWindow()
+ if (scoped && tabIsOnScreen(scoped.scopeId, scoped.tab) && win && !win.isDestroyed()) {
+ win.webContents.focus()
+ }
+ },
+ noticeForAgent: (contents, notice) => {
+ const scopeId = browserScopeIdForContents(contents)
+ if (scopeId) withBrowserScope(scopeId, () => events?.onAgentNotice?.(notice))
+ },
+ })
initPanel({
onGeometryChanged: () => events?.onPanelGeometryChanged?.(),
getMainWindow: () => getMainWindow(),
@@ -1457,16 +1577,22 @@ function scopedTabForContents(contents: WebContents): { scopeId: string; tab: Ag
return tab ? { scopeId, tab } : null
}
-function mediaRequestIsUserInitiated(scopeId: string, tab: AgentTab): boolean {
- const win = panelWindow()
+function tabIsOnScreen(scopeId: string, tab: AgentTab): boolean {
return (
resolveBrowserScopeId(scopeId) === getActiveBrowserScopeId() &&
browserScopeStates.get(scopeId)?.activeTabId === tab.id &&
- isPanelVisible() &&
+ isPanelVisible()
+ )
+}
+
+function mediaRequestIsUserInitiated(scopeId: string, tab: AgentTab): boolean {
+ const win = panelWindow()
+ return (
+ tabIsOnScreen(scopeId, tab) &&
Boolean(win && !win.isDestroyed() && win.isFocused()) &&
tab.view.webContents.isFocused() &&
typeof tab.lastRealUserGestureAt === 'number' &&
- Date.now() - tab.lastRealUserGestureAt <= MEDIA_PERMISSION_GESTURE_WINDOW_MS
+ withinWindow(tab.lastRealUserGestureAt, MEDIA_PERMISSION_GESTURE_WINDOW_MS)
)
}
@@ -1602,6 +1728,12 @@ const browserPermissions: BrowserPermissionHandlers = {
withBrowserScope(scoped.scopeId, () => publishPageIssue(scoped.tab))
return
}
+ // Blink asks only after a click or key, and the view keeps the window as it
+ // is; Electron never settles a refused request, blocking every later one.
+ if (permission === 'fullscreen') {
+ callback(Boolean(scopedTabForContents(contents)))
+ return
+ }
callback(ALLOWED_SITE_PERMISSIONS.has(permission))
},
check: (contents, permission, requestingOrigin, details) => {
@@ -1649,6 +1781,7 @@ function configureAgentPartition(ses: Session): void {
if (configuredPartitions.has(ses)) return
configuredPartitions.add(ses)
keepSessionCookiesAcrossRestarts(ses)
+ configureBrowserPasskeys(ses)
ses.setPermissionRequestHandler(browserPermissions.request)
ses.setPermissionCheckHandler(browserPermissions.check)
ses.webRequest.onBeforeRequest((details, callback) => {
@@ -1657,6 +1790,16 @@ function configureAgentPartition(ses: Session): void {
configureBrowserDownloads(ses)
}
+/**
+ * Passkey account choice for browser tabs. The borrowed app session gets the
+ * listener too, so the handler cancels requests from any non-browser contents.
+ */
+function configureBrowserPasskeys(ses: Session): void {
+ if (configuredPasskeySessions.has(ses)) return
+ configuredPasskeySessions.add(ses)
+ ses.on('select-webauthn-account', handleSelectPasskeyAccount)
+}
+
/** Borrowing app authentication must not replace its permission or request handlers. */
function configureBrowserDownloads(ses: Session): void {
if (configuredDownloadSessions.has(ses)) return
@@ -1891,6 +2034,11 @@ export function enablePageDialogs(): void {
currentScope.pageDialogsEnabled = true
}
+/** Lets this scope's renderer show sign-in and app-launch prompts from now on. */
+export function enablePagePrompts(): void {
+ currentScope.pagePromptsEnabled = true
+}
+
/**
* Whether a dialog on this tab is the user's to answer: the renderer can show
* it, the page is on screen, and it is the user's page rather than the agent's.
@@ -2053,6 +2201,7 @@ export function recordPageLoadFailure(
contents: WebContents,
issue: Extract
): void {
+ noteMainFrameNavigation(contents, null)
if ((issue.code === -2 || issue.code === -3) && routedNavigations.has(contents)) return
const tab = tabForContents(contents)
if (!tab) return
@@ -2174,6 +2323,38 @@ function hardReloadPage(contents: WebContents): void {
contents.reloadIgnoringCache()
}
+/** A browser-chrome history move or reload, held for the page's leave-site question. */
+function userNavigate(contents: WebContents, kind: PageNavigation): void {
+ navigateForUser(contents, () =>
+ kind === 'back'
+ ? goBack(contents)
+ : kind === 'forward'
+ ? goForward(contents)
+ : reloadPage(contents)
+ )
+}
+
+/** Moves one tab's history, or selects a tab relative to it in the strip. */
+function runTabShortcut(
+ tab: AgentTab,
+ shortcut: 'back' | 'forward' | ResourceTabSelectionShortcut
+): void {
+ if (shortcut === 'back' || shortcut === 'forward') {
+ userNavigate(tab.view.webContents, shortcut)
+ return
+ }
+ const targetIndex = resourceTabTargetIndex(
+ shortcut,
+ tabs.length,
+ tabs.findIndex((entry) => entry.id === tab.id)
+ )
+ const target = targetIndex === null ? null : tabs[targetIndex]
+ if (target) {
+ switchTab(target.id)
+ target.view.webContents.focus()
+ }
+}
+
/** Hands one page selection to the exact app window and chat hosting its tab. */
function addPageSelectionToChat(contents: WebContents, text: string): void {
if (!text.trim() || getBrowserScopeId() !== getActiveBrowserScopeId()) return
@@ -2292,12 +2473,11 @@ export function stopFindInActiveTab(focusPage: boolean): void {
if (tab) tab.view.webContents.focus()
}
-/**
- * Whether a page's popup can become a tab that keeps its opener: http(s), within the tab
- * limits, and in the opener's own session (a session boundary needs a separate, opener-less tab).
- */
+/** Whether a popup can become an opener-keeping tab: blank (Chromium puts it in the opener's session) or same-session http(s), within the tab limits. */
function canAdoptPopup(opener: WebContents, url: string): boolean {
- if (!/^https?:\/\//i.test(url) || !hasTabCapacity()) return false
+ if (!hasTabCapacity()) return false
+ if (isBlankPopupUrl(url)) return true
+ if (!/^https?:\/\//i.test(url)) return false
return (
!browserAppSession ||
isAppOrigin(url, browserAppSession.origin) === Boolean(agentAppOrigin(opener))
@@ -2309,14 +2489,21 @@ function adoptPopupTab(
opener: WebContents,
url: string,
popup: PopupWindowOptions,
- { agentOwned, background }: { agentOwned: boolean; background: boolean }
+ {
+ agentOwned,
+ background,
+ referrer,
+ }: { agentOwned: boolean; background: boolean; referrer?: Referrer }
): WebContents {
const openerTabId = tabForContents(opener)?.id
- const tab = openLinkTab(url, { agentOwned, background }, popup)
+ const tab = openLinkTab(url, { agentOwned, background }, { opener, options: popup })
tab.openerTabId = openerTabId
+ tab.returnToOpenerOnClose = true
const contents = tab.view.webContents
// A background-tab disposition defers creation, so Chromium supplies no contents to adopt.
- if (!popup.webContents) void contents.loadURL(url).catch(() => {})
+ if (!popup.webContents && !isBlankPopupUrl(url)) {
+ void contents.loadURL(url, { httpReferrer: referrer }).catch(() => {})
+ }
return contents
}
@@ -2327,7 +2514,7 @@ function adoptPopupTab(
function openLinkTab(
url: string,
{ agentOwned, background }: { agentOwned: boolean; background: boolean },
- popup?: PopupWindowOptions
+ popup?: AdoptedPopup
): AgentTab {
if (agentOwned) return addAutomationTab(url, popup)
if (background) {
@@ -2347,12 +2534,16 @@ function openLinkTab(
*/
function openTabWithUrl(
url: string,
- { agentOwned, background = false }: { agentOwned: boolean; background?: boolean }
+ {
+ agentOwned,
+ background = false,
+ referrer,
+ }: { agentOwned: boolean; background?: boolean; referrer?: Referrer }
): void {
- if (!/^https?:\/\//i.test(url)) return
+ if (!parseHttpUrl(url)) return
try {
const tab = openLinkTab(url, { agentOwned, background })
- void tab.view.webContents.loadURL(url).catch(() => {})
+ void tab.view.webContents.loadURL(url, { httpReferrer: referrer }).catch(() => {})
} catch (error) {
logger.warn('Could not open a link in a new browser tab', {
error: getErrorMessage(error),
@@ -2366,18 +2557,33 @@ function openTabWithUrl(
*/
type PopupWindowOptions = BrowserWindowConstructorOptions & WebContentsViewConstructorOptions
+/** A page-opened window becoming a tab, with the page that opened it. */
+interface AdoptedPopup {
+ opener: WebContents
+ options: PopupWindowOptions
+}
+
/**
* Creates a tab's view. `popup` adopts a page-opened window's WebContents, which Chromium created
* with the opener's (identical) web preferences, so the opener relationship and its session stay
- * intact; callers only adopt popups whose URL belongs to the opener's session.
+ * intact; a deferred popup without one gets a fresh view in the opener's session. Callers only
+ * adopt popups that are blank or whose URL belongs to the opener's session.
*/
-function createTabView(url?: string, popup?: PopupWindowOptions): WebContentsView {
- const appSession =
- url && browserAppSession && isAppOrigin(url, browserAppSession.origin)
- ? browserAppSession
- : undefined
+function createTabView(url?: string, popup?: AdoptedPopup): WebContentsView {
+ let appSession: BrowserAppSession | undefined
+ if (popup) {
+ // A popup follows its opener's session (Chromium creates it there); a blank URL names no origin.
+ const popupSession = popup.options.webContents?.session ?? popup.opener.session
+ if (browserAppSession && popupSession === browserAppSession.session) {
+ appSession = browserAppSession
+ }
+ } else if (url && browserAppSession && isAppOrigin(url, browserAppSession.origin)) {
+ appSession = browserAppSession
+ }
const scopeId = getBrowserScopeId()
- const view = popup?.webContents ? new WebContentsView(popup) : createFreshTabView(appSession)
+ const view = popup?.options.webContents
+ ? new WebContentsView(popup.options)
+ : createFreshTabView(appSession)
try {
/** Detached tabs must lay out before a foreground panel owns their native view. */
const [width, height] = getMainWindow()?.getContentSize() ?? [1280, 720]
@@ -2389,6 +2595,19 @@ function createTabView(url?: string, popup?: PopupWindowOptions): WebContentsVie
}
}
+/**
+ * Preferences every tab needs, popups Chromium creates included.
+ * `focusOnNavigation`: Electron focuses a page on every main-frame commit, even
+ * a hidden agent tab, which steals the caret from chat; focus follows explicit
+ * user actions instead (see focusPageForUser).
+ * `disableHtmlFullscreenWindowResize`: a page's fullscreen fills the browser
+ * panel, never the Sim window.
+ */
+const AGENT_TAB_WEB_PREFERENCES = {
+ focusOnNavigation: false,
+ disableHtmlFullscreenWindowResize: true,
+} as const
+
function createFreshTabView(appSession: BrowserAppSession | undefined): WebContentsView {
return new WebContentsView({
webPreferences: {
@@ -2409,11 +2628,9 @@ function createFreshTabView(appSession: BrowserAppSession | undefined): WebConte
// the active tab while a tool waits on it, applied explicitly by
// applyAutomationTabPolicy — never blanket across every tab.
backgroundThrottling: true,
- // Electron focuses a page on every main-frame commit, even a hidden
- // agent tab, which steals the caret from chat. Focus follows explicit
- // user actions instead (see focusPageForUser).
- focusOnNavigation: false,
- spellcheck: false,
+ ...AGENT_TAB_WEB_PREFERENCES,
+ // Off elsewhere: Chromium would download Hunspell dictionaries.
+ spellcheck: process.platform === 'darwin',
// The default every origin this tab visits starts at; a per-origin zoom
// the user sets from the page menu still wins and still persists.
zoomFactor: getBrowserDefaultZoomFactor(),
@@ -2429,8 +2646,10 @@ function initializeTabView(
view.setBackgroundColor(browserBackgroundColor())
const contents = view.webContents
registerAgentWebContents(contents, appOrigin, browserPermissions)
- if (appOrigin) configureBrowserDownloads(contents.session)
- else configureAgentPartition(contents.session)
+ if (appOrigin) {
+ configureBrowserDownloads(contents.session)
+ configureBrowserPasskeys(contents.session)
+ } else configureAgentPartition(contents.session)
const routeNavigation = (url: string, method: string): boolean => {
if (!/^https?:\/\//i.test(url) || !browserAppSession) return false
const wantsAppSession = isAppOrigin(url, browserAppSession.origin)
@@ -2457,10 +2676,27 @@ function initializeTabView(
registerAgentNavigation(contents, routeNavigation)
attachAgentContextMenu(contents, {
addToChat: (text) => withBrowserScope(scopeId, () => addPageSelectionToChat(contents, text)),
- openTab: (url) =>
- withBrowserScope(scopeId, () => openTabWithUrl(url, { agentOwned: false, background: true })),
+ openTab: (url, referrer) =>
+ withBrowserScope(scopeId, () =>
+ openTabWithUrl(url, { agentOwned: false, background: true, referrer })
+ ),
defaultZoomFactor: getBrowserDefaultZoomFactor,
+ history: () =>
+ withBrowserScope(scopeId, () => ({
+ canGoBack: canGoBack(contents),
+ canGoForward: canGoForward(contents),
+ })),
+ navigate: (kind) => withBrowserScope(scopeId, () => userNavigate(contents, kind)),
})
+ // Chromium leaves Control-wheel zoom to the embedder; step the page menu's ladder.
+ contents.on(
+ 'zoom-changed',
+ bindToBrowserScope(scopeId, (_event, direction) => {
+ contents.setZoomFactor(
+ steppedZoomFactor(contents.getZoomFactor(), direction === 'in' ? 1 : -1)
+ )
+ })
+ )
contents.on(
'focus',
@@ -2507,17 +2743,19 @@ function initializeTabView(
contents.on(
'before-mouse-event',
bindToBrowserScope(scopeId, (_event, mouse) => {
- if (
- isDispatchingAgentInput(contents) ||
- !['mouseDown', 'contextMenu', 'mouseWheel'].includes(mouse.type)
- ) {
+ if (!['mouseDown', 'contextMenu', 'mouseWheel'].includes(mouse.type)) return
+ const tab = tabs.find((entry) => entry.view.webContents === contents)
+ if (isDispatchingAgentInput(contents)) {
+ if (tab && mouse.type === 'mouseDown') tab.lastAgentInputAt = Date.now()
return
}
- const tab = tabs.find((entry) => entry.view.webContents === contents)
if (tab && tab.id === currentScope.automationTabId) noteUserIntervention(tab.id)
if (tab?.id === currentScope.activeTabId) {
currentScope.visibleTabUserSelected = true
- if (mouse.type === 'mouseDown') tab.lastRealUserGestureAt = Date.now()
+ if (mouse.type === 'mouseDown') {
+ tab.lastRealUserGestureAt = Date.now()
+ tab.lastUserActivationAt = tab.lastRealUserGestureAt
+ }
}
})
)
@@ -2544,27 +2782,34 @@ function initializeTabView(
})
)
- // Keep popups inside the browser resource: http(s) window.open and
- // target=_blank requests become a new internal tab, never a native window.
- // A same-session popup adopts Chromium's own WebContents so window.opener
- // survives (sign-in and "connect" popups post their result back to it).
+ // Popups become internal tabs, never native windows; adopting Chromium's own
+ // WebContents keeps window.opener for sign-in popups that post back to it.
contents.setWindowOpenHandler((details) =>
withBrowserScope(scopeId, () => {
const agentOwned = agentOwnsPopupFrom(contents)
const background = details.disposition === 'background-tab'
- if (!canAdoptPopup(contents, details.url)) {
- openTabWithUrl(details.url, { agentOwned, background })
+ const adopt = canAdoptPopup(contents, details.url)
+ // Only Chromium can carry a form POST with its initiator; never reissue one as a GET.
+ if (details.postBody && (!adopt || background)) {
+ logger.warn('Refused a form POST into a new browser tab')
+ return { action: 'deny' }
+ }
+ if (!adopt) {
+ openTabWithUrl(details.url, { agentOwned, background, referrer: details.referrer })
return { action: 'deny' }
}
return {
action: 'allow',
outlivesOpener: true,
- // A popup's contents are created by Chromium from these preferences, not
- // from createFreshTabView, so they must opt out of navigation focus here.
- overrideBrowserWindowOptions: { webPreferences: { focusOnNavigation: false } },
+ // Chromium creates a popup's contents from these, not from createFreshTabView.
+ overrideBrowserWindowOptions: { webPreferences: AGENT_TAB_WEB_PREFERENCES },
createWindow: (options) =>
withBrowserScope(scopeId, () =>
- adoptPopupTab(contents, details.url, options, { agentOwned, background })
+ adoptPopupTab(contents, details.url, options, {
+ agentOwned,
+ background,
+ referrer: details.referrer,
+ })
),
}
})
@@ -2595,6 +2840,7 @@ function initializeTabView(
if (!tab) return
tab.nativePageDialogOpen = false
invalidatePageDialog(tab)
+ cancelPagePrompts(contents)
if (tab.recoveringUnresponsive) {
tab.recoveringUnresponsive = false
contents.reload()
@@ -2639,20 +2885,35 @@ function initializeTabView(
'before-input-event',
bindToBrowserScope(scopeId, (event, input) => {
const tab = tabs.find((entry) => entry.view === view)
+ const agentInput = isDispatchingAgentInput(contents)
+ if (agentInput && input.type === 'keyDown' && tab) tab.lastAgentInputAt = Date.now()
if (
- !isDispatchingAgentInput(contents) &&
+ !agentInput &&
input.type === 'keyDown' &&
tab &&
tab.id === currentScope.automationTabId
) {
noteUserIntervention(tab.id)
}
- if (!isDispatchingAgentInput(contents) && tab?.id === currentScope.activeTabId) {
+ if (!agentInput && tab?.id === currentScope.activeTabId) {
currentScope.visibleTabUserSelected = true
- if (input.type === 'keyDown' && !input.isAutoRepeat) tab.lastRealUserGestureAt = Date.now()
+ if (input.type === 'keyDown' && !input.isAutoRepeat) {
+ tab.lastRealUserGestureAt = Date.now()
+ tab.lastUserActivationAt = tab.lastRealUserGestureAt
+ }
}
const shortcut = browserShortcutForInput(input)
if (!shortcut) return
+ if (isUserOnlyShortcut(shortcut)) {
+ if (agentInput || !tab) return
+ if (shortcut === 'stop') {
+ if (contents.isLoadingMainFrame()) contents.stop()
+ return
+ }
+ event.preventDefault()
+ runTabShortcut(tab, shortcut)
+ return
+ }
event.preventDefault()
if (shortcut === 'focus-omnibox') {
@@ -2669,7 +2930,7 @@ function initializeTabView(
return
}
if (shortcut === 'reload') {
- navigateForUser(contents, () => reloadPage(contents))
+ userNavigate(contents, 'reload')
return
}
if (shortcut === 'hard-reload') {
@@ -2721,14 +2982,30 @@ function initializeTabView(
'did-navigate',
bindToBrowserScope(scopeId, () => {
const tab = tabs.find((entry) => entry.view.webContents === contents)
- if (tab) tab.pendingRestoreUrl = undefined
+ if (tab) {
+ tab.pendingRestoreUrl = undefined
+ tab.documentCommittedAt = Date.now()
+ }
+ noteMainFrameNavigation(contents, null)
persistBrowserSession()
})
)
+ contents.on('did-redirect-navigation', (details) => {
+ if (details.isMainFrame && !details.isSameDocument)
+ noteMainFrameNavigation(contents, details.url)
+ })
+ // An aborted navigation (ERR_ABORTED) reports no failure the driver records; once nothing is
+ // loading, no top-level request is pending, so a later frame cannot pass for it.
+ contents.on('did-stop-loading', () => noteMainFrameNavigation(contents, null))
+
contents.on(
'did-start-navigation',
bindToBrowserScope(scopeId, (details) => {
if (!details.isMainFrame) return
+ if (!details.isSameDocument) {
+ cancelPagePrompts(contents)
+ noteMainFrameNavigation(contents, details.url)
+ }
const tab = tabForContents(contents)
if (!tab) return
invalidatePageDialog(tab)
@@ -2773,6 +3050,7 @@ function initializeTabView(
// the view's contents, so remove it without touching them.
const tab = tabs.find((entry) => entry.view === view)
if (tab) removeTab(tab, null)
+ cancelPagePrompts(contents)
events?.onTabClosed(contents)
})
)
@@ -2934,7 +3212,7 @@ interface AddTabOptions {
activate?: boolean
notify?: boolean
url?: string
- popup?: PopupWindowOptions
+ popup?: AdoptedPopup
}
function addTabInternal({
@@ -3415,7 +3693,7 @@ export function restoreBrowserSession(): void {
}
}
-export function addTab(url?: string, popup?: PopupWindowOptions): AgentTab {
+export function addTab(url?: string, popup?: AdoptedPopup): AgentTab {
restoreBrowserSession()
currentScope.visibleTabUserSelected = true
return addTabInternal({ url, popup })
@@ -3426,7 +3704,7 @@ export function addTab(url?: string, popup?: PopupWindowOptions): AgentTab {
* renderer's decision: every page is a resource tab there, and it shows the
* agent's tab or badges it depending on what the user is doing.
*/
-export function addAutomationTab(url?: string, popup?: PopupWindowOptions): AgentTab {
+export function addAutomationTab(url?: string, popup?: AdoptedPopup): AgentTab {
restoreBrowserSession()
const tab = addTabInternal({ activate: false, notify: false, url, popup })
currentScope.automationTabId = tab.id
@@ -3500,6 +3778,7 @@ export function switchTab(tabId: string, { claim = true }: { claim?: boolean } =
if (previousActiveTab && previousActiveTab.id !== tab.id) {
revokeTabMediaPermissions(previousActiveTab, false)
previousActiveTab.pendingUserFocus = false
+ for (const entry of tabs) entry.returnToOpenerOnClose = false
}
// The claim describes the page on screen, so a mirrored switch to another
// page leaves that page unclaimed rather than inheriting the last one's.
@@ -3594,8 +3873,10 @@ function removeTab(
clearFocusedBrowserTab(tab.id)
detachIfAttached(tab.view)
if (contents && !contents.isDestroyed()) contents.close()
+ const opener = tabs.find((entry) => entry.id === tab.openerTabId)
if (currentScope.activeTabId === tab.id) {
- currentScope.activeTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null
+ const returnTo = tab.returnToOpenerOnClose ? opener : undefined
+ currentScope.activeTabId = (returnTo ?? tabs[index] ?? tabs[index - 1])?.id ?? null
layout()
const active = activeTab()
if (active) {
@@ -3603,7 +3884,6 @@ function removeTab(
}
}
if (currentScope.automationTabId === tab.id) {
- const opener = tabs.find((entry) => entry.id === tab.openerTabId)
currentScope.automationTabId =
opener?.id ?? (adoptNeighborForAgent ? ((tabs[index] ?? tabs[index - 1])?.id ?? null) : null)
applyAutomationTabPolicy()
@@ -3659,6 +3939,8 @@ export function handleFocusedShortcut(
shortcut: FocusedResourceShortcut,
ownerWindow?: BrowserWindow | null
): boolean {
+ // Accelerators and mouse buttons arrive before any chat has opened a browser.
+ if (!getActiveBrowserScopeId()) return false
// Tab-management accelerators belong to the visible Browser even after
// focus moves into Sim chrome. Otherwise Cmd-T/L/Shift-T silently stop
// behaving like browser shortcuts, and Cmd-W becomes especially unsafe.
@@ -3672,17 +3954,8 @@ export function handleFocusedShortcut(
(visibleBrowserShortcut ? visibleActiveTab(ownerWindow) : null)
if (!shortcutTab) return false
- if (isResourceTabSelectionShortcut(shortcut)) {
- const targetIndex = resourceTabTargetIndex(
- shortcut,
- tabs.length,
- tabs.findIndex((tab) => tab.id === shortcutTab.id)
- )
- const target = targetIndex === null ? null : tabs[targetIndex]
- if (target) {
- switchTab(target.id)
- target.view.webContents.focus()
- }
+ if (isResourceTabSelectionShortcut(shortcut) || shortcut === 'back' || shortcut === 'forward') {
+ runTabShortcut(shortcutTab, shortcut)
return true
}
@@ -3702,26 +3975,14 @@ export function handleFocusedShortcut(
case 'focus-omnibox':
focusRendererOmnibox('select')
return true
- case 'reload-or-clear': {
- const contents = shortcutTab.view.webContents
- navigateForUser(contents, () => reloadPage(contents))
+ case 'reload-or-clear':
+ userNavigate(shortcutTab.view.webContents, 'reload')
return true
- }
case 'hard-reload': {
const contents = shortcutTab.view.webContents
navigateForUser(contents, () => hardReloadPage(contents))
return true
}
- case 'back': {
- const contents = shortcutTab.view.webContents
- navigateForUser(contents, () => goBack(contents))
- return true
- }
- case 'forward': {
- const contents = shortcutTab.view.webContents
- navigateForUser(contents, () => goForward(contents))
- return true
- }
}
const zoomAction = zoomActionForShortcut(shortcut)
@@ -3885,6 +4146,17 @@ export async function clearProfileStorage(): Promise {
// allowlist would silently miss whatever Chromium adds next.
await ses.clearStorageData()
await ses.clearCache()
+ await clearBrowserSignIns(ses)
+}
+
+/**
+ * Sign-out forgets every HTTP credential Chromium cached for browser tabs, in the
+ * borrowed app session too, and the remembered certificate and app-launch choices.
+ */
+async function clearBrowserSignIns(agentSession: Session): Promise {
+ resetPagePromptDecisions()
+ await agentSession.clearAuthCache()
+ await browserAppSession?.session.clearAuthCache()
}
/**
@@ -3918,6 +4190,10 @@ export async function clearAgentData(kinds: readonly BrowserDataKind[]): Promise
if (storages.length > 0) {
await ses.clearStorageData({ storages } as Parameters[0])
}
+ if (kinds.includes('cookies')) {
+ await ses.clearAuthCache()
+ resetPagePromptDecisions(ses)
+ }
if (kinds.includes('cache')) {
await ses.clearCache()
// Resolved-host verdicts are a cache too, and a user clearing the cache
diff --git a/apps/desktop/src/main/browser-import/browser-launch.ts b/apps/desktop/src/main/browser-import/browser-launch.ts
new file mode 100644
index 00000000000..120a4332de9
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/browser-launch.ts
@@ -0,0 +1,25 @@
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+
+const execFileAsync = promisify(execFile)
+
+const OPEN_TIMEOUT_MS = 15_000
+
+/**
+ * Opens `url` in one profile of an installed Chromium-family browser.
+ *
+ * `-n` launches a fresh instance whose command line Chromium's process
+ * singleton forwards to the running browser; without it a running browser
+ * drops `--args` and opens the page in whichever profile was last active.
+ */
+export async function openInBrowserProfile(
+ bundleId: string,
+ profileDirectory: string,
+ url: string
+): Promise {
+ await execFileAsync(
+ '/usr/bin/open',
+ ['-n', '-b', bundleId, '--args', `--profile-directory=${profileDirectory}`, url],
+ { timeout: OPEN_TIMEOUT_MS }
+ )
+}
diff --git a/apps/desktop/src/main/browser-import/browser-sources.ts b/apps/desktop/src/main/browser-import/browser-sources.ts
index 7d65b37ca99..fa4c100f5bf 100644
--- a/apps/desktop/src/main/browser-import/browser-sources.ts
+++ b/apps/desktop/src/main/browser-import/browser-sources.ts
@@ -26,6 +26,8 @@ export interface BrowserSource {
userDataSegments: readonly string[]
/** The login-Keychain generic-password item holding the Safe Storage key. */
keychain: { service: string; account: string }
+ /** The macOS bundle identifier, used to open a page in this browser. */
+ bundleId: string
}
export const BROWSER_SOURCES: readonly BrowserSource[] = [
@@ -34,42 +36,49 @@ export const BROWSER_SOURCES: readonly BrowserSource[] = [
label: 'Chrome',
userDataSegments: ['Library', 'Application Support', 'Google', 'Chrome'],
keychain: { service: 'Chrome Safe Storage', account: 'Chrome' },
+ bundleId: 'com.google.Chrome',
},
{
id: 'arc',
label: 'Arc',
userDataSegments: ['Library', 'Application Support', 'Arc', 'User Data'],
keychain: { service: 'Arc Safe Storage', account: 'Arc' },
+ bundleId: 'company.thebrowser.Browser',
},
{
id: 'dia',
label: 'Dia',
userDataSegments: ['Library', 'Application Support', 'Dia', 'User Data'],
keychain: { service: 'Dia Safe Storage', account: 'Dia' },
+ bundleId: 'company.thebrowser.dia',
},
{
id: 'brave',
label: 'Brave',
userDataSegments: ['Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'],
keychain: { service: 'Brave Safe Storage', account: 'Brave' },
+ bundleId: 'com.brave.Browser',
},
{
id: 'edge',
label: 'Microsoft Edge',
userDataSegments: ['Library', 'Application Support', 'Microsoft Edge'],
keychain: { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' },
+ bundleId: 'com.microsoft.edgemac',
},
{
id: 'vivaldi',
label: 'Vivaldi',
userDataSegments: ['Library', 'Application Support', 'Vivaldi'],
keychain: { service: 'Vivaldi Safe Storage', account: 'Vivaldi' },
+ bundleId: 'com.vivaldi.Vivaldi',
},
{
id: 'chromium',
label: 'Chromium',
userDataSegments: ['Library', 'Application Support', 'Chromium'],
keychain: { service: 'Chromium Safe Storage', account: 'Chromium' },
+ bundleId: 'org.chromium.Chromium',
},
] as const
diff --git a/apps/desktop/src/main/browser-import/chromium-cookies.test.ts b/apps/desktop/src/main/browser-import/chromium-cookies.test.ts
index f3db3020989..b64db8c100d 100644
--- a/apps/desktop/src/main/browser-import/chromium-cookies.test.ts
+++ b/apps/desktop/src/main/browser-import/chromium-cookies.test.ts
@@ -123,7 +123,7 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => {
},
])
- const result = await readBrowserCookies(path, KEY, NOW_SECONDS)
+ const result = await readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS })
expect(result.rowsSeen).toBe(1)
expect(result.cookies).toHaveLength(1)
@@ -150,7 +150,7 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => {
},
])
- const [cookie] = (await readBrowserCookies(path, KEY, NOW_SECONDS)).cookies
+ const [cookie] = (await readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS })).cookies
expect(cookie.expirationDate).toBeCloseTo(NOW_SECONDS + 86_400, 3)
})
@@ -163,7 +163,9 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => {
{ hostKey: '.example.com', name: 'a', encryptedValue: encryptV10(plaintext) },
])
- expect((await readBrowserCookies(path, KEY, NOW_SECONDS)).cookies[0].value).toBe('bound')
+ expect(
+ (await readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS })).cookies[0].value
+ ).toBe('bound')
})
it('counts unreadable and expired rows instead of failing the import', async () => {
@@ -178,7 +180,7 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => {
},
])
- const result = await readBrowserCookies(path, KEY, NOW_SECONDS)
+ const result = await readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS })
expect(result.rowsSeen).toBe(3)
expect(result.cookies.map(({ name }) => name)).toEqual(['good'])
@@ -186,6 +188,37 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => {
expect(result.skipped.expired).toBe(1)
})
+ it('reads only in-scope rows, never decrypting or counting another site', async () => {
+ const path = await writeCookieDatabase([
+ { hostKey: '.example.com', name: 'domain', encryptedValue: encryptV10(Buffer.from('a')) },
+ {
+ hostKey: 'Console.Example.com',
+ name: 'host',
+ encryptedValue: encryptV10(Buffer.from('b')),
+ },
+ {
+ hostKey: 'notexample.com',
+ name: 'lookalike',
+ encryptedValue: encryptV10(Buffer.from('c')),
+ },
+ {
+ hostKey: 'example.com.evil.com',
+ name: 'prefix',
+ encryptedValue: encryptV10(Buffer.from('d')),
+ },
+ { hostKey: 'evil.com', name: 'broken', encryptedValue: Buffer.from('v20-not-supported') },
+ ])
+
+ const result = await readBrowserCookies(path, KEY, {
+ nowSeconds: NOW_SECONDS,
+ scope: { domain: 'example.com', exact: false },
+ })
+
+ expect(result.cookies.map(({ name }) => name).sort()).toEqual(['domain', 'host'])
+ expect(result.rowsSeen).toBe(2)
+ expect(result.skipped['decrypt-failed']).toBe(0)
+ })
+
it('reports an unrecognised schema rather than guessing', async () => {
const path = join(directory, 'Cookies')
const { DatabaseSync } = await import('node:sqlite')
@@ -193,7 +226,7 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => {
database.exec('CREATE TABLE something_else (a TEXT)')
database.close()
- await expect(readBrowserCookies(path, KEY, NOW_SECONDS)).rejects.toMatchObject({
+ await expect(readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS })).rejects.toMatchObject({
code: 'unsupported-schema',
})
})
@@ -204,7 +237,7 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => {
])
const before = await stat(path)
- await readBrowserCookies(path, KEY, NOW_SECONDS)
+ await readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS })
const after = await stat(path)
expect(after.size).toBe(before.size)
@@ -217,7 +250,7 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => {
{ hostKey: 'example.com', name: 'a', encryptedValue: encryptV10(Buffer.from('v')) },
])
- await readBrowserCookies(path, KEY, NOW_SECONDS)
+ await readBrowserCookies(path, KEY, { nowSeconds: NOW_SECONDS })
expect(await stagingLeftBehindSince(before)).toEqual([])
})
@@ -226,7 +259,9 @@ describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => {
const before = await stagingDirectories()
await writeFile(join(directory, 'Cookies'), 'not a database')
- await expect(readBrowserCookies(join(directory, 'Cookies'), KEY, NOW_SECONDS)).rejects.toThrow()
+ await expect(
+ readBrowserCookies(join(directory, 'Cookies'), KEY, { nowSeconds: NOW_SECONDS })
+ ).rejects.toThrow()
expect(await stagingLeftBehindSince(before)).toEqual([])
})
diff --git a/apps/desktop/src/main/browser-import/chromium-cookies.ts b/apps/desktop/src/main/browser-import/chromium-cookies.ts
index 74e79b6cd0b..18e74676f57 100644
--- a/apps/desktop/src/main/browser-import/chromium-cookies.ts
+++ b/apps/desktop/src/main/browser-import/chromium-cookies.ts
@@ -1,5 +1,6 @@
import { decryptChromiumValue } from '@/main/browser-import/chromium-crypto'
import { translateCookieRow } from '@/main/browser-import/cookie-translate'
+import { isCookieHostInScope, type SiteScope } from '@/main/browser-import/site-scope'
import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source'
import {
type ChromiumCookieRow,
@@ -17,12 +18,35 @@ import {
const MAX_COOKIE_ROWS = 50_000
-const COOKIE_QUERY = `
+const COOKIE_COLUMNS = `
SELECT host_key, name, path, expires_utc, is_secure, is_httponly,
has_expires, is_persistent, samesite, encrypted_value, value
- FROM cookies
- LIMIT ${MAX_COOKIE_ROWS}
-`
+ FROM cookies`
+
+/** `%`, `_`, and the escape itself are LIKE metacharacters; a hostname may carry `_`. */
+function escapeLike(value: string): string {
+ return value.replace(/[\\%_]/g, '\\$&')
+}
+
+/**
+ * The cookie query, narrowed in SQL to a site's rows when there is a scope.
+ * The narrowing is only an efficiency: {@link isCookieHostInScope} still
+ * decides every row, since LIKE is case-insensitive and knows no labels.
+ */
+function cookieQuery(scope: SiteScope | undefined): { query: string; params: string[] } {
+ if (!scope) return { query: `${COOKIE_COLUMNS} LIMIT ${MAX_COOKIE_ROWS}`, params: [] }
+ const exactHosts = [scope.domain, `.${scope.domain}`]
+ if (scope.exact) {
+ return {
+ query: `${COOKIE_COLUMNS} WHERE host_key IN (?, ?) LIMIT ${MAX_COOKIE_ROWS}`,
+ params: exactHosts,
+ }
+ }
+ return {
+ query: `${COOKIE_COLUMNS} WHERE host_key IN (?, ?) OR host_key LIKE ? ESCAPE '\\' LIMIT ${MAX_COOKIE_ROWS}`,
+ params: [...exactHosts, `%.${escapeLike(scope.domain)}`],
+ }
+}
export interface ReadCookiesResult {
cookies: ImportableCookie[]
@@ -45,6 +69,12 @@ function toRow(raw: Record): ChromiumCookieRow {
}
}
+interface ReadCookiesOptions {
+ nowSeconds?: number
+ /** Limits the read to one site; other rows are never decrypted, returned, or counted. */
+ scope?: SiteScope
+}
+
/**
* Rows that fail to decrypt or cannot be aimed at a single host are counted,
* not thrown — one unreadable cookie should not cost the user the other
@@ -53,14 +83,18 @@ function toRow(raw: Record): ChromiumCookieRow {
export async function readBrowserCookies(
cookiesPath: string,
key: Buffer,
- nowSeconds: number = Date.now() / 1000
+ { nowSeconds = Date.now() / 1000, scope }: ReadCookiesOptions = {}
): Promise {
- const rows = await queryBrowserDatabase(cookiesPath, 'Cookies', COOKIE_QUERY)
+ const { query, params } = cookieQuery(scope)
+ const rows = await queryBrowserDatabase(cookiesPath, 'Cookies', query, params)
const cookies: ImportableCookie[] = []
const skipped = emptySkipCounts()
+ let rowsSeen = 0
for (const raw of rows) {
const row = toRow(raw)
+ if (scope && !isCookieHostInScope(row.hostKey, scope)) continue
+ rowsSeen += 1
const encrypted = raw.encrypted_value
let value: string | null
if (encrypted instanceof Uint8Array && encrypted.length > 0) {
@@ -82,5 +116,5 @@ export async function readBrowserCookies(
}
}
- return { cookies, skipped, rowsSeen: rows.length }
+ return { cookies, skipped, rowsSeen }
}
diff --git a/apps/desktop/src/main/browser-import/import-service.test.ts b/apps/desktop/src/main/browser-import/import-service.test.ts
index cdede9365da..d21af03eb61 100644
--- a/apps/desktop/src/main/browser-import/import-service.test.ts
+++ b/apps/desktop/src/main/browser-import/import-service.test.ts
@@ -530,3 +530,23 @@ describe('remembering the sites an import brought over', () => {
expect(readSites).toHaveBeenCalledWith('/chrome/Default/History', new Set(['example.com']))
})
})
+
+describe('importChromeCookies with a site scope', () => {
+ it('reads only the scoped site from the chosen profile', async () => {
+ const readCookies = vi.fn(async () => read())
+ const scope = { domain: 'amazon.com', exact: false }
+
+ await expect(
+ importChromeCookies('arc:Profile 2', createDeps({ readCookies }), scope)
+ ).resolves.toEqual({ cookiesImported: 1, cookiesSkipped: 0 })
+ expect(readCookies).toHaveBeenCalledWith('/arc/Profile 2/Cookies', expect.any(Buffer), scope)
+ })
+
+ it('reports nothing found, not a failure, when the profile has no sign-in for the site', async () => {
+ const deps = createDeps({ readCookies: async () => read({ cookies: [], rowsSeen: 0 }) })
+
+ await expect(
+ importChromeCookies('chrome:Default', deps, { domain: 'example.com', exact: false })
+ ).resolves.toEqual({ cookiesImported: 0, cookiesSkipped: 0 })
+ })
+})
diff --git a/apps/desktop/src/main/browser-import/import-service.ts b/apps/desktop/src/main/browser-import/import-service.ts
index 8c704361d16..e7ebaca621f 100644
--- a/apps/desktop/src/main/browser-import/import-service.ts
+++ b/apps/desktop/src/main/browser-import/import-service.ts
@@ -12,6 +12,7 @@ import type { ReadCookiesResult } from '@/main/browser-import/chromium-cookies'
import { deriveEncryptionKey } from '@/main/browser-import/chromium-crypto'
import type { ReadPasswordsResult } from '@/main/browser-import/chromium-passwords'
import type { ImportedSite } from '@/main/browser-import/chromium-site-names'
+import type { SiteScope } from '@/main/browser-import/site-scope'
import {
type BrowserProfile,
type ImportableCookie,
@@ -34,7 +35,8 @@ export interface ImportServiceDeps {
listProfiles: () => Promise
/** Reads one browser's Safe Storage key; the item comes from its source. */
readSafeStoragePassword: (item: { service: string; account: string }) => Promise
- readCookies: (cookiesPath: string, key: Buffer) => Promise
+ /** `scope` limits the read to one site; absent for a whole profile. */
+ readCookies: (cookiesPath: string, key: Buffer, scope?: SiteScope) => Promise
writeCookies: (cookies: ImportableCookie[]) => Promise<{ imported: number; failed: number }>
readPasswords: (loginDataPath: string, key: Buffer) => Promise
/** Site icons for the given origins, read from the source browser's own store. */
@@ -54,7 +56,7 @@ export interface ImportServiceDeps {
}
}
-function cookieFailure(error: BrowserImportResult['error']): BrowserImportResult {
+export function cookieFailure(error: BrowserImportResult['error']): BrowserImportResult {
return { cookiesImported: 0, cookiesSkipped: 0, error }
}
@@ -152,7 +154,8 @@ export function toDisplayProfiles(profiles: BrowserProfile[]): BrowserImportProf
}
/**
- * Copies one Chrome profile's cookies into the built-in browser.
+ * Copies one Chrome profile's cookies into the built-in browser — or, given a
+ * `scope`, only one site's: the sign-in the user just finished there.
*
* Fails closed at every step: an unsupported platform, an absent Chrome, a
* refused Keychain prompt, or an unrecognised database all stop the import
@@ -161,7 +164,8 @@ export function toDisplayProfiles(profiles: BrowserProfile[]): BrowserImportProf
*/
export async function importChromeCookies(
profileId: string | undefined,
- deps: ImportServiceDeps
+ deps: ImportServiceDeps,
+ scope?: SiteScope
): Promise {
if (deps.platform !== 'darwin') return cookieFailure('unsupported-platform')
@@ -169,7 +173,7 @@ export async function importChromeCookies(
const open = await openBrowserProfile(profileId, deps)
let outcome: CookieOutcome
try {
- outcome = await runCookieImport(open, deps)
+ outcome = await runCookieImport(open, deps, scope)
} finally {
zeroKey(open)
}
@@ -199,13 +203,14 @@ interface PasswordOutcome {
*/
async function runCookieImport(
{ profile, key }: OpenProfile,
- deps: ImportServiceDeps
+ deps: ImportServiceDeps,
+ scope?: SiteScope
): Promise {
if (profile.cookiesPath === null) {
return { result: cookieFailure('profile-unreadable'), domains: new Set() }
}
- const read = await deps.readCookies(profile.cookiesPath, key)
+ const read = await deps.readCookies(profile.cookiesPath, key, scope)
const skippedReading = totalSkipped(read.skipped)
// Rows present but nothing usable means the profile did not decrypt —
// typically a scheme this importer does not support. Report it as a
diff --git a/apps/desktop/src/main/browser-import/index.test.ts b/apps/desktop/src/main/browser-import/index.test.ts
new file mode 100644
index 00000000000..56cef1e287b
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/index.test.ts
@@ -0,0 +1,270 @@
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { BROWSER_SOURCES, type BrowserSource } from '@/main/browser-import/browser-sources'
+import type { BrowserProfile } from '@/main/browser-import/types'
+import { emptySkipCounts } from '@/main/browser-import/types'
+
+/**
+ * Continuing a sign-in elsewhere and bringing it back, against stand-ins for
+ * the only things a test must never touch: the developer's real browser
+ * profiles, their Keychain, the browser launcher, and the Electron cookie jar.
+ */
+vi.mock('electron', () => import('@/test/electron-mock'))
+vi.mock('@/main/browser-agent/session', () => ({
+ importAgentCookies: vi.fn(async (cookies: unknown[]) => ({
+ imported: cookies.length,
+ failed: 0,
+ })),
+}))
+vi.mock('@/main/browser-credentials', () => ({
+ credentialsAvailable: () => false,
+ importCredentials: vi.fn(),
+}))
+vi.mock('@/main/browser-sites', () => ({ rememberSites: vi.fn(async () => {}) }))
+vi.mock('@/main/account-data-generation', () => ({
+ captureAccountDataGeneration: () => 0,
+ runAccountDataMutation: (_generation: number, operation: () => Promise) => operation(),
+}))
+vi.mock('@/main/browser-import/browser-launch', () => ({ openInBrowserProfile: vi.fn() }))
+vi.mock('@/main/browser-import/chromium-profiles', () => ({ listAllBrowserProfiles: vi.fn() }))
+vi.mock('@/main/browser-import/chromium-cookies', () => ({ readBrowserCookies: vi.fn() }))
+vi.mock('@/main/browser-import/chromium-crypto', async (importOriginal) => ({
+ ...(await importOriginal()),
+ readSafeStoragePassword: vi.fn(async () => 'safe-storage-password'),
+}))
+
+const { openInBrowserProfile } = await import('@/main/browser-import/browser-launch')
+const { listAllBrowserProfiles } = await import('@/main/browser-import/chromium-profiles')
+const { readBrowserCookies } = await import('@/main/browser-import/chromium-cookies')
+const {
+ bringSignInBack,
+ cancelSignIn,
+ clearSignIns,
+ continueSignIn,
+ getPendingSignIn,
+ migrateSignIn,
+} = await import('@/main/browser-import')
+
+/** The importer is macOS-only; pin the platform so the suite means the same on every runner. */
+const realPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
+beforeAll(() => Object.defineProperty(process, 'platform', { value: 'darwin' }))
+afterAll(() => {
+ if (realPlatform) Object.defineProperty(process, 'platform', realPlatform)
+})
+
+/** Holds the next browser launch open until the test releases it. */
+function holdLaunch(): () => void {
+ let release: (() => void) | undefined
+ vi.mocked(openInBrowserProfile).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ release = resolve
+ })
+ )
+ return () => release?.()
+}
+
+const ARC = BROWSER_SOURCES.find(({ id }) => id === 'arc') as BrowserSource
+const PROFILE: BrowserProfile = {
+ id: 'arc:Profile 2',
+ directory: 'Profile 2',
+ label: 'Work',
+ source: ARC,
+ cookiesPath: '/arc/Profile 2/Cookies',
+ loginDataPaths: [],
+ faviconsPath: null,
+ historyPath: null,
+}
+const SIGN_IN_URL =
+ 'https://signin.aws.amazon.com/oauth?redirect_uri=https%3A%2F%2Fconsole.aws.amazon.com'
+const request = (url = SIGN_IN_URL, profileId = PROFILE.id) => ({ profileId, tabId: 'tab-1', url })
+
+function readsCookies(count: number) {
+ vi.mocked(readBrowserCookies).mockResolvedValue({
+ cookies: Array.from({ length: count }, (_, index) => ({
+ url: 'https://console.aws.amazon.com/',
+ name: `session-${index}`,
+ value: 'value',
+ path: '/',
+ secure: true,
+ httpOnly: true,
+ sameSite: 'lax' as const,
+ })),
+ skipped: emptySkipCounts(),
+ rowsSeen: count,
+ })
+}
+
+beforeEach(() => {
+ vi.mocked(listAllBrowserProfiles).mockResolvedValue([PROFILE])
+ vi.mocked(openInBrowserProfile).mockResolvedValue()
+ readsCookies(2)
+ clearSignIns()
+})
+
+describe('continueSignIn', () => {
+ it('opens the exact page in the chosen profile and remembers it for the chat', async () => {
+ await expect(continueSignIn('chat-a', request())).resolves.toEqual({
+ pending: { site: 'amazon.com', browserLabel: 'Arc' },
+ })
+ expect(openInBrowserProfile).toHaveBeenCalledWith(
+ 'company.thebrowser.Browser',
+ 'Profile 2',
+ SIGN_IN_URL
+ )
+ expect(getPendingSignIn('chat-a')).toEqual({ site: 'amazon.com', browserLabel: 'Arc' })
+ expect(getPendingSignIn('chat-b')).toBeNull()
+ })
+
+ it.each([
+ ['plain http', 'http://example.com/login'],
+ ['a local file', 'file:///etc/passwd'],
+ ['script', 'javascript:alert(1)'],
+ ['embedded credentials', 'https://user:secret@example.com/'],
+ ['garbage', 'not a url'],
+ ])('refuses %s without launching anything', async (_label, url) => {
+ await expect(continueSignIn('chat-a', request(url))).resolves.toEqual({
+ error: 'unsupported-page',
+ })
+ expect(openInBrowserProfile).not.toHaveBeenCalled()
+ })
+
+ it('refuses a profile it cannot bring a sign-in back from', async () => {
+ await expect(
+ continueSignIn('chat-a', request(SIGN_IN_URL, 'chrome:Profile 9'))
+ ).resolves.toEqual({ error: 'chrome-not-found' })
+ vi.mocked(listAllBrowserProfiles).mockResolvedValue([{ ...PROFILE, cookiesPath: null }])
+ await expect(continueSignIn('chat-a', request())).resolves.toEqual({
+ error: 'chrome-not-found',
+ })
+ expect(openInBrowserProfile).not.toHaveBeenCalled()
+ })
+
+ it('remembers nothing when the browser will not launch', async () => {
+ vi.mocked(openInBrowserProfile).mockRejectedValue(new Error('Unable to find application'))
+
+ await expect(continueSignIn('chat-a', request())).resolves.toEqual({ error: 'launch-failed' })
+ expect(getPendingSignIn('chat-a')).toBeNull()
+ })
+})
+
+describe('bringSignInBack', () => {
+ it('imports only the handed-off site from that profile, without rescanning profiles', async () => {
+ await continueSignIn('chat-a', request())
+
+ await expect(bringSignInBack('chat-a')).resolves.toEqual({
+ result: { cookiesImported: 2, cookiesSkipped: 0 },
+ handoff: expect.objectContaining({ tabId: 'tab-1', url: SIGN_IN_URL }),
+ current: true,
+ })
+ expect(readBrowserCookies).toHaveBeenCalledWith('/arc/Profile 2/Cookies', expect.any(Buffer), {
+ scope: { domain: 'amazon.com', exact: false },
+ })
+ expect(listAllBrowserProfiles).toHaveBeenCalledOnce()
+ expect(getPendingSignIn('chat-a')).toBeNull()
+ })
+
+ it('keeps the handoff pending when nothing came back, so the user can retry', async () => {
+ await continueSignIn('chat-a', request())
+ readsCookies(0)
+
+ await expect(bringSignInBack('chat-a')).resolves.toMatchObject({
+ result: { cookiesImported: 0, cookiesSkipped: 0 },
+ })
+ expect(getPendingSignIn('chat-a')).not.toBeNull()
+ })
+
+ it('keeps a newer handoff that replaced the one being brought back', async () => {
+ await continueSignIn('chat-a', request())
+ const signedIn = await vi.mocked(readBrowserCookies).getMockImplementation()?.(
+ '',
+ Buffer.alloc(0)
+ )
+ let finishRead: (() => void) | undefined
+ vi.mocked(readBrowserCookies).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishRead = () => signedIn && resolve(signedIn)
+ })
+ )
+ const broughtBack = bringSignInBack('chat-a')
+ await vi.waitFor(() => expect(finishRead).toBeTypeOf('function'))
+ await continueSignIn('chat-a', request('https://accounts.google.com/signin'))
+ finishRead?.()
+ await expect(broughtBack).resolves.toMatchObject({
+ result: { cookiesImported: 2 },
+ current: false,
+ })
+
+ expect(getPendingSignIn('chat-a')).toEqual({ site: 'google.com', browserLabel: 'Arc' })
+ })
+
+ it('answers only to the chat that started the handoff', async () => {
+ await continueSignIn('chat-a', request())
+
+ await expect(bringSignInBack('chat-b')).resolves.toEqual({
+ result: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' },
+ })
+ expect(readBrowserCookies).not.toHaveBeenCalled()
+ })
+
+ it('has nothing to bring back once the user cancels', async () => {
+ await continueSignIn('chat-a', request())
+ cancelSignIn('chat-a')
+
+ await expect(bringSignInBack('chat-a')).resolves.toMatchObject({ result: { error: 'unknown' } })
+ expect(readBrowserCookies).not.toHaveBeenCalled()
+ })
+})
+
+describe('a handoff retired or moved while its browser launches', () => {
+ it('records nothing once a cancel lands mid-launch', async () => {
+ const release = holdLaunch()
+ const continuing = continueSignIn('chat-a', request())
+ await vi.waitFor(() => expect(openInBrowserProfile).toHaveBeenCalled())
+ cancelSignIn('chat-a')
+ release()
+
+ await expect(continuing).resolves.toEqual({})
+ expect(getPendingSignIn('chat-a')).toBeNull()
+ })
+
+ it('lets a newer continue win over an older launch that finishes later', async () => {
+ const release = holdLaunch()
+ const older = continueSignIn('chat-a', request())
+ await vi.waitFor(() => expect(openInBrowserProfile).toHaveBeenCalled())
+ await continueSignIn('chat-a', request('https://accounts.google.com/signin'))
+ release()
+
+ await expect(older).resolves.toEqual({})
+ expect(getPendingSignIn('chat-a')).toEqual({ site: 'google.com', browserLabel: 'Arc' })
+ })
+
+ it('follows a provisional chat to its saved id, mid-launch or after', async () => {
+ const release = holdLaunch()
+ const continuing = continueSignIn('pending:new-chat', request())
+ await vi.waitFor(() => expect(openInBrowserProfile).toHaveBeenCalled())
+ migrateSignIn('pending:new-chat', 'chat-a')
+ release()
+ await continuing
+
+ expect(getPendingSignIn('chat-a')).toEqual({ site: 'amazon.com', browserLabel: 'Arc' })
+ expect(getPendingSignIn('pending:new-chat')).toBeNull()
+ migrateSignIn('chat-a', 'chat-b')
+ await expect(bringSignInBack('chat-b')).resolves.toMatchObject({
+ result: { cookiesImported: 2 },
+ })
+ })
+
+ it('forgets every handoff on account teardown, including one still launching', async () => {
+ await continueSignIn('chat-a', request())
+ const release = holdLaunch()
+ const continuing = continueSignIn('chat-b', request())
+ await vi.waitFor(() => expect(openInBrowserProfile).toHaveBeenCalledTimes(2))
+ clearSignIns()
+ release()
+
+ await expect(continuing).resolves.toEqual({})
+ expect(getPendingSignIn('chat-a')).toBeNull()
+ expect(getPendingSignIn('chat-b')).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/main/browser-import/index.ts b/apps/desktop/src/main/browser-import/index.ts
index 299de7bcceb..d4140f32fe1 100644
--- a/apps/desktop/src/main/browser-import/index.ts
+++ b/apps/desktop/src/main/browser-import/index.ts
@@ -1,9 +1,11 @@
import type {
BrowserChromeImportResult,
+ BrowserContinueSignInResult,
BrowserCredentialConflictPolicy,
BrowserImportProfile,
BrowserImportResult,
BrowserPasswordImportResult,
+ BrowserPendingSignIn,
} from '@sim/desktop-bridge'
import {
captureAccountDataGeneration,
@@ -11,6 +13,7 @@ import {
} from '@/main/account-data-generation'
import { importAgentCookies } from '@/main/browser-agent/session'
import { credentialsAvailable, importCredentials } from '@/main/browser-credentials'
+import { openInBrowserProfile } from '@/main/browser-import/browser-launch'
import { readBrowserCookies } from '@/main/browser-import/chromium-cookies'
import { readSafeStoragePassword } from '@/main/browser-import/chromium-crypto'
import { readBrowserFavicons } from '@/main/browser-import/chromium-favicons'
@@ -18,13 +21,17 @@ import { readBrowserPasswords } from '@/main/browser-import/chromium-passwords'
import { listAllBrowserProfiles } from '@/main/browser-import/chromium-profiles'
import { readBrowserSites } from '@/main/browser-import/chromium-site-names'
import {
+ cookieFailure,
type ImportServiceDeps,
listImportableProfiles,
importChromeCookies as runCookieImport,
importChromeData as runDataImport,
importChromePasswords as runPasswordImport,
} from '@/main/browser-import/import-service'
+import { type SiteScope, siteScopeFor } from '@/main/browser-import/site-scope'
+import type { BrowserProfile } from '@/main/browser-import/types'
import { rememberSites } from '@/main/browser-sites'
+import { isSafeExternalUrl } from '@/main/navigation'
/**
* Composition root for the Chrome importer: binds the real Keychain, profile,
@@ -37,7 +44,7 @@ function deps(): ImportServiceDeps {
platform: process.platform,
listProfiles: () => listAllBrowserProfiles(),
readSafeStoragePassword: (item) => readSafeStoragePassword(item),
- readCookies: (cookiesPath, key) => readBrowserCookies(cookiesPath, key),
+ readCookies: (cookiesPath, key, scope) => readBrowserCookies(cookiesPath, key, { scope }),
writeCookies: (cookies) => importAgentCookies(cookies),
readPasswords: (loginDataPath, key) => readBrowserPasswords(loginDataPath, key),
readFavicons: (faviconsPath, origins) => readBrowserFavicons(faviconsPath, origins),
@@ -72,3 +79,120 @@ export function importChromeData(
): Promise {
return runDataImport(profileId, policy, deps())
}
+
+/** A page the user took to another browser to finish signing in. */
+interface SignInHandoff {
+ /** Resolved once, so bringing the sign-in back reads exactly this profile. */
+ profile: BrowserProfile
+ tabId: string
+ url: string
+ scope: SiteScope
+}
+
+/**
+ * A chat's handoff, created before the browser launches so a cancel, a newer
+ * continue, sign-out, or the chat's id migrating during the launch can retire
+ * or move it. A launch records its handoff only while its slot is still current.
+ */
+interface HandoffSlot {
+ handoff?: SignInHandoff
+}
+
+/** At most one per chat: continuing another page replaces the previous slot. */
+const slotsByScope = new Map()
+
+function pendingSignIn({ profile, scope }: SignInHandoff): BrowserPendingSignIn {
+ return { site: scope.domain, browserLabel: profile.source.label }
+}
+
+function isCurrentSlot(slot: HandoffSlot): boolean {
+ for (const current of slotsByScope.values()) if (current === slot) return true
+ return false
+}
+
+/**
+ * Opens a built-in browser page in one of the user's own browser profiles and
+ * remembers it for the chat, so {@link bringSignInBack} imports the site the
+ * user left from and reopens that page, whatever the tab shows by then.
+ *
+ * Only an https page goes, and only to a profile the importer can read back
+ * from: a sign-in that can never return would strand the user. A handoff
+ * retired while its browser launched resolves with neither `pending` nor
+ * `error`.
+ */
+export async function continueSignIn(
+ scopeId: string,
+ request: { profileId: string; tabId: string; url: string }
+): Promise {
+ const scope = isSafeExternalUrl(request.url) ? siteScopeFor(new URL(request.url).hostname) : null
+ if (!scope) return { error: 'unsupported-page' }
+ const slot: HandoffSlot = {}
+ slotsByScope.set(scopeId, slot)
+ const profiles = await listAllBrowserProfiles().catch(() => [])
+ const profile = profiles.find(({ id }) => id === request.profileId)
+ if (!profile?.cookiesPath) return retire(slot, { error: 'chrome-not-found' })
+ try {
+ await openInBrowserProfile(profile.source.bundleId, profile.directory, request.url)
+ } catch {
+ return retire(slot, { error: 'launch-failed' })
+ }
+ if (!isCurrentSlot(slot)) return {}
+ slot.handoff = { profile, tabId: request.tabId, url: request.url, scope }
+ return { pending: pendingSignIn(slot.handoff) }
+}
+
+function retire(
+ slot: HandoffSlot,
+ result: BrowserContinueSignInResult
+): BrowserContinueSignInResult {
+ for (const [scopeId, current] of slotsByScope) {
+ if (current === slot) slotsByScope.delete(scopeId)
+ }
+ return result
+}
+
+export function getPendingSignIn(scopeId: string): BrowserPendingSignIn | null {
+ const handoff = slotsByScope.get(scopeId)?.handoff
+ return handoff ? pendingSignIn(handoff) : null
+}
+
+export function cancelSignIn(scopeId: string): void {
+ slotsByScope.delete(scopeId)
+}
+
+/** Follows a provisional chat to its saved id, including a launch still in flight. */
+export function migrateSignIn(fromScopeId: string, toScopeId: string): void {
+ const slot = slotsByScope.get(fromScopeId)
+ if (!slot) return
+ slotsByScope.delete(fromScopeId)
+ slotsByScope.set(toScopeId, slot)
+}
+
+/** Account teardown: forgets every handoff and retires launches still in flight. */
+export function clearSignIns(): void {
+ slotsByScope.clear()
+}
+
+/**
+ * Imports the chat's handed-off site from the profile it was continued in. The
+ * handoff stays pending until something comes back, so the user can finish
+ * signing in and try again. `current` is false when a cancel, a newer
+ * continue, or teardown retired the handoff while it was being imported.
+ */
+export async function bringSignInBack(scopeId: string): Promise<{
+ result: BrowserImportResult
+ handoff?: { tabId: string; url: string }
+ current?: boolean
+}> {
+ const slot = slotsByScope.get(scopeId)
+ const handoff = slot?.handoff
+ if (!slot || !handoff) return { result: cookieFailure('unknown') }
+ const result = await runCookieImport(
+ handoff.profile.id,
+ { ...deps(), listProfiles: async () => [handoff.profile] },
+ handoff.scope
+ )
+ const current = slotsByScope.get(scopeId) === slot && slot.handoff === handoff
+ if (current && result.cookiesImported > 0) slotsByScope.delete(scopeId)
+ return { result, handoff, current }
+}
diff --git a/apps/desktop/src/main/browser-import/site-scope.test.ts b/apps/desktop/src/main/browser-import/site-scope.test.ts
new file mode 100644
index 00000000000..5475fcb3824
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/site-scope.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest'
+import { isCookieHostInScope, siteScopeFor } from '@/main/browser-import/site-scope'
+
+/**
+ * A per-site import must bring back the sign-in it was asked for and nothing
+ * else. Each case is a way the scope could leak another site's cookies into
+ * the built-in browser, or drop the ones the user just signed in for.
+ */
+describe('siteScopeFor', () => {
+ it.each([
+ [
+ 'a sign-in host widens to its registrable domain',
+ 'signin.aws.amazon.com',
+ 'amazon.com',
+ false,
+ ],
+ ['a private suffix tenant stays itself', 'alice.github.io', 'alice.github.io', false],
+ [
+ 'an AWS access portal stays itself',
+ 'd-1234567890.awsapps.com',
+ 'd-1234567890.awsapps.com',
+ false,
+ ],
+ ['case and a root dot normalize', 'WWW.Example.COM.', 'example.com', false],
+ ['an IPv4 host is exact', '127.0.0.1', '127.0.0.1', true],
+ ['an IPv6 host is exact', '[::1]', '[::1]', true],
+ ['a single-label host is exact', 'localhost', 'localhost', true],
+ ])('%s', (_label, host, domain, exact) => {
+ expect(siteScopeFor(host)).toEqual({ domain, exact })
+ })
+
+ it.each(['', '.'])('refuses the empty host %j', (host) => {
+ expect(siteScopeFor(host)).toBeNull()
+ })
+})
+
+describe('isCookieHostInScope', () => {
+ function inScope(pageHost: string, hostKey: string): boolean {
+ const scope = siteScopeFor(pageHost)
+ if (!scope) throw new Error(`No scope for ${pageHost}`)
+ return isCookieHostInScope(hostKey, scope)
+ }
+
+ it.each([
+ ['www.example.com', 'example.com'],
+ ['www.example.com', 'sub.example.com'],
+ ['www.example.com', '.example.com'],
+ ['www.example.com', '.deep.sub.example.com'],
+ ['example.com', 'Sub.EXAMPLE.com'],
+ ['signin.aws.amazon.com', 'console.aws.amazon.com'],
+ ['alice.github.io', '.alice.github.io'],
+ ['127.0.0.1', '127.0.0.1'],
+ ['localhost', 'localhost'],
+ ])('a page on %s brings back %s', (pageHost, hostKey) => {
+ expect(inScope(pageHost, hostKey)).toBe(true)
+ })
+
+ it.each([
+ ['example.com', 'example.com.evil.com'],
+ ['example.com', '.example.com.evil.com'],
+ ['example.com', 'notexample.com'],
+ ['example.com', '.notexample.com'],
+ ['example.com', 'evil.com'],
+ ['example.com', '.com'],
+ ['example.com', ''],
+ ['example.com', '.'],
+ ['signin.aws.amazon.com', 'amazon.co.uk'],
+ ['alice.github.io', 'bob.github.io'],
+ ['alice.github.io', '.github.io'],
+ ['127.0.0.1', '127.0.0.10'],
+ ['127.0.0.1', '10.127.0.0.1'],
+ ['localhost', 'evil.localhost'],
+ ])('a page on %s never brings back %s', (pageHost, hostKey) => {
+ expect(inScope(pageHost, hostKey)).toBe(false)
+ })
+})
diff --git a/apps/desktop/src/main/browser-import/site-scope.ts b/apps/desktop/src/main/browser-import/site-scope.ts
new file mode 100644
index 00000000000..d51bbddd8ef
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/site-scope.ts
@@ -0,0 +1,45 @@
+import { parse } from 'tldts'
+
+/**
+ * The cookies a per-site import may bring back from another browser.
+ *
+ * A sign-in rarely stays on the host the user started from — AWS signs in on
+ * `signin.aws.amazon.com` and lands on `console.aws.amazon.com` — so the scope
+ * is the page's registrable domain (eTLD+1), which covers both. Private
+ * registry suffixes count as public here (`github.io`, `awsapps.com`), so one
+ * tenant of a shared host never brings back its neighbours' cookies.
+ */
+export interface SiteScope {
+ /** The registrable domain, or the exact host when it has none. */
+ domain: string
+ /** True when only cookies for `domain` itself qualify, never its subdomains. */
+ exact: boolean
+}
+
+/**
+ * Whether `host` is `domain` or one of its subdomains, matched on label
+ * boundaries so `notexample.com` and `example.com.evil.com` never qualify.
+ */
+export function isSameOrSubdomain(host: string, domain: string): boolean {
+ return host === domain || host.endsWith(`.${domain}`)
+}
+
+/** The scope for a page on `hostname`, or null when there is no host to scope to. */
+export function siteScopeFor(hostname: string): SiteScope | null {
+ const host = hostname.trim().toLowerCase().replace(/\.$/, '')
+ if (host.length === 0) return null
+ const parsed = parse(host, { allowPrivateDomains: true })
+ if (parsed.domain) return { domain: parsed.domain, exact: false }
+ // An IP, a single-label host, or a bare public suffix has no registrable
+ // parent to widen to, and its subdomains are other sites.
+ return { domain: host, exact: true }
+}
+
+/**
+ * Whether a cookie stored under Chromium's `host_key` belongs to `scope`. A
+ * domain cookie on the suffix itself (`.github.io`) is no one tenant's sign-in.
+ */
+export function isCookieHostInScope(hostKey: string, scope: SiteScope): boolean {
+ const host = hostKey.toLowerCase().replace(/^\./, '')
+ return scope.exact ? host === scope.domain : isSameOrSubdomain(host, scope.domain)
+}
diff --git a/apps/desktop/src/main/browser-import/sqlite-source.ts b/apps/desktop/src/main/browser-import/sqlite-source.ts
index 8dcbb70033c..2cbb2eb6689 100644
--- a/apps/desktop/src/main/browser-import/sqlite-source.ts
+++ b/apps/desktop/src/main/browser-import/sqlite-source.ts
@@ -1,6 +1,7 @@
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
+import type { SQLInputValue } from 'node:sqlite'
import { ImportFailure } from '@/main/browser-import/types'
/**
@@ -17,7 +18,11 @@ import { ImportFailure } from '@/main/browser-import/types'
/** SQLite keeps recent writes beside the main file; sidecars are needed for a faithful copy. */
const SQLITE_SIDECAR_SUFFIXES = ['-wal', '-shm', '-journal']
-async function queryCopy(databasePath: string, query: string): Promise[]> {
+async function queryCopy(
+ databasePath: string,
+ query: string,
+ params: readonly SQLInputValue[]
+): Promise[]> {
// Imported lazily: `node:sqlite` is only needed on the import path, and this
// keeps module load working on runtimes that lack it.
const { DatabaseSync } = await import('node:sqlite')
@@ -40,7 +45,7 @@ async function queryCopy(databasePath: string, query: string): Promise[]
+ return statement.all(...params) as Record[]
} catch {
throw new ImportFailure('unsupported-schema', 'The Chrome table is not in a recognised shape.')
} finally {
@@ -50,12 +55,14 @@ async function queryCopy(databasePath: string, query: string): Promise[]> {
const staging = await mkdtemp(join(tmpdir(), 'sim-chrome-import-'))
try {
@@ -69,7 +76,7 @@ export async function queryBrowserDatabase(
// Absent sidecars are normal: they only exist while a transaction is live.
await copyFile(`${sourcePath}${suffix}`, `${workingCopy}${suffix}`).catch(() => {})
}
- return await queryCopy(workingCopy, query)
+ return await queryCopy(workingCopy, query, params)
} finally {
await rm(staging, { recursive: true, force: true }).catch(() => {})
}
diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts
index 3148c992577..28ca953f16f 100644
--- a/apps/desktop/src/main/config.ts
+++ b/apps/desktop/src/main/config.ts
@@ -92,6 +92,8 @@ export interface DesktopSettings {
windowBounds?: WindowBounds
zoomLevel?: number
lastRoute?: string
+ /** Routes of the other app windows open when an update relaunch was committed. */
+ updateRelaunchRoutes?: string[]
themeBackground?: 'dark' | 'light'
blockThirdPartyAnalytics?: boolean
trayEnabled?: boolean
diff --git a/apps/desktop/src/main/context-menu.ts b/apps/desktop/src/main/context-menu.ts
index 689da904371..d0c88501507 100644
--- a/apps/desktop/src/main/context-menu.ts
+++ b/apps/desktop/src/main/context-menu.ts
@@ -9,14 +9,37 @@ export interface ContextMenuDeps {
allowHttpLocalhost: boolean
}
-interface TemplateHandlers {
+interface SpellingHandlers {
replaceMisspelling(word: string): void
addToDictionary(word: string): void
+}
+
+interface TemplateHandlers extends SpellingHandlers {
openLink(url: string): void
copyLink(url: string): void
inspect(x: number, y: number): void
}
+/** Corrections for the misspelled word under the cursor, then Add to Dictionary; empty when none. */
+export function spellingItems(
+ params: Pick,
+ handlers: SpellingHandlers
+): MenuItemConstructorOptions[] {
+ if (!params.misspelledWord) return []
+ const suggestions = params.dictionarySuggestions.slice(0, MAX_SUGGESTIONS)
+ return [
+ ...(suggestions.length > 0
+ ? suggestions.map(
+ (suggestion): MenuItemConstructorOptions => ({
+ label: suggestion,
+ click: () => handlers.replaceMisspelling(suggestion),
+ })
+ )
+ : [{ label: 'No Guesses Found', enabled: false }]),
+ { label: 'Add to Dictionary', click: () => handlers.addToDictionary(params.misspelledWord) },
+ ]
+}
+
/**
* Builds the native right-click menu for a given context. Returns an empty
* template when there is nothing editable, selected, or linked — which leaves
@@ -38,18 +61,8 @@ export function buildContextMenuTemplate(
): MenuItemConstructorOptions[] {
const template: MenuItemConstructorOptions[] = []
- if (params.misspelledWord) {
- for (const suggestion of params.dictionarySuggestions.slice(0, MAX_SUGGESTIONS)) {
- template.push({ label: suggestion, click: () => handlers.replaceMisspelling(suggestion) })
- }
- if (params.dictionarySuggestions.length === 0) {
- template.push({ label: 'No Guesses Found', enabled: false })
- }
- template.push(
- { label: 'Add to Dictionary', click: () => handlers.addToDictionary(params.misspelledWord) },
- { type: 'separator' }
- )
- }
+ const spelling = spellingItems(params, handlers)
+ if (spelling.length > 0) template.push(...spelling, { type: 'separator' })
if (params.isEditable) {
template.push(
diff --git a/apps/desktop/src/main/downloads.test.ts b/apps/desktop/src/main/downloads.test.ts
index 951bc151f6c..22302421def 100644
--- a/apps/desktop/src/main/downloads.test.ts
+++ b/apps/desktop/src/main/downloads.test.ts
@@ -1,3 +1,4 @@
+import { EventEmitter } from 'node:events'
import { mkdtempSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -5,7 +6,35 @@ import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => import('@/test/electron-mock'))
-import { sanitizeFilename, uniqueDownloadPath } from '@/main/downloads'
+import type { DownloadItem, Session } from 'electron'
+import {
+ attachDownloadHandling,
+ countProgressingAppDownloads,
+ sanitizeFilename,
+ uniqueDownloadPath,
+} from '@/main/downloads'
+import type { EventRecorder } from '@/main/observability'
+
+type DownloadState = ReturnType
+
+function startAppDownload(session: EventEmitter) {
+ const item = new EventEmitter()
+ let state: DownloadState = 'progressing'
+ Object.assign(item, {
+ getFilename: () => 'export.csv',
+ getMimeType: () => 'text/csv',
+ getSavePath: () => '/Downloads/export.csv',
+ getState: () => state,
+ setSaveDialogOptions: vi.fn(),
+ })
+ session.emit('will-download', {}, item, undefined)
+ return {
+ finish(finalState: DownloadState) {
+ state = finalState
+ item.emit('done', {}, finalState)
+ },
+ }
+}
describe('sanitizeFilename', () => {
it('neutralizes path separators and leading dots', () => {
@@ -49,3 +78,21 @@ describe('uniqueDownloadPath', () => {
)
})
})
+
+describe('countProgressingAppDownloads', () => {
+ it('counts an app-window download until it completes or is cancelled', () => {
+ const session = new EventEmitter()
+ const events = { record: vi.fn() } as unknown as EventRecorder
+ attachDownloadHandling(session as unknown as Session, events)
+ const before = countProgressingAppDownloads()
+
+ const completed = startAppDownload(session)
+ const cancelled = startAppDownload(session)
+ expect(countProgressingAppDownloads()).toBe(before + 2)
+
+ completed.finish('completed')
+ expect(countProgressingAppDownloads()).toBe(before + 1)
+ cancelled.finish('cancelled')
+ expect(countProgressingAppDownloads()).toBe(before)
+ })
+})
diff --git a/apps/desktop/src/main/downloads.ts b/apps/desktop/src/main/downloads.ts
index a26582f379b..718b3f7429c 100644
--- a/apps/desktop/src/main/downloads.ts
+++ b/apps/desktop/src/main/downloads.ts
@@ -2,7 +2,7 @@ import { lstat } from 'node:fs/promises'
import { basename, extname, join } from 'node:path'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
-import type { Session } from 'electron'
+import type { DownloadItem, Session } from 'electron'
import { app } from 'electron'
import { isAgentWebContents } from '@/main/browser-agent/registry'
import type { EventRecorder } from '@/main/observability'
@@ -131,6 +131,17 @@ export async function uniqueDownloadPath(
return null
}
+const activeAppDownloads = new Set()
+
+/** App-window downloads (Files exports and saves) still receiving bytes, which a restart would cut off. */
+export function countProgressingAppDownloads(): number {
+ let count = 0
+ for (const item of activeAppDownloads) {
+ if (item.getState() === 'progressing') count += 1
+ }
+ return count
+}
+
/**
* Wires will-download so exports, blob URLs, and presigned-URL downloads all
* get a native save dialog with a sensible default name, and completed
@@ -139,11 +150,13 @@ export async function uniqueDownloadPath(
export function attachDownloadHandling(session: Session, events: EventRecorder): void {
session.on('will-download', (_event, item, contents) => {
if (contents && isAgentWebContents(contents)) return
+ activeAppDownloads.add(item)
const filename = suggestedFilename(item.getFilename(), item.getMimeType())
item.setSaveDialogOptions({
defaultPath: join(app.getPath('downloads'), filename),
})
item.once('done', (_doneEvent, state) => {
+ activeAppDownloads.delete(item)
if (state === 'completed') {
logger.info('Download completed', { filename })
if (process.platform === 'darwin') {
diff --git a/apps/desktop/src/main/in-flight-work.test.ts b/apps/desktop/src/main/in-flight-work.test.ts
new file mode 100644
index 00000000000..4a218ab7268
--- /dev/null
+++ b/apps/desktop/src/main/in-flight-work.test.ts
@@ -0,0 +1,73 @@
+import type { WebContents } from 'electron'
+import { describe, expect, it, vi } from 'vitest'
+import {
+ countWindowsWithUnsavedChanges,
+ describeInFlightWork,
+ type InFlightWork,
+} from '@/main/in-flight-work'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+
+function windowAnswering(answer: () => Promise, state = { destroyed: false }) {
+ return {
+ isDestroyed: () => state.destroyed,
+ isCrashed: () => false,
+ executeJavaScript: vi.fn(answer),
+ } as unknown as WebContents
+}
+
+describe('countWindowsWithUnsavedChanges', () => {
+ it('never reports a window it could not ask as clean', async () => {
+ const counts = await countWindowsWithUnsavedChanges([
+ windowAnswering(() => Promise.resolve(true)),
+ windowAnswering(() => Promise.resolve(false)),
+ windowAnswering(() => Promise.reject(new Error('Script failed to execute'))),
+ windowAnswering(() => Promise.resolve(true), { destroyed: true }),
+ ])
+
+ expect(counts).toEqual({ unsavedWindowCount: 1, unresponsiveWindowCount: 1 })
+ })
+})
+
+describe('describeInFlightWork', () => {
+ const IDLE: InFlightWork = {
+ terminalCommands: [],
+ uncheckedTerminalCount: 0,
+ computerUseApp: null,
+ browserToolRunning: false,
+ agentWorkRunning: false,
+ downloadCount: 0,
+ unsavedWindowCount: 0,
+ unresponsiveWindowCount: 0,
+ }
+
+ it('warns about a terminal it could not check rather than restarting silently', () => {
+ expect(describeInFlightWork({ ...IDLE, uncheckedTerminalCount: 2 })).toEqual([
+ "2 terminals couldn't be checked and may still be running commands.",
+ ])
+ expect(describeInFlightWork({ ...IDLE, uncheckedTerminalCount: 1 })).toEqual([
+ "A terminal couldn't be checked and may still be running a command.",
+ ])
+ })
+
+ it('starts the sentence for an unannounced command with a capital', () => {
+ expect(describeInFlightWork({ ...IDLE, terminalCommands: [''] })).toEqual([
+ 'A command is still running in a terminal.',
+ ])
+ expect(describeInFlightWork({ ...IDLE, terminalCommands: ['npm run dev'] })).toEqual([
+ 'npm is still running in a terminal.',
+ ])
+ })
+
+ it('counts unannounced commands rather than naming "a command" twice', () => {
+ expect(describeInFlightWork({ ...IDLE, terminalCommands: ['', ''] })).toEqual([
+ '2 terminal commands are still running.',
+ ])
+ expect(describeInFlightWork({ ...IDLE, terminalCommands: ['', 'npm run dev'] })).toEqual([
+ 'npm and a command are still running in terminals.',
+ ])
+ expect(
+ describeInFlightWork({ ...IDLE, terminalCommands: ['', 'npm run dev', 'claude'] })
+ ).toEqual(['3 terminal commands are still running.'])
+ })
+})
diff --git a/apps/desktop/src/main/in-flight-work.ts b/apps/desktop/src/main/in-flight-work.ts
new file mode 100644
index 00000000000..3203b675a85
--- /dev/null
+++ b/apps/desktop/src/main/in-flight-work.ts
@@ -0,0 +1,148 @@
+import { createLogger } from '@sim/logger'
+import { describeRunningCommand } from '@sim/terminal-protocol'
+import { getErrorMessage } from '@sim/utils/errors'
+import { sleep } from '@sim/utils/helpers'
+import type { WebContents } from 'electron'
+
+const logger = createLogger('DesktopInFlightWork')
+
+/** A renderer that cannot answer this quickly is treated as hung, holding edits it cannot report. */
+const UNSAVED_CHANGES_PROBE_TIMEOUT_MS = 1_000
+const MAX_NAMED_TERMINAL_COMMANDS = 2
+
+/**
+ * Asks the page's own `beforeunload` guards whether they would hold it open, without unloading it,
+ * so every editor's live dirty state counts and no renderer needs to know about the shell.
+ */
+const UNSAVED_CHANGES_PROBE = `(() => {
+ let event
+ try {
+ event = document.createEvent('BeforeUnloadEvent')
+ event.initEvent('beforeunload', false, true)
+ } catch {
+ event = new Event('beforeunload', { cancelable: true })
+ }
+ window.dispatchEvent(event)
+ return event.defaultPrevented || (typeof event.returnValue === 'string' && event.returnValue !== '')
+})()`
+
+type UnloadAnswer = 'clean' | 'unsaved' | 'unresponsive'
+
+async function askAboutUnsavedChanges(contents: WebContents): Promise {
+ if (contents.isDestroyed() || contents.isCrashed()) return 'clean'
+ try {
+ const answer = await Promise.race([
+ contents.executeJavaScript(UNSAVED_CHANGES_PROBE),
+ sleep(UNSAVED_CHANGES_PROBE_TIMEOUT_MS).then(() => 'unresponsive' as const),
+ ])
+ if (answer === 'unresponsive') return answer
+ return answer === true ? 'unsaved' : 'clean'
+ } catch (error) {
+ logger.warn('Could not ask a window about unsaved changes', {
+ message: getErrorMessage(error, 'unknown'),
+ })
+ return 'unresponsive'
+ }
+}
+
+/**
+ * Counts the windows whose pages would ask before leaving, and those too hung
+ * to answer, which may be holding edits just the same.
+ */
+export async function countWindowsWithUnsavedChanges(
+ windows: readonly WebContents[]
+): Promise> {
+ const answers = await Promise.all(windows.map(askAboutUnsavedChanges))
+ return {
+ unsavedWindowCount: answers.filter((answer) => answer === 'unsaved').length,
+ unresponsiveWindowCount: answers.filter((answer) => answer === 'unresponsive').length,
+ }
+}
+
+export interface InFlightWork {
+ /** The command line holding each busy terminal's foreground (`''` when unannounced). */
+ terminalCommands: readonly string[]
+ /** Terminals whose foreground could not be read, which may be running a command. */
+ uncheckedTerminalCount: number
+ /** The app a computer-use action is driving, `''` when it has no name, or null when idle. */
+ computerUseApp: string | null
+ /** Whether an agent has a browser action queued or running. */
+ browserToolRunning: boolean
+ /** Whether a chat has desktop work claimed on this machine. */
+ agentWorkRunning: boolean
+ /** Agent-browser and app-window downloads still in progress. */
+ downloadCount: number
+ unsavedWindowCount: number
+ unresponsiveWindowCount: number
+}
+
+/**
+ * One sentence for the busy terminals. Commands are named while at most two are running and
+ * at most one is unannounced; past that, two "a command"s would say nothing, so they are counted.
+ */
+function describeTerminalCommands(commands: readonly string[]): string | null {
+ if (commands.length === 0) return null
+ const named = commands.filter((command) => command.trim()).map(describeRunningCommand)
+ const unnamed = commands.length - named.length
+ if (unnamed > 1 || commands.length > MAX_NAMED_TERMINAL_COMMANDS) {
+ return `${commands.length} terminal commands are still running.`
+ }
+ const labels = unnamed === 1 ? [...named, describeRunningCommand('')] : named
+ const sentence =
+ labels.length > 1
+ ? `${labels.join(' and ')} are still running in terminals.`
+ : `${labels[0]} is still running in a terminal.`
+ // Program names keep their own case; only the generic "a command" label is capitalized.
+ return named.length === 0 ? sentence.charAt(0).toUpperCase() + sentence.slice(1) : sentence
+}
+
+/**
+ * Names the work a restart would interrupt, one sentence per kind, so a
+ * restart prompt says exactly what is at stake. Empty when nothing is.
+ */
+export function describeInFlightWork(work: InFlightWork): string[] {
+ const sentences: string[] = []
+ const terminalSentence = describeTerminalCommands(work.terminalCommands)
+ if (terminalSentence) sentences.push(terminalSentence)
+ if (work.uncheckedTerminalCount === 1) {
+ sentences.push("A terminal couldn't be checked and may still be running a command.")
+ } else if (work.uncheckedTerminalCount > 1) {
+ sentences.push(
+ `${work.uncheckedTerminalCount} terminals couldn't be checked and may still be running commands.`
+ )
+ }
+
+ if (work.computerUseApp !== null) {
+ sentences.push(
+ work.computerUseApp
+ ? `An agent is using ${work.computerUseApp}.`
+ : 'An agent is using an app on this computer.'
+ )
+ }
+ if (work.browserToolRunning) {
+ sentences.push('An agent is using the browser.')
+ }
+ if (work.agentWorkRunning && work.computerUseApp === null && !work.browserToolRunning) {
+ sentences.push('An agent is still working on this computer.')
+ }
+
+ if (work.downloadCount === 1) {
+ sentences.push('A download is still in progress.')
+ } else if (work.downloadCount > 1) {
+ sentences.push(`${work.downloadCount} downloads are still in progress.`)
+ }
+
+ if (work.unsavedWindowCount === 1) {
+ sentences.push('A window has unsaved changes.')
+ } else if (work.unsavedWindowCount > 1) {
+ sentences.push(`${work.unsavedWindowCount} windows have unsaved changes.`)
+ }
+ if (work.unresponsiveWindowCount === 1) {
+ sentences.push("A window isn't responding and may have unsaved changes.")
+ } else if (work.unresponsiveWindowCount > 1) {
+ sentences.push(
+ `${work.unresponsiveWindowCount} windows aren't responding and may have unsaved changes.`
+ )
+ }
+ return sentences
+}
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index 56c4261fe10..a0efc04faf5 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -37,6 +37,7 @@ import {
executeTool as executeAgentBrowserTool,
hasBrowserScopeSession,
initDriver as initBrowserAgentDriver,
+ isBrowserToolRunning,
restoreBrowserScope as restoreAgentBrowserScope,
} from '@/main/browser-agent/driver'
import {
@@ -46,6 +47,7 @@ import {
setPanelOccluded as setBrowserAgentPanelOccluded,
} from '@/main/browser-agent/panel'
import {
+ countProgressingBrowserDownloads,
handleFocusedShortcut as handleFocusedBrowserShortcut,
isBrowserScopeSuspended,
quiesceBrowserSessions,
@@ -53,6 +55,7 @@ import {
setBrowserAppearanceTheme as setAgentBrowserTheme,
setPanelFocused as setBrowserAgentPanelFocused,
} from '@/main/browser-agent/session'
+import { clearSignIns } from '@/main/browser-import'
import { attachClientInfo } from '@/main/client-info'
import { NativeComputerUseClient } from '@/main/computer-use/native-client'
import { ComputerUseService } from '@/main/computer-use/service'
@@ -72,12 +75,13 @@ import { createDesktopToolRunner } from '@/main/desktop-executor/runner'
import { createDesktopExecutorService } from '@/main/desktop-executor/service'
import { createSleepBlocker } from '@/main/desktop-executor/sleep-blocker'
import { createDesktopSettingsService } from '@/main/desktop-settings'
-import { attachDownloadHandling } from '@/main/downloads'
+import { attachDownloadHandling, countProgressingAppDownloads } from '@/main/downloads'
import { createAuthFlow, createConnectFlow, createHandoffManager } from '@/main/handoff'
import {
installDocumentationHelpSearch,
uninstallDocumentationHelpSearch,
} from '@/main/help-search'
+import { countWindowsWithUnsavedChanges, describeInFlightWork } from '@/main/in-flight-work'
import { registerIpcHandlers } from '@/main/ipc'
import { attachLoadHealth, type LoadHealthHandle } from '@/main/load-health'
import { LocalFilePermissions } from '@/main/local-file-permissions'
@@ -128,6 +132,9 @@ function reportHandoffFailure(error: unknown): void {
logger.error('Sign-in handoff failed', { error: getErrorMessage(error) })
}
+/** Bounds what a hand-edited settings file can make an update relaunch open. */
+const MAX_RELAUNCH_WINDOWS = 16
+
const DOCK_ICON_FOR_CHANNEL = {
prod: 'dock-icon.png',
staging: 'dock-icon-staging.png',
@@ -390,6 +397,7 @@ function main(): void {
clearHandoffState: async () => {
const stores = [
{ label: 'sign-in handoff state', clear: () => handoff.clear() },
+ { label: 'browser sign-in handoffs', clear: clearSignIns },
{ label: 'recent tasks', clear: () => tray?.clearRecentChats() },
{
label: 'renderer session state',
@@ -531,6 +539,12 @@ function main(): void {
win.on('focus', () => {
lastActiveWindow = win
})
+ // Windows and Linux report a mouse's back and forward buttons as app
+ // commands. They move the Browser tab the user is in, never the Sim shell.
+ win.on('app-command', (_event, command) => {
+ if (command === 'browser-backward') handleFocusedBrowserShortcut('back', win)
+ else if (command === 'browser-forward') handleFocusedBrowserShortcut('forward', win)
+ })
// A fresh document (reload, origin change, crash recovery) has no browser
// panel mounted yet — hide the embedded agent-browser view immediately
// rather than letting it linger over the loading page.
@@ -569,6 +583,42 @@ function main(): void {
return win
}
+ /**
+ * An update relaunch reopens every app window where it was: the active one
+ * through `lastRoute` like any launch, the rest from this list. Written only
+ * once the relaunch is committed, and withdrawn if it fails.
+ */
+ function rememberWindowsForRelaunch(committed: boolean): void {
+ if (!committed) {
+ if (config.get('updateRelaunchRoutes') === undefined) return
+ config.set('updateRelaunchRoutes', undefined)
+ config.flush()
+ return
+ }
+ const active = getMainWindow()
+ const activeRoute = active ? routeFromAppUrl(active.webContents.getURL()) : null
+ if (activeRoute) config.set('lastRoute', activeRoute)
+ const otherRoutes = getWindows()
+ .filter((win) => win !== active)
+ .flatMap((win) => routeFromAppUrl(win.webContents.getURL()) ?? [])
+ config.set('updateRelaunchRoutes', otherRoutes.length > 0 ? otherRoutes : undefined)
+ config.flush()
+ }
+
+ /** Reopens the windows an update relaunch remembered, once the active window is up. */
+ function restoreWindowsAfterRelaunch(): void {
+ const routes: unknown = config.get('updateRelaunchRoutes')
+ if (routes === undefined) return
+ config.set('updateRelaunchRoutes', undefined)
+ config.flush()
+ if (!Array.isArray(routes)) return
+ for (const route of routes.slice(0, MAX_RELAUNCH_WINDOWS)) {
+ if (typeof route === 'string' && decideStartRoute(route) === route) {
+ void createAndLoadAppWindow({ route })
+ }
+ }
+ }
+
/** Opens the Sim app's settings page in the active window. */
function openSettings(): void {
void openMainWindowAt(settingsRoute(config.get('lastRoute')))
@@ -679,6 +729,7 @@ function main(): void {
Notification.isSupported() ? new Notification(options) : null,
})
+ let agentWorkRunning = false
const desktopExecutor = createDesktopExecutorService({
userDataPath,
origin: appOrigin,
@@ -686,7 +737,10 @@ function main(): void {
preferences: () => desktopSettings.getPreferences(),
accountDataAvailable,
onApprovals: (items) => approvalNotifier.update(items),
- onBusyChange: (busy) => sleepBlocker.setBusy(busy),
+ onBusyChange: (busy) => {
+ agentWorkRunning = busy
+ sleepBlocker.setBusy(busy)
+ },
// A result the model has makes a tmux run it handed back as still going collectable across a
// restart.
onResultDelivered: (toolCallId) => terminal.markRunDelivered(toolCallId),
@@ -889,6 +943,7 @@ function main(): void {
...(kind === 'account' && origin
? [
{ label: 'sign-in handoff state', clear: () => handoff.clear() },
+ { label: 'browser sign-in handoffs', clear: clearSignIns },
{ label: 'background executor', clear: () => desktopExecutor.signOut() },
{
label: 'terminal sessions',
@@ -1064,7 +1119,7 @@ function main(): void {
updates: {
getState: () => updater?.getState() ?? { status: 'idle' },
check: () => updater?.check(),
- install: () => updater?.install(),
+ install: (options) => updater?.install(options),
},
server: {
open: () => serverWindow.open(),
@@ -1113,6 +1168,24 @@ function main(): void {
autoDownload: () => config.get('autoDownloadUpdates') ?? true,
setRelaunchPending: (pending) => {
committedRelaunchPending = pending
+ rememberWindowsForRelaunch(pending)
+ },
+ describeInFlightWork: async () => {
+ // Native activity is read after the window probes so work started meanwhile still counts.
+ const windows = await countWindowsWithUnsavedChanges(
+ getWindows().map((win) => win.webContents)
+ )
+ const terminalWork = await terminal.runningWork()
+ const computerUseActivity = computerUse.getActivity()
+ return describeInFlightWork({
+ terminalCommands: terminalWork.commands,
+ uncheckedTerminalCount: terminalWork.uncheckedCount,
+ computerUseApp: computerUseActivity ? (computerUseActivity.appName ?? '') : null,
+ browserToolRunning: isBrowserToolRunning(),
+ agentWorkRunning,
+ downloadCount: countProgressingBrowserDownloads() + countProgressingAppDownloads(),
+ ...windows,
+ })
},
beforeInstall: async () => {
if (!prepareAccountDataTeardownForQuit()) {
@@ -1129,6 +1202,7 @@ function main(): void {
},
})
desktopSettings.applySystemPreferences()
+ restoreWindowsAfterRelaunch()
})
}
diff --git a/apps/desktop/src/main/input-activity.ts b/apps/desktop/src/main/input-activity.ts
index 1e1579edcfa..e67bbeebc88 100644
--- a/apps/desktop/src/main/input-activity.ts
+++ b/apps/desktop/src/main/input-activity.ts
@@ -132,6 +132,11 @@ function isWithin(
if (contents.isDestroyed()) return false
const activity = activityByContents.get(contents)
if (!activity) return false
- const elapsed = Date.now() - stamp(activity)
+ return withinWindow(stamp(activity), windowMs)
+}
+
+/** Whether `at` lies in the last `windowMs`; a clock stepping backwards never passes. */
+export function withinWindow(at: number, windowMs: number): boolean {
+ const elapsed = Date.now() - at
return elapsed >= 0 && elapsed < windowMs
}
diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts
index aeece41f983..b5580f76393 100644
--- a/apps/desktop/src/main/ipc.test.ts
+++ b/apps/desktop/src/main/ipc.test.ts
@@ -20,6 +20,17 @@ vi.mock('@/main/browser-import', () => ({
passwordsUpdated: 0,
passwordsSkipped: 1,
})),
+ continueSignIn: vi.fn(async () => ({
+ pending: { site: 'example.com', browserLabel: 'Chrome' },
+ })),
+ bringSignInBack: vi.fn(async () => ({
+ result: { cookiesImported: 2, cookiesSkipped: 0 },
+ handoff: { tabId: 'tab-1', url: 'https://signin.example.com/login' },
+ current: true,
+ })),
+ cancelSignIn: vi.fn(),
+ getPendingSignIn: vi.fn(() => null),
+ migrateSignIn: vi.fn(),
}))
vi.mock('@/main/browser-search/suggestions', () => ({
@@ -135,9 +146,12 @@ import {
revealCredential,
} from '@/main/browser-credentials'
import {
+ bringSignInBack,
+ continueSignIn,
importChromeCookies,
importChromePasswords,
listChromeImportProfiles,
+ migrateSignIn,
} from '@/main/browser-import'
import { getSearchSuggestions } from '@/main/browser-search/suggestions'
import { ComputerUseService } from '@/main/computer-use/service'
@@ -1138,11 +1152,23 @@ describe('registerIpcHandlers', () => {
})
expect(migrate).not.toHaveBeenCalled()
+ expect(migrateSignIn).not.toHaveBeenCalled()
expect(deps.scopeEvents.activateBrowser).toHaveBeenCalledTimes(1)
migrate.mockRestore()
})
+ it('moves a pending sign-in with the browser scope when a new chat gets its id', async () => {
+ const migrate = vi.spyOn(browserDriver, 'migrateBrowserScope').mockReturnValue(true)
+ const { invoke } = collectHandlers()
+
+ await invoke.get('browser-agent:activate-scope')?.(appEvent, 'pending:signin')
+ await invoke.get('browser-agent:migrate-scope')?.(appEvent, 'pending:signin', 'chat-saved')
+
+ expect(migrateSignIn).toHaveBeenCalledWith('pending:signin', 'chat-saved')
+ migrate.mockRestore()
+ })
+
it('requires a live user gesture before importing Chrome cookies', async () => {
const { invoke } = collectHandlers()
const handler = invoke.get('browser-import:cookies')
@@ -1164,6 +1190,161 @@ describe('registerIpcHandlers', () => {
expect(importChromeCookies).toHaveBeenCalledWith('Default')
})
+ describe('continuing a sign-in in another browser', () => {
+ const SIGN_IN_URL = 'https://signin.example.com/login'
+ let tabUrl = SIGN_IN_URL
+ let active: ReturnType
+
+ beforeEach(async () => {
+ tabUrl = SIGN_IN_URL
+ active = vi.spyOn(browserSession, 'activeTab').mockReturnValue({
+ id: 'tab-1',
+ view: { webContents: { getURL: () => tabUrl } },
+ } as never)
+ await collectHandlers().invoke.get('browser-agent:activate-scope')?.(
+ activeAppEvent,
+ 'chat-signin'
+ )
+ })
+
+ afterEach(() => active.mockRestore())
+
+ const continueIn = (event: typeof activeAppEvent, ...args: unknown[]) =>
+ collectHandlers().invoke.get('browser-import:continue-sign-in')?.(event, ...args)
+
+ it('hands over the page the tab shows, ignoring any address the renderer names', async () => {
+ await expect(
+ continueIn(activeAppEvent, 'chrome:Default', 'chat-signin', 'https://evil.example/')
+ ).resolves.toEqual({ pending: { site: 'example.com', browserLabel: 'Chrome' } })
+ expect(continueSignIn).toHaveBeenCalledWith('chat-signin', {
+ profileId: 'chrome:Default',
+ tabId: 'tab-1',
+ url: SIGN_IN_URL,
+ })
+ })
+
+ it('refuses a renderer acting without the user', async () => {
+ await collectHandlers().invoke.get('browser-agent:activate-scope')?.(
+ inactiveAppEvent,
+ 'chat-scripted'
+ )
+ expect(await continueIn(inactiveAppEvent, 'chrome:Default', 'chat-scripted')).toEqual({
+ error: 'unknown',
+ })
+ expect(continueSignIn).not.toHaveBeenCalled()
+ })
+
+ it('refuses a foreign origin and a malformed profile', async () => {
+ expect(await continueIn(evilEvent, 'chrome:Default', 'chat-signin')).toEqual({
+ error: 'unknown',
+ })
+ expect(await continueIn(activeAppEvent, 42, 'chat-signin')).toEqual({ error: 'unknown' })
+ expect(continueSignIn).not.toHaveBeenCalled()
+ })
+
+ it("refuses Sim's own pages, which ride the app session", async () => {
+ tabUrl = `${APP}/workspace/ws1/files`
+ expect(await continueIn(activeAppEvent, 'chrome:Default', 'chat-signin')).toEqual({
+ error: 'unsupported-page',
+ })
+ expect(continueSignIn).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('bringing a sign-in back', () => {
+ let panelAction: ReturnType
+ let peek: ReturnType
+ const tabsWith = (...tabIds: string[]) => ({
+ scopeId: 'chat-signin',
+ tabs: tabIds.map((tabId) => ({ tabId, url: '', title: '', loading: false, active: false })),
+ activeTabId: 'tab-2',
+ })
+
+ beforeEach(async () => {
+ panelAction = vi.spyOn(browserDriver, 'handlePanelAction').mockResolvedValue()
+ peek = vi.spyOn(browserSession, 'peekTabsState').mockReturnValue(tabsWith('tab-1', 'tab-2'))
+ await collectHandlers().invoke.get('browser-agent:activate-scope')?.(
+ activeAppEvent,
+ 'chat-signin'
+ )
+ })
+
+ afterEach(() => {
+ panelAction.mockRestore()
+ peek.mockRestore()
+ })
+
+ const bringBack = (event: typeof activeAppEvent, scope: string) =>
+ collectHandlers().invoke.get('browser-import:bring-sign-in-back')?.(event, scope)
+
+ it('refuses a renderer acting without the user, and a foreign origin', async () => {
+ await collectHandlers().invoke.get('browser-agent:activate-scope')?.(
+ inactiveAppEvent,
+ 'chat-scripted'
+ )
+ expect(await bringBack(inactiveAppEvent, 'chat-scripted')).toEqual({
+ cookiesImported: 0,
+ cookiesSkipped: 0,
+ error: 'unknown',
+ })
+ expect(await bringBack(evilEvent, 'chat-signin')).toMatchObject({ error: 'unknown' })
+ expect(bringSignInBack).not.toHaveBeenCalled()
+ })
+
+ it('reopens the handed-off page in its own tab', async () => {
+ await expect(bringBack(activeAppEvent, 'chat-signin')).resolves.toEqual({
+ cookiesImported: 2,
+ cookiesSkipped: 0,
+ })
+ expect(bringSignInBack).toHaveBeenCalledWith('chat-signin')
+ expect(panelAction.mock.calls).toEqual([
+ ['chat-signin', { action: 'switch-tab', tabId: 'tab-1' }],
+ ['chat-signin', { action: 'navigate', url: 'https://signin.example.com/login' }],
+ ])
+ })
+
+ it('reopens the page in a new tab when its tab was closed', async () => {
+ peek.mockReturnValue(tabsWith('tab-2'))
+ const tabContents = { loadURL: vi.fn(async () => {}), isDestroyed: () => false }
+ const add = vi
+ .spyOn(browserSession, 'addTab')
+ .mockReturnValue({ view: { webContents: tabContents } } as never)
+ const focus = vi.spyOn(browserSession, 'focusPageForUser').mockImplementation(() => {})
+
+ await bringBack(activeAppEvent, 'chat-signin')
+
+ expect(add).toHaveBeenCalledOnce()
+ expect(tabContents.loadURL).toHaveBeenCalledWith('https://signin.example.com/login')
+ expect(panelAction).not.toHaveBeenCalled()
+ add.mockRestore()
+ focus.mockRestore()
+ })
+
+ it('leaves the tabs alone when a newer continue retired the handoff mid-import', async () => {
+ vi.mocked(bringSignInBack).mockResolvedValueOnce({
+ result: { cookiesImported: 2, cookiesSkipped: 0 },
+ handoff: { tabId: 'tab-1', url: 'https://signin.example.com/login' },
+ current: false,
+ })
+
+ await bringBack(activeAppEvent, 'chat-signin')
+
+ expect(panelAction).not.toHaveBeenCalled()
+ })
+
+ it('leaves the tabs alone when nothing came back', async () => {
+ vi.mocked(bringSignInBack).mockResolvedValueOnce({
+ result: { cookiesImported: 0, cookiesSkipped: 0 },
+ handoff: { tabId: 'tab-1', url: 'https://signin.example.com/login' },
+ current: true,
+ })
+
+ await bringBack(activeAppEvent, 'chat-signin')
+
+ expect(panelAction).not.toHaveBeenCalled()
+ })
+ })
+
it('never imports Chrome cookies for a foreign origin', async () => {
const { invoke } = collectHandlers()
diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts
index ebd34d86e1f..b88167462ac 100644
--- a/apps/desktop/src/main/ipc.ts
+++ b/apps/desktop/src/main/ipc.ts
@@ -57,6 +57,7 @@ import {
} from '@/main/browser-agent/driver'
import { isAgentWebContents } from '@/main/browser-agent/registry'
import {
+ activeTab,
addTab,
findInActiveTab,
focusPageForUser,
@@ -79,10 +80,15 @@ import {
revealCredential,
} from '@/main/browser-credentials'
import {
+ bringSignInBack,
+ cancelSignIn,
+ continueSignIn,
+ getPendingSignIn,
importChromeCookies,
importChromeData,
importChromePasswords,
listChromeImportProfiles,
+ migrateSignIn,
} from '@/main/browser-import'
import { getSearchSuggestions } from '@/main/browser-search/suggestions'
import { listSites } from '@/main/browser-sites'
@@ -385,7 +391,7 @@ export interface IpcDeps {
updates: {
getState: () => DesktopUpdateState
check: () => void
- install: () => void
+ install: (options: { mandatory: boolean }) => void
}
server: {
open: () => void
@@ -1016,7 +1022,8 @@ export function registerIpcHandlers(deps: IpcDeps): void {
kind: 'send',
gate: 'app-origin',
needsUserActivation: true,
- handler: () => deps.updates.install(),
+ handler: (options) =>
+ deps.updates.install({ mandatory: isRecordLike(options) && options.mandatory === true }),
},
'browser-agent:execute-tool': {
kind: 'invoke',
@@ -1176,6 +1183,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
return { tabs: [], activeTabId: null }
}
consumePendingScope(browserPendingScopesBySender, contents, from)
+ migrateSignIn(from, to)
if (browserScopeBySender.get(contents) === from) {
browserScopeBySender.set(contents, to)
deps.scopeEvents.activateBrowser(contents, to)
@@ -1312,6 +1320,9 @@ export function registerIpcHandlers(deps: IpcDeps): void {
needsUserActivation: ([action]) => {
if (!isRecordLike(action)) return false
if (action.action === 'navigate') return true
+ if (action.action === 'respond-prompt') {
+ return isRecordLike(action.response) && action.response.allowed === true
+ }
return action.action === 'respond-media-permission' && action.allowed === true
},
handler: (sender, action, rawScope) => {
@@ -1512,6 +1523,78 @@ export function registerIpcHandlers(deps: IpcDeps): void {
)
},
},
+ // Continue a sign-in in the user's own browser (see the bridge's
+ // `continueSignIn`). The shell reads the address from the tab, never from
+ // the renderer; Sim's own pages ride the app session, so they are refused.
+ 'browser-import:continue-sign-in': {
+ kind: 'invoke',
+ gate: 'app-origin',
+ requires: 'browser',
+ passSender: true,
+ needsUserActivation: true,
+ denied: { error: 'unknown' },
+ handler: (sender, profileId, rawScope) => {
+ const scopeId = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope)
+ if (!scopeId || typeof profileId !== 'string') return { error: 'unknown' }
+ const tab = withBrowserScope(scopeId, () => activeTab())
+ const url = tab?.view.webContents.getURL() ?? ''
+ if (!tab || isAppOrigin(url, deps.appOrigin())) return { error: 'unsupported-page' }
+ return continueSignIn(scopeId, { profileId, tabId: tab.id, url })
+ },
+ },
+ // Imports the chat's pending sign-in, then reopens the handed-off page
+ // through the omnibox's navigation path, or in a new tab if it was closed.
+ 'browser-import:bring-sign-in-back': {
+ kind: 'invoke',
+ gate: 'app-origin',
+ requires: 'browser',
+ passSender: true,
+ needsUserActivation: true,
+ denied: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' },
+ handler: async (sender, rawScope) => {
+ const scopeId = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope)
+ if (!scopeId) return { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }
+ const { result, handoff, current } = await bringSignInBack(scopeId)
+ if (!handoff || !current || result.cookiesImported === 0) return result
+ const tabOpen = withBrowserScope(scopeId, () =>
+ peekTabsState().tabs.some(({ tabId }) => tabId === handoff.tabId)
+ )
+ if (!tabOpen) {
+ withBrowserScope(scopeId, () => {
+ const tab = addTab()
+ void tab.view.webContents.loadURL(handoff.url).catch(() => {})
+ focusPageForUser(tab.view.webContents)
+ })
+ return result
+ }
+ await handlePanelAction(scopeId, { action: 'switch-tab', tabId: handoff.tabId })
+ .then(() => handlePanelAction(scopeId, { action: 'navigate', url: handoff.url }))
+ .catch(() => {})
+ return result
+ },
+ },
+ 'browser-import:cancel-sign-in': {
+ kind: 'invoke',
+ gate: 'app-origin',
+ requires: 'browser',
+ passSender: true,
+ denied: undefined,
+ handler: (sender, rawScope) => {
+ const scopeId = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope)
+ if (scopeId) cancelSignIn(scopeId)
+ },
+ },
+ 'browser-import:pending-sign-in': {
+ kind: 'invoke',
+ gate: 'app-origin',
+ requires: 'browser',
+ passSender: true,
+ denied: null,
+ handler: (sender, rawScope) => {
+ const scopeId = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope)
+ return scopeId ? getPendingSignIn(scopeId) : null
+ },
+ },
// Reported by the browser preload for the page it is running in. The
// origin it names is a claim: the fill coordinator re-checks it against
// the live URL before any password is read.
diff --git a/apps/desktop/src/main/navigation.test.ts b/apps/desktop/src/main/navigation.test.ts
index d902421c109..bb4c69a2d17 100644
--- a/apps/desktop/src/main/navigation.test.ts
+++ b/apps/desktop/src/main/navigation.test.ts
@@ -4,6 +4,7 @@ vi.mock('electron', () => import('@/test/electron-mock'))
import { shell } from 'electron'
import {
+ appLinkTarget,
classifyNavigation,
classifyWindowOpen,
isSafeExternalUrl,
@@ -61,6 +62,12 @@ describe('classifyNavigation', () => {
).toBe('external')
})
+ it('sends mailto links to the mail client', () => {
+ expect(
+ classifyNavigation('mailto:support@sim.ai', { appOrigin: APP, currentUrl: `${APP}/login` })
+ ).toBe('external')
+ })
+
it('denies non-web schemes everywhere', () => {
for (const url of [
'javascript:alert(1)',
@@ -75,6 +82,10 @@ describe('classifyNavigation', () => {
})
describe('classifyWindowOpen', () => {
+ it('sends mailto popups to the mail client instead of denying them', () => {
+ expect(classifyWindowOpen('mailto:support@sim.ai', '_blank', APP)).toBe('external')
+ })
+
it('does not let a cross-origin http URL ride the mcp-oauth frame name in-app', () => {
expect(classifyWindowOpen('http://mcp.example/authorize', 'mcp-oauth-srv1', APP)).toBe(
'external'
@@ -106,4 +117,70 @@ describe('openExternalSafe', () => {
await expect(openExternalSafe('file:///etc/passwd', true)).resolves.toBe(false)
expect(shell.openExternal).not.toHaveBeenCalled()
})
+
+ it.each([
+ 'mailto:support@sim.ai',
+ 'MAILTO:support@sim.ai,help@sim.ai?subject=Desktop%20offline&body=Hi%0Athere',
+ 'mailto:?to=support@sim.ai&cc=a@b.example',
+ ])('hands the well-formed mailto %s to the system mail client', async (url) => {
+ await expect(openExternalSafe(url)).resolves.toBe(true)
+ expect(shell.openExternal).toHaveBeenCalledWith(url)
+ })
+
+ it.each([
+ ['an attachment field', 'mailto:a@b.example?attach=/etc/passwd'],
+ ['an unlisted header', 'mailto:a@b.example?X-Header=y'],
+ ['a non-address recipient field', 'mailto:a@b.example?cc=file:///etc/passwd'],
+ ['unencoded whitespace', 'mailto:a@b.example?subject=a b'],
+ ['an encoded header injection', 'mailto:a@b.example%0d%0aBcc:c@d.example'],
+ ['a fragment', 'mailto:a@b.example#frag'],
+ ])('refuses a mailto with %s', async (_rule, url) => {
+ await expect(openExternalSafe(url)).resolves.toBe(false)
+ expect(shell.openExternal).not.toHaveBeenCalled()
+ })
+})
+
+describe('appLinkTarget', () => {
+ it.each([
+ 'file:///etc/passwd',
+ 'javascript:alert(1)',
+ 'ms-msdt:id',
+ 'search-ms:query=x',
+ 'ms-cxh-full://0',
+ 'local+app://x',
+ 'jnlp://evil.example/app.jnlp',
+ 'x-man-page://ls',
+ 'c:/Windows/System32/calc.exe',
+ 'https://example.com',
+ ])('refuses %s', (url) => {
+ expect(appLinkTarget(url)).toBeNull()
+ })
+
+ it('refuses credentials, control characters, oversize URLs, and unsafe mail links', () => {
+ expect(appLinkTarget('zoommtg://user:pass@zoom.us/join')).toBeNull()
+ expect(appLinkTarget('zoommtg://zoom.us/join?x=1\n')).toBeNull()
+ expect(appLinkTarget('zoommtg://zoom.us/join?x= 1')).toBeNull()
+ expect(appLinkTarget(`zoommtg://zoom.us/join?x=${'a'.repeat(2_048)}`)).toBeNull()
+ expect(appLinkTarget('mailto:a@example.com?attach=/etc/passwd')).toBeNull()
+ })
+
+ it('hands the operating system the parser-rebuilt URL of an app link', () => {
+ expect(appLinkTarget('ZoomMtg://zoom.us/join?confno=123&pwd=abc')).toEqual({
+ url: 'zoommtg://zoom.us/join?confno=123&pwd=abc',
+ scheme: 'zoommtg',
+ })
+ expect(appLinkTarget('mailto:a@example.com?subject=Hi')?.scheme).toBe('mailto')
+ })
+
+ it('opens an app link only on the app-link path, and only in its rebuilt form', async () => {
+ vi.mocked(shell.openExternal).mockClear()
+ await expect(openExternalSafe('zoommtg://zoom.us/join')).resolves.toBe(false)
+ await expect(
+ openExternalSafe('ZoomMtg://zoom.us/join', false, { appLink: true })
+ ).resolves.toBe(false)
+ await expect(
+ openExternalSafe('zoommtg://zoom.us/join', false, { appLink: true })
+ ).resolves.toBe(true)
+ expect(shell.openExternal).toHaveBeenCalledExactlyOnceWith('zoommtg://zoom.us/join')
+ })
})
diff --git a/apps/desktop/src/main/navigation.ts b/apps/desktop/src/main/navigation.ts
index 3131a5c125e..5d69e18e4d6 100644
--- a/apps/desktop/src/main/navigation.ts
+++ b/apps/desktop/src/main/navigation.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { isLoopbackHostname } from '@sim/security/ssrf'
+import { isValidEmailSyntax } from '@sim/utils/string'
import { shell } from 'electron'
import { scrubUrl } from '@/main/observability'
@@ -112,6 +113,9 @@ export function classifyNavigation(rawUrl: string, ctx: NavigationContext): Main
if (rawUrl === 'about:blank') {
return 'in-app'
}
+ if (isMailtoUrl(rawUrl)) {
+ return 'external'
+ }
const url = parseHttpUrl(rawUrl)
if (!url) {
return 'deny'
@@ -146,9 +150,12 @@ export function classifyWindowOpen(
frameName: string,
appOrigin: string
): WindowOpenAction {
- if (rawUrl === '' || rawUrl === 'about:blank') {
+ if (isBlankPopupUrl(rawUrl)) {
return 'popup-blank'
}
+ if (isMailtoUrl(rawUrl)) {
+ return 'external'
+ }
const url = parseHttpUrl(rawUrl)
if (!url) {
return 'deny'
@@ -174,7 +181,7 @@ export function classifyWindowOpen(
* by the blank-then-assign pattern.
*/
export function classifyBlankChildNavigation(rawUrl: string, appOrigin: string): BlankChildAction {
- if (rawUrl === '' || rawUrl === 'about:blank') {
+ if (isBlankPopupUrl(rawUrl)) {
return 'ignore'
}
const url = parseHttpUrl(rawUrl)
@@ -187,6 +194,54 @@ export function classifyBlankChildNavigation(rawUrl: string, appOrigin: string):
return 'external'
}
+/** The URL `window.open('')` and `window.open('about:blank')` hand a popup handler. */
+export function isBlankPopupUrl(url: string): boolean {
+ return url === '' || url === 'about:blank'
+}
+
+function isMailtoUrl(raw: string): boolean {
+ return /^mailto:/i.test(raw)
+}
+
+/** RFC 6068 fields a mail client fills in; anything else (`attach=`, arbitrary headers) is refused. */
+const MAILTO_FIELDS = new Set(['to', 'cc', 'bcc', 'subject', 'body'])
+
+function isMailtoAddressList(encoded: string): boolean {
+ if (encoded === '') return true
+ try {
+ return decodeURIComponent(encoded)
+ .split(',')
+ .every((address) => isValidEmailSyntax(address.trim()))
+ } catch {
+ return false
+ }
+}
+
+/**
+ * A `mailto:` of printable ASCII, plain addresses, and compose fields only: some mail clients
+ * honour `attach=` and other headers, which would let a page attach a local file.
+ */
+function isSafeMailtoUrl(raw: string): boolean {
+ if (!isMailtoUrl(raw) || raw.length > 4_096 || !/^[\x21-\x7e]+$/.test(raw)) {
+ return false
+ }
+ const [addresses = '', query, ...rest] = raw.slice(7).split('?')
+ if (rest.length > 0 || addresses.includes('#') || !isMailtoAddressList(addresses)) {
+ return false
+ }
+ if (query === undefined) return true
+ for (const field of query.split('&')) {
+ const separator = field.indexOf('=')
+ const name = (separator === -1 ? field : field.slice(0, separator)).toLowerCase()
+ if (!MAILTO_FIELDS.has(name)) return false
+ const value = separator === -1 ? '' : field.slice(separator + 1)
+ if ((name === 'to' || name === 'cc' || name === 'bcc') && !isMailtoAddressList(value)) {
+ return false
+ }
+ }
+ return true
+}
+
/**
* Validates a URL for handing to the system browser: https always, http only
* for loopback hosts when explicitly allowed, never credentials in the URL,
@@ -212,11 +267,111 @@ export function isSafeExternalUrl(raw: string, allowHttpLocalhost = false): bool
}
/**
- * Opens a URL in the system browser after validation. Every openExternal in
- * the app goes through here — menu items, IPC, and navigation policy.
+ * Schemes that reach local files, network shares, script, browser internals,
+ * or Windows/macOS handlers with a record of remote-code abuse. A browser page
+ * never opens them, even with a registered handler and the user's click.
+ */
+const DENIED_APP_LINK_SCHEMES: ReadonlySet = new Set([
+ 'about',
+ 'afp',
+ 'applescript',
+ 'blob',
+ 'chrome',
+ 'chrome-extension',
+ 'chrome-untrusted',
+ 'data',
+ 'dav',
+ 'devtools',
+ 'disk',
+ 'disks',
+ 'file',
+ 'filesystem',
+ 'ftp',
+ 'hcp',
+ 'help',
+ 'ie.http',
+ 'its',
+ 'jar',
+ 'javascript',
+ 'jnlp',
+ 'localfs',
+ 'mk',
+ 'ms-appinstaller',
+ 'ms-excel',
+ 'ms-help',
+ 'ms-its',
+ 'ms-msdt',
+ 'ms-officecmd',
+ 'ms-powerpoint',
+ 'ms-settings',
+ 'ms-word',
+ 'nfs',
+ 'nntp',
+ 'res',
+ 'search',
+ 'search-ms',
+ 'shell',
+ 'sim-shell',
+ 'smb',
+ 'vbscript',
+ 'view-source',
+ 'vnd.ms.radio',
+ 'webdav',
+ 'x-man-page',
+])
+const DENIED_APP_LINK_SCHEME_PREFIXES = ['ms-cxh', 'local+'] as const
+const MAX_APP_LINK_LENGTH = 2_048
+/** C0 controls, space, DEL, and C1 controls: never valid in a URL handed to the OS. */
+const URL_CONTROL_CHARACTERS = /[\u0000-\u0020\u007f-\u009f]/
+
+function isDeniedAppLinkScheme(scheme: string): boolean {
+ return (
+ scheme.length < 2 ||
+ scheme === 'http' ||
+ scheme === 'https' ||
+ DENIED_APP_LINK_SCHEMES.has(scheme) ||
+ DENIED_APP_LINK_SCHEME_PREFIXES.some((prefix) => scheme.startsWith(prefix))
+ )
+}
+
+/**
+ * The link to another app a browser page may hand the OS, rebuilt by the URL
+ * parser, or null when the scheme is denied or the URL is malformed, oversized,
+ * carries credentials or control characters, or is an unsafe mail link.
+ */
+export function appLinkTarget(raw: unknown): { url: string; scheme: string } | null {
+ if (typeof raw !== 'string' || raw.length > MAX_APP_LINK_LENGTH) return null
+ if (URL_CONTROL_CHARACTERS.test(raw)) return null
+ let url: URL
+ try {
+ url = new URL(raw)
+ } catch {
+ return null
+ }
+ const scheme = url.protocol.slice(0, -1).toLowerCase()
+ if (!/^[a-z][a-z0-9+.-]*$/.test(scheme) || isDeniedAppLinkScheme(scheme)) return null
+ if (url.username || url.password) return null
+ if (scheme === 'mailto' && !isSafeMailtoUrl(raw)) return null
+ const rebuilt = url.href
+ if (rebuilt.length > MAX_APP_LINK_LENGTH || URL_CONTROL_CHARACTERS.test(rebuilt)) return null
+ return { url: rebuilt, scheme }
+}
+
+/**
+ * Opens a URL in the system browser, a `mailto:` in the system mail client, or, with
+ * `appLink`, a validated link to another app, after validation. Every openExternal in the
+ * app goes through here — menu items, IPC, navigation policy, and browser-tab app links.
*/
-export async function openExternalSafe(raw: string, allowHttpLocalhost = false): Promise {
- if (!isSafeExternalUrl(raw, allowHttpLocalhost)) {
+export async function openExternalSafe(
+ raw: string,
+ allowHttpLocalhost = false,
+ { appLink = false }: { appLink?: boolean } = {}
+): Promise {
+ if (
+ !isSafeExternalUrl(raw, allowHttpLocalhost) &&
+ !isSafeMailtoUrl(raw) &&
+ !(appLink && appLinkTarget(raw)?.url === raw)
+ ) {
logger.warn('Blocked unsafe external URL', { url: scrubUrl(raw) })
return false
}
diff --git a/apps/desktop/src/main/security-guards.ts b/apps/desktop/src/main/security-guards.ts
index 93d719e5e9b..e69dbb8c669 100644
--- a/apps/desktop/src/main/security-guards.ts
+++ b/apps/desktop/src/main/security-guards.ts
@@ -1,6 +1,11 @@
import { createLogger } from '@sim/logger'
-import type { WebContents } from 'electron'
+import type { WebContents, WebFrameMain } from 'electron'
import { app } from 'electron'
+import {
+ handleBrowserLogin,
+ handleSelectClientCertificate,
+ requestExternalLaunch,
+} from '@/main/browser-agent/page-prompts'
import { isAgentWebContents } from '@/main/browser-agent/registry'
import { trackInputActivity } from '@/main/input-activity'
import { classifyNavigation, openExternalSafe } from '@/main/navigation'
@@ -21,14 +26,25 @@ export interface GuardDeps {
* Applies the top-level navigation policy to both will-navigate and
* will-redirect — OAuth chains can be redirect-injected, so redirects get the
* same classifier as direct navigations.
+ *
+ * Agent-browser tabs never navigate off http(s). A link to another app (Zoom,
+ * Teams, mail) becomes a launch request the user decides only when the tab's
+ * own top-level document initiated it: a navigation a frame initiated, even
+ * one targeting the top level, is refused, as is the `openExternal`
+ * permission that backs frame launches.
*/
export function attachNavigationGuards(contents: WebContents, deps: GuardDeps): void {
- const handle = (event: { preventDefault(): void }, url: string) => {
+ const handle = (
+ event: { preventDefault(): void; isMainFrame?: boolean; initiator?: WebFrameMain | null },
+ url: string
+ ) => {
if (isAgentWebContents(contents)) {
const hasAllowedAgentBrowserScheme = /^https?:/i.test(url)
if (!hasAllowedAgentBrowserScheme) {
event.preventDefault()
- logger.warn('Denied non-http navigation in agent browser', { url: scrubUrl(url) })
+ if (event.isMainFrame === true) {
+ requestExternalLaunch(contents, url, event.initiator)
+ } else logger.warn('Denied non-http navigation in agent browser', { url: scrubUrl(url) })
}
return
}
@@ -69,7 +85,9 @@ export function attachNavigationGuards(contents: WebContents, deps: GuardDeps):
* Agent-browser tabs swap the classifier for a free-http(s) policy (their job
* is browsing arbitrary sites, isolated on their own partition). TLS errors
* are always fatal when packaged; self-host private CAs must be
- * system-trusted rather than bypassed in-app.
+ * system-trusted rather than bypassed in-app. HTTP authentication and client
+ * certificate requests from agent-browser tabs wait on the user; every other
+ * contents keeps Electron's defaults.
*/
export function installGlobalGuards(deps: GuardDeps): void {
app.on('web-contents-created', (_event, contents) => {
@@ -87,6 +105,9 @@ export function installGlobalGuards(deps: GuardDeps): void {
attachNavigationGuards(contents, deps)
})
+ app.on('login', handleBrowserLogin)
+ app.on('select-client-certificate', handleSelectClientCertificate)
+
app.on('certificate-error', (event, _webContents, url, error, _certificate, callback) => {
event.preventDefault()
callback(false)
diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts
index c9334dab154..9aafbf7ff2c 100644
--- a/apps/desktop/src/main/terminal/index.ts
+++ b/apps/desktop/src/main/terminal/index.ts
@@ -37,7 +37,11 @@ import {
isResourceTabSelectionShortcut,
resourceTabTargetIndex,
} from '@/main/resource-shortcuts'
-import { readForegroundProcessGroup, signalProcessGroup } from '@/main/terminal/process-group'
+import {
+ type ForegroundProcessGroup,
+ readForegroundProcessGroup,
+ signalProcessGroup,
+} from '@/main/terminal/process-group'
import type { RunLedger } from '@/main/terminal/run-ledger'
import { elide, type ShellStartupBounds, TerminalSession } from '@/main/terminal/session'
import {
@@ -167,7 +171,7 @@ export interface TerminalServiceOptions {
}
interface TerminalProcessGroups {
- foreground(shellPid: number): Promise
+ foreground(shellPid: number): Promise
signal(pgid: number, signal: NodeJS.Signals): void
}
@@ -176,6 +180,22 @@ const OS_PROCESS_GROUPS: TerminalProcessGroups = {
signal: signalProcessGroup,
}
+/** The terminal work a restart would cut off. */
+export interface TerminalWork {
+ /** The command line holding each busy terminal's foreground, `''` when unannounced. */
+ commands: string[]
+ /** Terminals whose foreground could not be read, which may be running anything. */
+ uncheckedCount: number
+}
+
+async function foregroundGroupId(
+ groups: TerminalProcessGroups,
+ shellPid: number
+): Promise {
+ const foreground = await groups.foreground(shellPid)
+ return foreground.kind === 'group' ? foreground.pgid : null
+}
+
/**
* One tool call's Stop: remembered if it arrives before the call's command starts, and handed to
* whatever is running once it does.
@@ -303,6 +323,32 @@ export class TerminalService {
this.cwdTimer = null
}
+ /**
+ * What each terminal is running. A shell without integration (fish, sh) announces nothing, so
+ * the OS's foreground process group answers for it; one the OS could not be asked about is
+ * reported as unchecked rather than idle.
+ */
+ async runningWork(): Promise {
+ const groups = this.options.processGroups ?? OS_PROCESS_GROUPS
+ const perSession = await Promise.all(
+ [...this.sessions.values()].map(
+ async (session): Promise<{ command: string | null; unchecked: boolean }> => {
+ if (session.isBusy) return { command: session.foreground ?? '', unchecked: false }
+ if (session.hasShellIntegration) return { command: null, unchecked: false }
+ const foreground = await groups.foreground(session.pid)
+ return {
+ command: foreground.kind === 'group' ? '' : null,
+ unchecked: foreground.kind === 'unknown',
+ }
+ }
+ )
+ )
+ return {
+ commands: perSession.flatMap(({ command }) => (command === null ? [] : [command])),
+ uncheckedCount: perSession.filter(({ unchecked }) => unchecked).length,
+ }
+ }
+
getTabs(): TerminalTabsState {
if (this.sessions.size === 0) return EMPTY_TABS
return {
@@ -973,12 +1019,12 @@ export class TerminalService {
private async stopCommand(session: TerminalSession, toolCallId: string): Promise {
if (session.agentCommandToolCallId !== toolCallId) return
const groups = this.options.processGroups ?? OS_PROCESS_GROUPS
- const pgid = await groups.foreground(session.pid)
+ const pgid = await foregroundGroupId(groups, session.pid)
if (session.agentCommandToolCallId !== toolCallId) return
session.kill('SIGINT')
for (const escalation of ['SIGTERM', 'SIGKILL'] as const) {
if (await this.commandEnds(session, toolCallId, STOP_ESCALATION_MS)) return
- if (pgid === null || (await groups.foreground(session.pid)) !== pgid) return
+ if (pgid === null || (await foregroundGroupId(groups, session.pid)) !== pgid) return
if (session.agentCommandToolCallId !== toolCallId) return
logger.info('Stopped command ignored the previous signal; escalating', {
toolCallId,
diff --git a/apps/desktop/src/main/terminal/process-group.test.ts b/apps/desktop/src/main/terminal/process-group.test.ts
new file mode 100644
index 00000000000..efefec57e4b
--- /dev/null
+++ b/apps/desktop/src/main/terminal/process-group.test.ts
@@ -0,0 +1,15 @@
+import { spawn } from 'node:child_process'
+import { once } from 'node:events'
+import { describe, expect, it } from 'vitest'
+import { readForegroundProcessGroup } from '@/main/terminal/process-group'
+
+describe.skipIf(process.platform === 'win32')('readForegroundProcessGroup', () => {
+ it('reads a shell that has already exited as idle, not unknown', async () => {
+ const child = spawn('true')
+ await once(child, 'exit')
+ const exitedPid = child.pid
+ if (exitedPid === undefined) throw new Error('The child never started')
+
+ await expect(readForegroundProcessGroup(exitedPid)).resolves.toEqual({ kind: 'shell' })
+ })
+})
diff --git a/apps/desktop/src/main/terminal/process-group.ts b/apps/desktop/src/main/terminal/process-group.ts
index 6e5a4e10a87..4c040383cb2 100644
--- a/apps/desktop/src/main/terminal/process-group.ts
+++ b/apps/desktop/src/main/terminal/process-group.ts
@@ -11,11 +11,25 @@ const logger = createLogger('DesktopTerminalProcessGroup')
const LOOKUP_TIMEOUT_MS = 2_000
/**
- * The tty's foreground process group, read from `ps`. Null when it cannot be read, or when the
- * shell itself holds the foreground (it is sitting at its prompt).
+ * Who holds a terminal's foreground: the shell at its prompt (or a shell that has already
+ * exited), another process group, or `unknown` when the OS could not be asked.
*/
-export function readForegroundProcessGroup(shellPid: number): Promise {
- if (!Number.isInteger(shellPid) || shellPid <= 0) return Promise.resolve(null)
+export type ForegroundProcessGroup =
+ | { kind: 'shell' }
+ | { kind: 'group'; pgid: number }
+ | { kind: 'unknown' }
+
+const SHELL: ForegroundProcessGroup = { kind: 'shell' }
+const UNKNOWN: ForegroundProcessGroup = { kind: 'unknown' }
+
+/**
+ * The tty's foreground process group, read from `ps`. A pid `ps` no longer knows is a shell
+ * that has exited, so nothing can be running in it. Windows has no `ps` or tty process groups,
+ * so a Windows shell always reads as holding its own foreground.
+ */
+export function readForegroundProcessGroup(shellPid: number): Promise {
+ if (process.platform === 'win32') return Promise.resolve(SHELL)
+ if (!Number.isInteger(shellPid) || shellPid <= 0) return Promise.resolve(UNKNOWN)
return new Promise((resolve) => {
let child: ReturnType
try {
@@ -23,12 +37,12 @@ export function readForegroundProcessGroup(shellPid: number): Promise {
+ const finish = (value: ForegroundProcessGroup) => {
if (settled) return
settled = true
clearTimeout(timer)
@@ -36,15 +50,22 @@ export function readForegroundProcessGroup(shellPid: number): Promise {
child.kill('SIGKILL')
- finish(null)
+ finish(UNKNOWN)
}, LOOKUP_TIMEOUT_MS)
child.stdout?.on('data', (chunk: Buffer) => {
stdout += chunk.toString()
})
- child.on('error', () => finish(null))
- child.on('close', () => {
- const pgid = Number.parseInt(stdout.trim(), 10)
- finish(Number.isInteger(pgid) && pgid > 0 && pgid !== shellPid ? pgid : null)
+ child.on('error', () => finish(UNKNOWN))
+ child.on('close', (code) => {
+ const output = stdout.trim()
+ if (code !== 0 || !output) {
+ finish(code === 1 && !output ? SHELL : UNKNOWN)
+ return
+ }
+ const pgid = Number.parseInt(output, 10)
+ if (!Number.isInteger(pgid)) finish(UNKNOWN)
+ else if (pgid <= 0 || pgid === shellPid) finish(SHELL)
+ else finish({ kind: 'group', pgid })
})
})
}
diff --git a/apps/desktop/src/main/terminal/registry.ts b/apps/desktop/src/main/terminal/registry.ts
index 22c91489830..8686b023c63 100644
--- a/apps/desktop/src/main/terminal/registry.ts
+++ b/apps/desktop/src/main/terminal/registry.ts
@@ -18,6 +18,7 @@ import {
TerminalService,
type TerminalServiceOptions,
type TerminalSink,
+ type TerminalWork,
} from '@/main/terminal'
import type { RunLedger, RunRecord } from '@/main/terminal/run-ledger'
import { recordedRunState, stopRecordedRun } from '@/main/terminal/tmux'
@@ -375,6 +376,17 @@ export class TerminalRegistry {
return true
}
+ /** What every chat's terminals are running. */
+ async runningWork(): Promise {
+ const perService = await Promise.all(
+ [...this.entries.values()].map((entry) => entry.service.runningWork())
+ )
+ return {
+ commands: perService.flatMap((work) => work.commands),
+ uncheckedCount: perService.reduce((total, work) => total + work.uncheckedCount, 0),
+ }
+ }
+
/**
* Stops every command the agent started in any chat's terminals; the user's own are untouched.
* That includes tmux runs no live terminal holds any more: a chat put away, or a previous
diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts
index 6ff6a0a53af..32a77f6c7a8 100644
--- a/apps/desktop/src/main/terminal/service.test.ts
+++ b/apps/desktop/src/main/terminal/service.test.ts
@@ -4,6 +4,7 @@ import { join } from 'node:path'
import { sleep } from '@sim/utils/helpers'
import { describe, expect, it, vi } from 'vitest'
import { TerminalService } from '@/main/terminal'
+import type { ForegroundProcessGroup } from '@/main/terminal/process-group'
import { createRunLedger } from '@/main/terminal/run-ledger'
/**
@@ -104,6 +105,9 @@ const { stubSessions } = vi.hoisted(() => ({
clearScrollback(): void
/** Whether Ctrl-C ends the running command, as it does for most programs. */
setInterruptible(interruptible: boolean): void
+ /** Whether the shell announces its commands, as zsh and bash do and fish does not. */
+ setShellIntegration(on: boolean): void
+ readonly pid: number
/** Ends the running command the way its process exiting would. */
finishRun(exitCode: number): void
/** Returns the run as still going, its command left running, as a wait that ran out does. */
@@ -142,6 +146,7 @@ vi.mock('@/main/terminal/session', async () => {
disposed: false,
busy: false,
interruptible: true,
+ shellIntegration: true,
toolCallId: null as string | null,
resolveRun: null as ((result: Record) => void) | null,
}
@@ -159,6 +164,9 @@ vi.mock('@/main/terminal/session', async () => {
setInterruptible: (interruptible: boolean) => {
state.interruptible = interruptible
},
+ setShellIntegration: (on: boolean) => {
+ state.shellIntegration = on
+ },
finishRun,
handBack: () => {
const resolve = state.resolveRun
@@ -204,7 +212,9 @@ vi.mock('@/main/terminal/session', async () => {
get isBusy() {
return state.busy
},
- hasShellIntegration: true,
+ get hasShellIntegration() {
+ return state.shellIntegration
+ },
/** Real sessions poll the pty here; a stub's cwd only ever changes on open. */
refreshCwd: async () => {},
dispose: () => {
@@ -477,6 +487,40 @@ describe('a shell that ends by itself', () => {
})
})
+describe('running work before a restart', () => {
+ function serviceWithForeground(read: (shellPid: number) => Promise) {
+ const terminal = new TerminalService({
+ loadCwd: () => '/tmp',
+ processGroups: { foreground: vi.fn(read), signal: vi.fn() },
+ })
+ const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 })
+ const session = stubSessions.get(activeTerminalId as string)
+ if (!session) throw new Error('No stub session')
+ return { terminal, session }
+ }
+
+ it('asks the OS about a shell that announces nothing, and trusts one that does', async () => {
+ const { terminal, session } = serviceWithForeground(async () => ({ kind: 'group', pgid: 4242 }))
+ session.setShellIntegration(false)
+ await expect(terminal.runningWork()).resolves.toEqual({ commands: [''], uncheckedCount: 0 })
+
+ session.setShellIntegration(true)
+ await expect(terminal.runningWork()).resolves.toEqual({ commands: [], uncheckedCount: 0 })
+ })
+
+ it('counts a shell at its own prompt as idle', async () => {
+ const { terminal, session } = serviceWithForeground(async () => ({ kind: 'shell' }))
+ session.setShellIntegration(false)
+ await expect(terminal.runningWork()).resolves.toEqual({ commands: [], uncheckedCount: 0 })
+ })
+
+ it('reports a terminal whose foreground could not be read as unchecked, never idle', async () => {
+ const { terminal, session } = serviceWithForeground(async () => ({ kind: 'unknown' }))
+ session.setShellIntegration(false)
+ await expect(terminal.runningWork()).resolves.toEqual({ commands: [], uncheckedCount: 1 })
+ })
+})
+
describe('stopping a tool call', () => {
const COMMAND_GROUP = 4242
@@ -484,7 +528,10 @@ describe('stopping a tool call', () => {
/** The tty's foreground group as the OS would report it at each read. */
let foreground: number | null = COMMAND_GROUP
const processGroups = {
- foreground: vi.fn(async (_shellPid: number) => foreground),
+ foreground: vi.fn(
+ async (_shellPid: number): Promise =>
+ foreground === null ? { kind: 'unknown' } : { kind: 'group', pgid: foreground }
+ ),
signal: vi.fn((_pgid: number, _signal: NodeJS.Signals) => {}),
}
const terminal = new TerminalService({ loadCwd: () => '/tmp', processGroups })
@@ -535,6 +582,20 @@ describe('stopping a tool call', () => {
await expect(running).resolves.toMatchObject({ ok: true, result: { exitCode: 143 } })
})
+ it('interrupts but never escalates when the foreground cannot be read', async () => {
+ const { terminal, session, processGroups, setForeground } = cancellableService()
+ session.setInterruptible(false)
+ const { running } = await startRun(terminal, session, 'call-unreadable')
+ setForeground(null)
+
+ await terminal.cancelTool('call-unreadable')
+
+ expect(session.kill).toHaveBeenCalledWith('SIGINT')
+ expect(processGroups.signal).not.toHaveBeenCalled()
+ session.finishRun(0)
+ await running
+ })
+
it('never signals a different command that took the foreground meanwhile', async () => {
const { terminal, session, processGroups, setForeground } = cancellableService()
session.setInterruptible(false)
diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts
index 5ed6241ccfb..723cc3d38ae 100644
--- a/apps/desktop/src/main/updater.test.ts
+++ b/apps/desktop/src/main/updater.test.ts
@@ -112,6 +112,10 @@ describe('initUpdater state machine', () => {
probeOriginFeed?: (feedUrl: string) => Promise
beforeInstall?: () => Promise
setRelaunchPending?: (pending: boolean) => void
+ /** Work the restart would interrupt, as the in-flight check names it. */
+ withRunningWork?: string
+ /** Overrides the in-flight check, e.g. to make it fail. */
+ describeInFlightWork?: () => Promise
}) {
const states: DesktopUpdateState[] = []
const handle = initUpdater({
@@ -127,6 +131,9 @@ describe('initUpdater state machine', () => {
platform: 'darwin',
beforeInstall: options?.beforeInstall,
setRelaunchPending: options?.setRelaunchPending,
+ describeInFlightWork:
+ options?.describeInFlightWork ??
+ (async () => (options?.withRunningWork ? [options.withRunningWork] : [])),
})
// Engine selection (signature detection) resolves asynchronously.
await vi.advanceTimersByTimeAsync(0)
@@ -151,7 +158,7 @@ describe('initUpdater state machine', () => {
vi.useRealTimers()
})
- it('walks check -> validated download -> ready and installs only after confirmation', async () => {
+ it('walks check -> validated download -> ready and installs on one click when nothing is running', async () => {
const { handle, states } = await createUpdater()
expect(handle.getState()).toEqual({ status: 'idle' })
@@ -183,21 +190,67 @@ describe('initUpdater state machine', () => {
handle.install()
await vi.advanceTimersByTimeAsync(0)
+ expect(dialog.showMessageBox).not.toHaveBeenCalled()
+ expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
+ })
+
+ it('asks once, naming the running work, and keeps the update ready on Not Now', async () => {
+ const setRelaunchPending = vi.fn()
+ const { handle } = await createUpdater({
+ setRelaunchPending,
+ withRunningWork: 'claude is still running in a terminal.',
+ })
+ await stageUpdate(handle, '2.0.0')
+ vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 1, checkboxChecked: false })
+
+ handle.install()
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(dialog.showMessageBox).toHaveBeenCalledTimes(1)
expect(dialog.showMessageBox).toHaveBeenCalledWith(
expect.objectContaining({
- buttons: ['Later', 'Restart and update'],
+ message: expect.stringContaining('claude is still running in a terminal.'),
+ buttons: ['Restart and Update', 'Not Now'],
defaultId: 0,
- cancelId: 0,
+ cancelId: 1,
})
)
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
+ expect(setRelaunchPending).not.toHaveBeenCalled()
+ expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' })
+ expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true)
+ })
- vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({
- response: 1,
- checkboxChecked: false,
+ it('asks before restarting when the check for running work fails', async () => {
+ const { handle } = await createUpdater({
+ describeInFlightWork: async () => {
+ throw new Error('terminal probe failed')
+ },
})
+ await stageUpdate(handle, '2.0.0')
+ vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 1, checkboxChecked: false })
+
handle.install()
await vi.advanceTimersByTimeAsync(0)
+
+ expect(dialog.showMessageBox).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: "Sim couldn't check whether restarting would interrupt any work.",
+ })
+ )
+ expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
+ })
+
+ it('installs a required update without offering to wait for running work', async () => {
+ const { handle } = await createUpdater({
+ withRunningWork: 'claude is still running in a terminal.',
+ })
+ await stageUpdate(handle, '2.0.0')
+
+ handle.install({ mandatory: true })
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(dialog.showMessageBox).not.toHaveBeenCalled()
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
})
@@ -212,7 +265,9 @@ describe('initUpdater state machine', () => {
resolveConfirmation = resolve
})
)
- const { handle } = await createUpdater()
+ const { handle } = await createUpdater({
+ withRunningWork: 'An agent is still working on this computer.',
+ })
handle.check()
await vi.advanceTimersByTimeAsync(0)
@@ -222,11 +277,12 @@ describe('initUpdater state machine', () => {
vi.mocked(dialog.showMessageBox).mockClear()
handle.install()
handle.install()
+ await vi.advanceTimersByTimeAsync(0)
expect(dialog.showMessageBox).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
- resolveConfirmation({ response: 1, checkboxChecked: false })
+ resolveConfirmation({ response: 0, checkboxChecked: false })
await vi.advanceTimersByTimeAsync(0)
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
})
@@ -260,10 +316,6 @@ describe('initUpdater state machine', () => {
handle.check()
emit('update-downloaded', { version: '2.0.0' })
emitSquirrel('update-downloaded')
- vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({
- response: 1,
- checkboxChecked: false,
- })
handle.install()
await vi.advanceTimersByTimeAsync(0)
@@ -292,10 +344,6 @@ describe('initUpdater state machine', () => {
emit('update-downloaded', { version: '2.0.0' })
emitSquirrel('update-downloaded')
await vi.advanceTimersByTimeAsync(10_000)
- vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({
- response: 1,
- checkboxChecked: false,
- })
handle.install()
await vi.advanceTimersByTimeAsync(0)
@@ -328,7 +376,6 @@ describe('initUpdater state machine', () => {
})
)
await vi.advanceTimersByTimeAsync(10_000)
- vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 1, checkboxChecked: false })
handle.install()
await vi.advanceTimersByTimeAsync(0)
@@ -343,10 +390,6 @@ describe('initUpdater state machine', () => {
it('bypasses renderer unload guards only after teardown succeeds', async () => {
const setRelaunchPending = vi.fn()
- vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({
- response: 1,
- checkboxChecked: false,
- })
const { handle } = await createUpdater({
beforeInstall: async () => {},
setRelaunchPending,
@@ -446,7 +489,9 @@ describe('initUpdater state machine', () => {
() => {
throw new Error('Restart confirmation did not initialize')
}
- const { handle } = await createUpdater()
+ const { handle } = await createUpdater({
+ withRunningWork: 'A window has unsaved changes.',
+ })
await stageUpdate(handle, '2.0.0')
await vi.advanceTimersByTimeAsync(10_000)
@@ -457,11 +502,12 @@ describe('initUpdater state machine', () => {
})
)
handle.install()
+ await vi.advanceTimersByTimeAsync(0)
emit('update-available', { version: '2.1.0' })
emit('update-downloaded', { version: '2.1.0' })
emitSquirrel('update-downloaded')
expect(handle.getState()).toEqual({ status: 'ready', version: '2.1.0' })
- resolveConfirmation({ response: 1, checkboxChecked: false })
+ resolveConfirmation({ response: 0, checkboxChecked: false })
await vi.advanceTimersByTimeAsync(0)
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
@@ -477,7 +523,6 @@ describe('initUpdater state machine', () => {
})
await stageUpdate(handle, '2.0.0')
await vi.advanceTimersByTimeAsync(10_000)
- vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 1, checkboxChecked: false })
handle.install()
await vi.advanceTimersByTimeAsync(0)
@@ -826,6 +871,41 @@ describe('checkForUpdatesInteractive', () => {
vi.useRealTimers()
})
+ function readyHandle(): UpdaterHandle {
+ return {
+ setAutoDownload: () => {},
+ getState: () => ({ status: 'ready', version: '9.9.9' }),
+ check: vi.fn(),
+ install: vi.fn(),
+ onState: () => () => {},
+ }
+ }
+
+ it('asks before restarting when the check finds an update already staged', async () => {
+ const handle = readyHandle()
+
+ checkForUpdatesInteractive({ getWindow: () => null, events, handle })
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(dialog.showMessageBox).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Sim 9.9.9 is ready to install',
+ buttons: ['Restart to Update', 'Later'],
+ })
+ )
+ expect(handle.install).not.toHaveBeenCalled()
+ })
+
+ it('installs a staged update only once the user chooses Restart to Update', async () => {
+ const handle = readyHandle()
+ vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 0, checkboxChecked: false })
+
+ checkForUpdatesInteractive({ getWindow: () => null, events, handle })
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(handle.install).toHaveBeenCalledTimes(1)
+ })
+
it('fails a hung interactive check after twelve seconds instead of waiting thirty', async () => {
const handle: UpdaterHandle = {
setAutoDownload: () => {},
diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts
index 05e786dd541..1c084971a27 100644
--- a/apps/desktop/src/main/updater.ts
+++ b/apps/desktop/src/main/updater.ts
@@ -251,12 +251,22 @@ export interface UpdaterDeps {
platform?: NodeJS.Platform
/** Flushes desktop-owned state before Squirrel terminates the process. */
beforeInstall?: () => Promise
- /** Bypasses renderer unload guards only after the user confirms a relaunch. */
+ /** Bypasses renderer unload guards once a relaunch is committed. */
setRelaunchPending?: (pending: boolean) => void
+ /**
+ * Names the work a restart would interrupt (running commands, agent
+ * actions, unsaved edits). A restart asks first only when this is non-empty.
+ */
+ describeInFlightWork?: () => Promise
/** Persists the natively staged version so the next process can verify installation. */
installStatePath?: string
}
+interface UpdateInstallOptions {
+ /** The shell is below the server's minimum version, so waiting is not an option. */
+ mandatory?: boolean
+}
+
export interface UpdaterHandle {
setAutoDownload(enabled: boolean): void
/** Current pipeline state for the renderer update UI. */
@@ -270,7 +280,7 @@ export interface UpdaterHandle {
* Quit and install a downloaded update (`ready`), or open the manual
* download for an `available` update on a shell that can't self-update.
*/
- install(): void
+ install(options?: UpdateInstallOptions): void
/** Main-process state subscription (menu feedback). Returns unsubscribe. */
onState(callback: (state: DesktopUpdateState) => void): () => void
}
@@ -338,7 +348,7 @@ interface UpdateEngine {
/** `interactive` requests must always publish a terminal state for their waiting UI. */
check(interactive?: boolean): void
advance(): void
- install(): void
+ install(options: { mandatory: boolean }): void
setAutoDownload(enabled: boolean): void
}
@@ -470,35 +480,48 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
})
}
- const confirmAndInstall = () => {
+ /** Installs at once unless the restart would interrupt nameable work; then asks once. */
+ const installUnlessInterrupting = async ({ mandatory }: { mandatory: boolean }) => {
if (installInFlight || installConfirmationInFlight || state.status !== 'ready') return
installConfirmationInFlight = true
- const version = state.version
- const options: Electron.MessageBoxOptions = {
- type: 'question',
- buttons: ['Later', 'Restart and update'],
- defaultId: 0,
- cancelId: 0,
- message: version ? `Restart to install Sim ${version}?` : 'Restart to update Sim?',
- detail:
- 'Sim will close all app windows while it updates. Running terminal commands, browser activity, downloads, uploads, and unsaved edits may be interrupted. Choose Later to install the update the next time you quit Sim.',
- }
- const win = deps.getWindow()
- const confirmation = win ? showShellDialog(win, options) : showShellDialog(options)
- void confirmation
- .then(({ response }) => {
- if (response === 1 && state.status === 'ready') {
- quitAndInstall()
+ try {
+ let work: string[] = []
+ if (!mandatory) {
+ try {
+ work = (await deps.describeInFlightWork?.()) ?? []
+ } catch (error) {
+ logger.warn('Could not check for work a restart would interrupt', {
+ message: getErrorMessage(error, 'unknown'),
+ })
+ work = ["Sim couldn't check whether restarting would interrupt any work."]
}
+ }
+ if (work.length > 0) {
+ const version = state.version
+ const options: Electron.MessageBoxOptions = {
+ type: 'warning',
+ title: version ? `Restart to install Sim ${version}?` : 'Restart to update Sim?',
+ message: work.join(' '),
+ detail:
+ 'Restarting now interrupts this work. Choose Not Now to finish first; the update also installs the next time you quit Sim.',
+ buttons: ['Restart and Update', 'Not Now'],
+ defaultId: 0,
+ cancelId: 1,
+ }
+ const win = deps.getWindow()
+ const { response } = await (win
+ ? showShellDialog(win, options)
+ : showShellDialog(options))
+ if (response !== 0) return
+ }
+ if (state.status === 'ready') quitAndInstall()
+ } catch (error) {
+ logger.warn('Could not show the restart prompt for work in progress', {
+ message: getErrorMessage(error, 'unknown'),
})
- .catch((error) => {
- logger.warn('Could not show update restart confirmation', {
- message: getErrorMessage(error, 'unknown'),
- })
- })
- .finally(() => {
- installConfirmationInFlight = false
- })
+ } finally {
+ installConfirmationInFlight = false
+ }
}
let activeProbeId: number | null = null
@@ -838,8 +861,8 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
advance() {
if (acceptedUpdateVersion !== null) download(acceptedUpdateVersion)
},
- install() {
- confirmAndInstall()
+ install(options) {
+ void installUnlessInterrupting(options)
},
setAutoDownload(enabled) {
autoDownloadEnabled = enabled
@@ -1017,12 +1040,12 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
}
engine.check(true)
},
- install() {
+ install({ mandatory = false } = {}) {
if (!engine) {
return
}
if (state.status === 'ready') {
- engine.install()
+ engine.install({ mandatory })
return
}
if (state.status === 'available' && state.manual) {
@@ -1094,11 +1117,23 @@ export function checkForUpdatesInteractive(
void showDialog({
type: 'info',
message: state.version ? `Downloading Sim ${state.version}…` : 'Downloading update…',
- detail: 'You will be prompted to restart when it is ready.',
+ detail: 'When it finishes, choose Restart to update in Sim, or quit Sim to install it.',
})
return
case 'ready':
- handle.install()
+ // The user asked to check, not to restart: confirm before closing every window.
+ void showDialog({
+ type: 'info',
+ buttons: ['Restart to Update', 'Later'],
+ defaultId: 0,
+ cancelId: 1,
+ message: state.version
+ ? `Sim ${state.version} is ready to install`
+ : 'An update is ready to install',
+ detail: 'Restart Sim now to finish updating, or it installs the next time you quit.',
+ }).then(({ response }) => {
+ if (response === 0) handle.install()
+ })
return
case 'error':
void showDialog({
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index 811b07561f4..b59f2e933fa 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -17,6 +17,7 @@ import type {
import type {
BrowserAddToChatPayload,
BrowserChromeImportResult,
+ BrowserContinueSignInResult,
BrowserCredentialConflictPolicy,
BrowserCredentialMetadata,
BrowserDownloadsState,
@@ -24,6 +25,7 @@ import type {
BrowserImportProfile,
BrowserImportResult,
BrowserPasswordImportResult,
+ BrowserPendingSignIn,
BrowserSiteInfo,
BrowserToolbarCommand,
ComputerUseActivity,
@@ -238,8 +240,8 @@ const api: SimDesktopApi = {
check: (): void => {
ipcRenderer.send('desktop:updates:check')
},
- install: (): void => {
- ipcRenderer.send('desktop:updates:install')
+ install: (options?: { mandatory?: boolean }): void => {
+ ipcRenderer.send('desktop:updates:install', { mandatory: options?.mandatory === true })
},
onState: (callback: (state: DesktopUpdateState) => void): (() => void) => {
const listener = (_event: unknown, state: DesktopUpdateState) => callback(state)
@@ -439,6 +441,17 @@ const api: SimDesktopApi = {
policy?: BrowserCredentialConflictPolicy
): Promise =>
ipcRenderer.invoke('browser-import:all', profileId, policy),
+ continueSignIn: (
+ profileId: string,
+ scopeId: string
+ ): Promise =>
+ ipcRenderer.invoke('browser-import:continue-sign-in', profileId, scopeId),
+ bringSignInBack: (scopeId: string): Promise =>
+ ipcRenderer.invoke('browser-import:bring-sign-in-back', scopeId),
+ cancelSignIn: (scopeId: string): Promise =>
+ ipcRenderer.invoke('browser-import:cancel-sign-in', scopeId),
+ getPendingSignIn: (scopeId: string): Promise =>
+ ipcRenderer.invoke('browser-import:pending-sign-in', scopeId),
},
}
: {}),
diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts
index c0f180897d0..105aa55f117 100644
--- a/apps/desktop/src/test/electron-mock.ts
+++ b/apps/desktop/src/test/electron-mock.ts
@@ -107,6 +107,11 @@ export const session = {
fromPartition: vi.fn(),
}
+/** Resolves a frame to its WebContents; tests return the contents they registered. */
+export const webContents = {
+ fromFrame: vi.fn(),
+}
+
export const protocol = {
registerSchemesAsPrivileged: vi.fn(),
handle: vi.fn(),
diff --git a/apps/docs/DOMAIN_MOVE.md b/apps/docs/DOMAIN_MOVE.md
index bbd479bcc00..6085e1690fd 100644
--- a/apps/docs/DOMAIN_MOVE.md
+++ b/apps/docs/DOMAIN_MOVE.md
@@ -2,6 +2,8 @@
The site currently targets `docs.sim.ai`. A move to `sim.ai/docs` is separate infrastructure work, not completed by the docs redesign. Changing `NEXT_PUBLIC_DOCS_URL` alone is insufficient.
+The platform's temporary 307 redirects send `/docs` to the introduction and `/docs/*` to the matching path on `docs.sim.ai`, preserving query parameters. Remove these outgoing redirects when mounting the docs at `/docs`; they are not the routing layer for the future move.
+
## Required changes
- Build Next.js with `basePath: '/docs'`; an asset prefix alone does not mount the app. Keep canonical catalog IDs such as `/introduction` independent of the public prefix.
diff --git a/apps/docs/README.md b/apps/docs/README.md
index 4733ee045c2..59428b42b94 100644
--- a/apps/docs/README.md
+++ b/apps/docs/README.md
@@ -29,3 +29,16 @@ The [machine access guide](MACHINE_ACCESS.md) documents Markdown, JSON, MCP, sea
From the repository root, run the required lint, type, audit, and test gates in the development guidelines. From this directory, `bun run type-check` compiles MDX and checks the docs workspace, and `bun run build` validates the production site. Inspect affected page types on desktop and a narrow viewport, and check internal links and existing anchors after heading changes.
The docs HTTP workflow also checks sidebar navigation, API language switching, and copying Markdown on desktop and mobile layouts. It uploads screenshots and a JSON report with the HTTP regression artifacts. These checks run against the built site in CI; no visitor telemetry is collected. Use Vercel's build logs and usage reports to compare deployment cost and duration.
+
+## Reader feedback
+
+Votes are submitted through `/api/feedback` to an authenticated Sim webhook. Configure
+`DOCS_FEEDBACK_WEBHOOK_URL` and `DOCS_FEEDBACK_WEBHOOK_TOKEN` only on the server. The
+endpoint returns unavailable when either is absent. Limit POST requests to this path
+at the deployment edge before enabling it (10 requests per IP per minute).
+
+The workflow receives `submission_id`, `page`, `helpful`, `comment`, and `received_at`.
+Use `submission_id` as the webhook deduplication field and a unique table column for
+upsert. Retries of an unchanged submission reuse its ID. The acknowledgment confirms
+queue acceptance; workflow failures remain visible in Sim execution logs. No email,
+IP address, or user identity is included in the stored feedback payload.
diff --git a/apps/docs/app/api/feedback/route.ts b/apps/docs/app/api/feedback/route.ts
new file mode 100644
index 00000000000..7f156870d5e
--- /dev/null
+++ b/apps/docs/app/api/feedback/route.ts
@@ -0,0 +1,95 @@
+import { createLogger } from '@sim/logger'
+import { z } from 'zod'
+import { getPage } from '@/lib/content/catalog'
+import { pagePathSchema } from '@/lib/http/pages'
+
+const logger = createLogger('DocsFeedback')
+const feedbackSchema = z
+ .object({
+ submissionId: z.uuid(),
+ page: pagePathSchema,
+ helpful: z.boolean(),
+ comment: z.string().trim().max(2000),
+ })
+ .strict()
+const MAX_BODY_BYTES = 12_288
+
+export async function POST(request: Request) {
+ const headers = { 'Cache-Control': 'no-store' }
+ const fail = (error: string, status: number) => Response.json({ error }, { status, headers })
+ const requestUrl = new URL(request.url)
+ const host = request.headers.get('host')
+ if (!host || request.headers.get('origin') !== `${requestUrl.protocol}//${host}`) {
+ return fail('Feedback must be submitted from this site.', 403)
+ }
+ if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json') {
+ return fail('Expected JSON.', 415)
+ }
+ const reader = request.body?.getReader()
+ if (!reader) return fail('Feedback is required.', 400)
+ let text = ''
+ let bytes = 0
+ const decoder = new TextDecoder()
+ try {
+ while (true) {
+ const chunk = await reader.read()
+ if (chunk.done) break
+ bytes += chunk.value.byteLength
+ if (bytes > MAX_BODY_BYTES) {
+ await reader.cancel()
+ return fail('Feedback is too long.', 413)
+ }
+ text += decoder.decode(chunk.value, { stream: true })
+ }
+ text += decoder.decode()
+ } catch {
+ return fail('Could not read feedback.', 400)
+ } finally {
+ reader.releaseLock()
+ }
+ let input: z.infer
+ try {
+ input = feedbackSchema.parse(JSON.parse(text))
+ } catch {
+ return fail('Check your feedback and try again.', 400)
+ }
+ const page = getPage(input.page.replace(/\/$/, '') || '/')
+ if (!page || page.url === '/') return fail('Documentation page not found.', 404)
+ const endpoint = process.env.DOCS_FEEDBACK_WEBHOOK_URL
+ const token = process.env.DOCS_FEEDBACK_WEBHOOK_TOKEN
+ if (!endpoint || !token) return fail('Feedback is temporarily unavailable.', 503)
+ try {
+ const url = new URL(endpoint)
+ if (url.protocol !== 'https:') throw new Error('Invalid feedback configuration')
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
+ body: JSON.stringify({
+ submission_id: input.submissionId,
+ page: page.url,
+ helpful: input.helpful,
+ comment: input.comment,
+ received_at: new Date().toISOString(),
+ }),
+ signal: AbortSignal.timeout(15_000),
+ redirect: 'error',
+ })
+ if (!response.ok) {
+ logger.warn('Feedback intake rejected submission', { status: response.status })
+ return fail('Could not send feedback. Please try again.', 502)
+ }
+ const receipt: unknown = await response.json()
+ if (
+ !receipt ||
+ typeof receipt !== 'object' ||
+ !('message' in receipt) ||
+ receipt.message !== 'Webhook processed'
+ ) {
+ return fail('Could not confirm feedback. Please try again.', 502)
+ }
+ return Response.json({ accepted: true }, { status: 202, headers })
+ } catch {
+ logger.warn('Feedback intake unavailable')
+ return fail('Could not send feedback. Please try again.', 503)
+ }
+}
diff --git a/apps/docs/components/docs/article.tsx b/apps/docs/components/docs/article.tsx
index 01cc048e327..09b9e35a0f3 100644
--- a/apps/docs/components/docs/article.tsx
+++ b/apps/docs/components/docs/article.tsx
@@ -1,13 +1,13 @@
import type { ReactNode } from 'react'
-import { ArrowLeft, ArrowRight, SquareArrowUpRight, ThumbsDown, ThumbsUp } from '@sim/emcn/icons'
+import { ArrowLeft, ArrowRight, SquareArrowUpRight } from '@sim/emcn/icons'
import Link from 'next/link'
import { ContentInteractions } from '@/components/docs/content-interactions'
+import { Feedback } from '@/components/docs/feedback'
import { PageActions } from '@/components/docs/page-actions'
import { IntegrationIcon } from '@/components/ui/integration-icon'
import { getNavigation, getNeighbours } from '@/lib/content/catalog'
import { findNavigationGroup } from '@/lib/content/navigation'
import type { DocsPage } from '@/lib/content/types'
-import { DOCS_BASE_URL } from '@/lib/urls'
interface ArticleProps {
page: DocsPage
@@ -24,8 +24,6 @@ export function Article({ page, children, aside }: ArticleProps) {
? `apps/sim/lib/api/contracts/v2`
: `apps/docs/content/docs/${page.sourcePath}`
const editUrl = `https://github.com/simstudioai/sim/${page.operation ? 'tree' : 'edit'}/staging/${source}`
- const feedbackUrl = (helpful: boolean) =>
- `https://github.com/simstudioai/sim/issues/new?${new URLSearchParams({ title: `Docs feedback: ${page.title}`, body: `Page: ${DOCS_BASE_URL}${page.url}\n\n${helpful ? 'What helped:' : 'What could be improved:'}\n` })}`
return (
{children}
-
-
Was this helpful?
-
-
-
-
-
-
-
+
Suggest an edit
diff --git a/apps/docs/components/docs/feedback.tsx b/apps/docs/components/docs/feedback.tsx
new file mode 100644
index 00000000000..4b5448bf3cd
--- /dev/null
+++ b/apps/docs/components/docs/feedback.tsx
@@ -0,0 +1,139 @@
+'use client'
+
+import { useRef, useState } from 'react'
+import { ThumbsDown, ThumbsUp } from '@sim/emcn/icons'
+import { generateId } from '@sim/utils/id'
+import { QueryClient, QueryClientProvider, useMutation } from '@tanstack/react-query'
+import { DocsButton } from '@/components/docs/primitives'
+
+interface FeedbackProps {
+ page: string
+}
+
+export function Feedback({ page }: FeedbackProps) {
+ const [client] = useState(() => new QueryClient())
+ return (
+
+
+
+ )
+}
+
+function FeedbackForm({ page }: FeedbackProps) {
+ const [helpful, setHelpful] = useState(null)
+ const [comment, setComment] = useState('')
+ const pendingSubmission = useRef<{
+ submissionId: string
+ helpful: boolean
+ comment: string
+ } | null>(null)
+ const submission = useMutation({
+ retry: false,
+ mutationFn: async (payload: { submissionId: string; helpful: boolean; comment: string }) => {
+ const response = await fetch('/api/feedback', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...payload, page }),
+ signal: AbortSignal.timeout(20_000),
+ })
+ if (!response.ok)
+ throw new Error(
+ response.status === 429
+ ? 'Too many submissions. Please try again in a minute.'
+ : 'Could not send feedback. Please try again.'
+ )
+ const result: unknown = await response.json()
+ if (
+ !result ||
+ typeof result !== 'object' ||
+ !('accepted' in result) ||
+ result.accepted !== true
+ ) {
+ throw new Error('Could not confirm feedback. Please try again.')
+ }
+ },
+ })
+ function submit(vote: boolean) {
+ if (submission.isPending) return
+ const text = vote ? '' : comment.trim()
+ if (
+ !pendingSubmission.current ||
+ pendingSubmission.current.helpful !== vote ||
+ pendingSubmission.current.comment !== text
+ ) {
+ pendingSubmission.current = { submissionId: generateId(), helpful: vote, comment: text }
+ }
+ submission.mutate(pendingSubmission.current)
+ }
+ if (submission.isSuccess) return Thanks for your feedback.
+ return (
+
+ )
+}
diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx
index 597b8fbb2d8..402b92fe5ab 100644
--- a/apps/docs/content/docs/cli/files.mdx
+++ b/apps/docs/content/docs/cli/files.mdx
@@ -102,7 +102,7 @@ sim files describe [options]
| Argument | Required | Description |
| --- | --- | --- |
-| `fileId` | Yes | File identifier. |
+| `fileId` | Yes | File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike. |
@@ -303,7 +303,7 @@ sim files rename [options]
| Argument | Required | Description |
| --- | --- | --- |
-| `fileId` | Yes | File identifier. |
+| `fileId` | Yes | File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike. |
@@ -613,7 +613,7 @@ sim files delete [options]
| Argument | Required | Description |
| --- | --- | --- |
-| `fileId` | Yes | File identifier. |
+| `fileId` | Yes | File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike. |
@@ -634,16 +634,26 @@ API request and response schemas: [deleteFile](/api-reference/files/deleteFile).
This archives the listed files; restore them with `files restore`.
```bash
-sim files batch-delete [options]
+sim files batch-delete [file-ids...] [options]
```
+**Arguments**
+
+
+
+| Argument | Required | Description |
+| --- | --- | --- |
+| `file-ids...` | No | File identifiers to delete. Same as --file-ids. |
+
+
+
**Options**
| Option | Required | Description |
| --- | --- | --- |
-| `--file-ids ` | Yes | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). |
+| `--file-ids ` | No | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @) (required, or pass the values as arguments). |
| `-y, --yes` | Yes | Confirm this operation. |
diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx
index df99c0861bf..171810846f2 100644
--- a/apps/docs/content/docs/cli/reference.mdx
+++ b/apps/docs/content/docs/cli/reference.mdx
@@ -972,16 +972,26 @@ Delete several files at once
This archives the listed files; restore them with `files restore`.
```bash
-sim files batch-delete [options]
+sim files batch-delete [file-ids...] [options]
```
+**Arguments**
+
+
+
+| Argument | Required | Description |
+| --- | --- | --- |
+| `file-ids...` | No | File identifiers to delete. Same as --file-ids. |
+
+
+
**Options**
| Option | Required | Description |
| --- | --- | --- |
-| `--file-ids ` | Yes | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). |
+| `--file-ids ` | No | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @) (required, or pass the values as arguments). |
| `-y, --yes` | Yes | Confirm this operation. |
@@ -1171,7 +1181,7 @@ sim files delete [options]
| Argument | Required | Description |
| --- | --- | --- |
-| `fileId` | Yes | File identifier. |
+| `fileId` | Yes | File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike. |
@@ -1416,7 +1426,7 @@ sim files describe [options]
| Argument | Required | Description |
| --- | --- | --- |
-| `fileId` | Yes | File identifier. |
+| `fileId` | Yes | File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike. |
@@ -1581,7 +1591,7 @@ sim files rename [options]
| Argument | Required | Description |
| --- | --- | --- |
-| `fileId` | Yes | File identifier. |
+| `fileId` | Yes | File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike. |
diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx
index e9d5d553eba..f45b2fd980b 100644
--- a/apps/docs/content/docs/search/sim-links.mdx
+++ b/apps/docs/content/docs/search/sim-links.mdx
@@ -5,7 +5,7 @@ description: Short sim/ links that take everyone in your organization to the rig
Sim Links are short names for pages your team opens often, like `sim/handbook` or `sim/okta`. Every member of your organization can use them, with or without a Search seat.
-Open **Sim Links** in the organization sidebar to see your organization's links. Search by name, destination, or description, or use **Sort → Opens** to see the most used links.
+Open **Sim Links** in the organization sidebar to see your organization's links. Search by name, destination, or description, or use **Sort → Opens** to see the 50 most opened links.
## Open a link
@@ -15,7 +15,9 @@ Names ignore case and punctuation, so `sim/Team-Handbook` and `sim/teamhandbook`
## Open links from the address bar
-Add Sim as a Chrome site search shortcut to type `sim handbook` instead:
+With the Sim Chrome extension, type `sim/handbook` in the address bar. To find a link, type `sim`, press **Space**, and start typing its name: matching links appear as you type, most opened first. Unlisted links aren't suggested.
+
+Without the extension, add Sim as a Chrome site search shortcut to type `sim handbook` instead:
1. Open Chrome **Settings → Search engine → Manage search engines and site search**.
2. Next to **Site search**, select **Add**.
@@ -33,7 +35,7 @@ To share a link, right-click it and select **Copy link**.
## Add values to a link
-Anything after the name is added to the destination: if `sim/docs` opens `https://docs.example.com`, then `sim/docs/billing` opens `https://docs.example.com/billing`.
+Anything after the name is added to the destination: if `sim/docs` opens `https://docs.example.com`, then `sim/docs/billing` opens `https://docs.example.com/billing`. A query is passed along too, so `sim/docs?lang=fr` opens `https://docs.example.com/?lang=fr`.
To place a value elsewhere, put `{*}` in the destination URL:
@@ -50,7 +52,7 @@ Values fill each `{*}` in order, and the last `{*}` takes the rest. A link with
By default, any member can edit or delete a link. The link's creator and organization admins can change two options under **Access**:
- **Hide from the Sim Links page**: only the creator and admins see the link in the list, marked **Unlisted**. Anyone with the name can still open it.
-- **Only you and admins can edit**: only the creator and admins can edit or delete the link, marked **Restricted**.
+- **Only the creator and admins can edit**: only the creator and admins can edit or delete the link, marked **Restricted**.
Changes to links are recorded in your organization's [audit logs](/platform/enterprise/audit-logs).
diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json
index ec710bef7bc..dab0fa456b1 100644
--- a/apps/docs/openapi-v2-files-audit.json
+++ b/apps/docs/openapi-v2-files-audit.json
@@ -2151,13 +2151,12 @@
"name": "fileId",
"in": "path",
"required": true,
- "description": "File identifier.",
+ "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike.",
"schema": {
"type": "string",
"minLength": 1,
- "maxLength": 128,
- "pattern": "^[A-Za-z0-9_-]+$",
- "description": "File identifier."
+ "maxLength": 4096,
+ "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike."
}
},
{
@@ -2238,13 +2237,12 @@
"name": "fileId",
"in": "path",
"required": true,
- "description": "File identifier.",
+ "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike.",
"schema": {
"type": "string",
"minLength": 1,
- "maxLength": 128,
- "pattern": "^[A-Za-z0-9_-]+$",
- "description": "File identifier."
+ "maxLength": 4096,
+ "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike."
}
}
],
@@ -2489,13 +2487,12 @@
"name": "fileId",
"in": "path",
"required": true,
- "description": "File identifier.",
+ "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike.",
"schema": {
"type": "string",
"minLength": 1,
- "maxLength": 128,
- "pattern": "^[A-Za-z0-9_-]+$",
- "description": "File identifier."
+ "maxLength": 4096,
+ "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike."
}
},
{
diff --git a/apps/docs/scripts/fixtures/content-baseline.json b/apps/docs/scripts/fixtures/content-baseline.json
index a535b99a55a..c990fe65fa0 100644
--- a/apps/docs/scripts/fixtures/content-baseline.json
+++ b/apps/docs/scripts/fixtures/content-baseline.json
@@ -4422,8 +4422,8 @@
{
"lang": "bash",
"meta": null,
- "sha256": "41e9881b8582523af56643e45a5a1ba351eee353683c169a0dc8fc56b4d3ad66",
- "bytes": 32,
+ "sha256": "c569c5e38502a1c1e8f6078954bc90b559dd80adb577bb4cf7b63ec7bbdb3aa7",
+ "bytes": 46,
"line": 632
},
{
@@ -11786,8 +11786,8 @@
{
"lang": "bash",
"meta": null,
- "sha256": "41e9881b8582523af56643e45a5a1ba351eee353683c169a0dc8fc56b4d3ad66",
- "bytes": 32,
+ "sha256": "c569c5e38502a1c1e8f6078954bc90b559dd80adb577bb4cf7b63ec7bbdb3aa7",
+ "bytes": 46,
"line": 970
},
{
diff --git a/apps/docs/scripts/test-docs-interfaces-e2e.ts b/apps/docs/scripts/test-docs-interfaces-e2e.ts
index 8e686b19507..892bd2d1b9a 100644
--- a/apps/docs/scripts/test-docs-interfaces-e2e.ts
+++ b/apps/docs/scripts/test-docs-interfaces-e2e.ts
@@ -3,6 +3,7 @@ import { dirname } from 'node:path'
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
import { toRecord } from '@sim/utils/object'
import routes from '@/.content/routes.json'
@@ -51,6 +52,63 @@ const paths = [
'/cli',
'/mcp/overview',
]
+const feedback = {
+ submissionId: generateId(),
+ page: '/introduction',
+ helpful: true,
+ comment: '',
+}
+for (const example of [
+ {
+ name: 'foreign origin',
+ origin: 'https://example.com',
+ contentType: 'application/json',
+ body: JSON.stringify(feedback),
+ status: 403,
+ },
+ {
+ name: 'non-JSON content',
+ origin: base.origin,
+ contentType: 'text/plain',
+ body: JSON.stringify(feedback),
+ status: 415,
+ },
+ {
+ name: 'oversized body',
+ origin: base.origin,
+ contentType: 'application/json',
+ body: JSON.stringify({ ...feedback, comment: 'x'.repeat(13000) }),
+ status: 413,
+ },
+ {
+ name: 'invalid vote',
+ origin: base.origin,
+ contentType: 'application/json',
+ body: JSON.stringify({ ...feedback, helpful: 'yes' }),
+ status: 400,
+ },
+ {
+ name: 'unknown page',
+ origin: base.origin,
+ contentType: 'application/json',
+ body: JSON.stringify({ ...feedback, page: '/no-such-document' }),
+ status: 404,
+ },
+]) {
+ await check(`Feedback rejects ${example.name} before forwarding`, async () => {
+ const response = await get('/api/feedback', {
+ method: 'POST',
+ headers: { Origin: example.origin, 'Content-Type': example.contentType },
+ body: example.body,
+ })
+ assert(response.status === example.status, `Expected ${example.status}, got ${response.status}`)
+ assert(
+ response.headers.get('Cache-Control') === 'no-store',
+ 'Feedback response must not be cached'
+ )
+ })
+}
+
for (const path of paths) {
await check(`Complete JSON and Markdown representations agree: ${path}`, async () => {
const json = await get(`/api/docs/page?path=${encodeURIComponent(path)}`)
diff --git a/apps/docs/scripts/test-navigation-e2e.ts b/apps/docs/scripts/test-navigation-e2e.ts
index 516311b1f94..e73c1f29036 100644
--- a/apps/docs/scripts/test-navigation-e2e.ts
+++ b/apps/docs/scripts/test-navigation-e2e.ts
@@ -14,6 +14,7 @@ import {
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
+import { toRecord } from '@sim/utils/object'
const logger = createLogger('DocsNavigationE2E', { logLevel: 'INFO' })
const origin = process.env.DOCS_E2E_BASE_URL
@@ -162,6 +163,114 @@ async function verifyClipboard(
let browser: Browser | undefined
try {
browser = await (engine === 'webkit' ? webkit : chromium).launch()
+ const feedbackBrowser = browser
+ await check('Feedback forwards votes and preserves unchanged retry identity', async () => {
+ const cert = process.env.DOCS_FEEDBACK_TEST_CERT
+ const key = process.env.DOCS_FEEDBACK_TEST_KEY
+ const token = process.env.DOCS_FEEDBACK_WEBHOOK_TOKEN
+ if (!cert || !key || !token) throw new Error('Feedback HTTPS fixture configuration is required')
+ const received: Array<{ authorization: string | null; body: Record }> = []
+ let accept = false
+ const intake = Bun.serve({
+ hostname: '127.0.0.1',
+ port: 3443,
+ tls: { cert: await readFile(cert), key: await readFile(key) },
+ async fetch(request) {
+ if (received.length >= 8) return new Response(null, { status: 429 })
+ received.push({
+ authorization: request.headers.get('authorization'),
+ body: toRecord(await request.json()),
+ })
+ return Response.json(
+ { message: accept ? 'Webhook processed' : 'Unavailable' },
+ { status: accept ? 200 : 503 }
+ )
+ },
+ })
+ let context: BrowserContext | undefined
+ try {
+ context = await feedbackBrowser.newContext()
+ const page = await context.newPage()
+ const started = Date.now()
+ await open(page, '/introduction')
+ await page.getByRole('button', { name: 'No, this could be improved', exact: true }).click()
+ const comment = page.getByLabel('What could be better? (optional)', { exact: true })
+ await comment.fill(' Explain deployment prerequisites. ')
+ const send = page.getByRole('button', { name: 'Send feedback', exact: true })
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const response = page.waitForResponse(
+ (response) => new URL(response.url()).pathname === '/api/feedback'
+ )
+ await send.click()
+ expect((await response).status()).toBe(502)
+ await expect(page.getByRole('main').getByRole('alert')).toContainText(
+ 'Could not send feedback.'
+ )
+ await expect(comment).toHaveValue(' Explain deployment prerequisites. ')
+ await expect(send).toBeEnabled()
+ }
+ expect(received).toHaveLength(2)
+ expect(received[1].body.submission_id).toBe(received[0].body.submission_id)
+ expect(received[0].body.comment).toBe('Explain deployment prerequisites.')
+ accept = true
+ await comment.fill(' Explain deployment permissions. ')
+ const accepted = page.waitForResponse(
+ (response) => new URL(response.url()).pathname === '/api/feedback'
+ )
+ await send.click()
+ expect((await accepted).status()).toBe(202)
+ await expect(
+ page.getByRole('status').filter({ hasText: 'Thanks for your feedback.' })
+ ).toBeVisible()
+ expect(received).toHaveLength(3)
+ expect(received[2].body.submission_id).not.toBe(received[1].body.submission_id)
+ expect(received[2].body).toMatchObject({
+ page: '/introduction',
+ helpful: false,
+ comment: 'Explain deployment permissions.',
+ })
+ await page
+ .getByRole('navigation', { name: 'Page navigation' })
+ .getByRole('link', { name: 'Next Getting started' })
+ .click()
+ const positive = page.waitForResponse(
+ (response) => new URL(response.url()).pathname === '/api/feedback'
+ )
+ await page.getByRole('button', { name: 'Yes, this was helpful', exact: true }).click()
+ expect((await positive).status()).toBe(202)
+ await expect(
+ page.getByRole('status').filter({ hasText: 'Thanks for your feedback.' })
+ ).toBeVisible()
+ expect(received).toHaveLength(4)
+ expect(received[3].body).toMatchObject({
+ page: '/getting-started',
+ helpful: true,
+ comment: '',
+ })
+ expect(received[3].body.submission_id).not.toBe(received[2].body.submission_id)
+ for (const request of received) {
+ expect(request.authorization).toBe(`Bearer ${token}`)
+ expect(Object.keys(request.body).sort()).toEqual([
+ 'comment',
+ 'helpful',
+ 'page',
+ 'received_at',
+ 'submission_id',
+ ])
+ expect(request.body.submission_id).toMatch(/^[0-9a-f-]{36}$/)
+ expect(typeof request.body.received_at).toBe('string')
+ const timestamp = Date.parse(String(request.body.received_at))
+ expect(timestamp).toBeGreaterThanOrEqual(started)
+ expect(timestamp).toBeLessThanOrEqual(Date.now())
+ }
+ } finally {
+ try {
+ await context?.close()
+ } finally {
+ await intake.stop(true)
+ }
+ }
+ })
const noScript = await browser.newContext({
javaScriptEnabled: false,
viewport: { width: 1440, height: 1000 },
diff --git a/apps/extension/DESIGN.md b/apps/extension/DESIGN.md
index 86bfc5c55b2..be04a71d45d 100644
--- a/apps/extension/DESIGN.md
+++ b/apps/extension/DESIGN.md
@@ -60,8 +60,14 @@ other workspace resources or browser-control tools.
- Other HTTP(S) origins are optional grants requested for the current site from a
user gesture. Each grant can be revoked. Keeping the panel open is not a grant
to read every subsequently selected tab.
-- No history, bookmarks, cookie API, debugger, network interception, native
- messaging, external messaging, or arbitrary remote code execution.
+- No history, bookmarks, cookie API, debugger, native messaging, external messaging,
+ or arbitrary remote code execution. The only network interception is the static
+ `sim/` redirect rule above.
+- The only background network request is the `sim` address-bar keyword: after
+ `sim` and Space, the text typed before the first `/` or `?` is sent to the
+ configured Sim origin with the signed-in session to suggest matching Sim links,
+ unless it contains a space (a search) or is longer than a link name can be. Page
+ content is never sent, and unlisted links are never suggested.
- No always-running page reader. Extraction is tied to an explicit attachment action.
- Page bytes are not written to extension local/sync storage. Temporary context-menu
handoff uses trusted-context session storage, expiration and navigation cleanup.
diff --git a/apps/extension/README.md b/apps/extension/README.md
index 7f7c4166efa..93a7729c8b8 100644
--- a/apps/extension/README.md
+++ b/apps/extension/README.md
@@ -27,6 +27,7 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app
- Required capabilities: `activeTab`, `scripting`, `sidePanel`, `storage`, `contextMenus`, `declarativeNetRequestWithHostAccess`, and access to the configured Sim origin and to `http(s)://sim/*`.
- Typing `sim/` in the address bar opens the organization's [Sim link](https://docs.sim.ai/search/sim-links). A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` to `/sim/*` with the path and query unchanged. The `sim` host permission exists only for that rule: every navigation to that host is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` should not install this build, since its links would be redirected to Sim.
+- The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. It is the extension's only network request: the background worker sends the text typed before the first `/` or `?`, and nothing else, unless that text contains a space (a search) or is longer than a link name can be, to `/api/sim-links/suggestions` with the signed-in session, and the extension's content security policy allows connections to that origin alone. Unlisted links are never suggested.
- Optional HTTP/HTTPS site access is requested only by a menu action naming that site. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites.
- Page capture runs on demand in Chrome's isolated world. It excludes editable fields, inputs, hidden elements, scripts, and embedded documents. It reads only the top frame. PDF viewers and canvas documents may have no accessible text.
- Extraction stops at 50,000 text characters, 150,000 serialized UTF-8 bytes, 15,000 visited nodes, or 150ms. The composer exposes truncation.
diff --git a/apps/extension/package.json b/apps/extension/package.json
index 796a3a2d843..e92c11c5047 100644
--- a/apps/extension/package.json
+++ b/apps/extension/package.json
@@ -1,6 +1,6 @@
{
"name": "@sim/extension",
- "version": "0.1.0",
+ "version": "0.1.2",
"private": true,
"type": "module",
"license": "Apache-2.0",
@@ -18,6 +18,7 @@
"@sim/emcn": "workspace:*",
"@sim/logger": "workspace:*",
"@sim/utils": "workspace:*",
+ "lru-cache": "11.5.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"zod": "4.3.6"
diff --git a/apps/extension/scripts/build.ts b/apps/extension/scripts/build.ts
index 072321b0ca0..626c4fc7b7f 100644
--- a/apps/extension/scripts/build.ts
+++ b/apps/extension/scripts/build.ts
@@ -107,9 +107,9 @@ await writeFile(
{
manifest_version: 3,
name: development ? 'Sim (Development)' : 'Sim',
- version: '0.1.0',
+ version: '0.1.2',
description:
- 'Ask Sim about the page you are reading, with your existing Sim conversations and tools.',
+ "Ask Sim about the page you are reading, and open your organization's sim/ links from the address bar.",
minimum_chrome_version: '120',
permissions: [
'activeTab',
@@ -125,6 +125,7 @@ await writeFile(
},
optional_host_permissions: ['https://*/*', 'http://*/*'],
background: { service_worker: 'background.js' },
+ omnibox: { keyword: 'sim' },
side_panel: { default_path: 'sidepanel.html' },
action: { default_title: 'Open Sim' },
icons: { 16: 'icon-16.png', 32: 'icon-32.png', 128: 'icon-128.png' },
@@ -135,7 +136,7 @@ await writeFile(
},
},
content_security_policy: {
- extension_pages: `default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; frame-src ${origin.origin}; connect-src 'none'; object-src 'none'; base-uri 'none'`,
+ extension_pages: `default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; frame-src ${origin.origin}; connect-src ${origin.origin}; object-src 'none'; base-uri 'none'`,
},
incognito: 'not_allowed',
...(key ? { key } : {}),
diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts
index d9ff9255368..3d949981a69 100644
--- a/apps/extension/scripts/test-e2e.ts
+++ b/apps/extension/scripts/test-e2e.ts
@@ -128,6 +128,13 @@ try {
await signIn.goto(`${origin}/sign-in`)
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(() => {
+ window.addEventListener('message', () => {
+ document.documentElement.dataset.queryCountAtDispatch =
+ document.documentElement.dataset.browserQueryCount ?? '0'
+ })
+ })
await panel.goto(`chrome-extension://${extensionId}/sidepanel.html`)
await panel.getByTitle('Sim chat').waitFor()
const frame: Frame = panel.frames()[1] ?? (await panel.waitForEvent('frameattached'))
@@ -588,6 +595,7 @@ try {
callback?: (tabs: chrome.tabs.Tab[]) => void
) => {
queries++
+ document.documentElement.dataset.browserQueryCount = String(queries)
return callback ? originalQuery(query, callback) : originalQuery(query)
}) as typeof chrome.tabs.query
window.addEventListener('message', (event: MessageEvent) => {
@@ -599,24 +607,31 @@ try {
event.data.type !== 'action'
)
return
- document.body.dataset.actionQueries = String(queries)
+ document.body.dataset.actionQueries = String(
+ queries - Number(document.documentElement.dataset.queryCountAtDispatch ?? '0')
+ )
document.body.dataset.observedActions = String(++actions)
})
})
+ await panel.evaluate(() => chrome.tabs.query({ active: true, currentWindow: true }))
const parentCdp = await panel.context().newCDPSession(panel)
const cdp = await panel.context().newCDPSession(app)
await expect
.poll(
async () => {
- const result = await parentCdp.send('Runtime.evaluate', {
- expression: 'navigator.userActivation.isActive',
- userGesture: false,
- })
- return result.result.value
+ const results = await Promise.all(
+ [parentCdp, cdp].map((session) =>
+ session.send('Runtime.evaluate', {
+ expression: 'navigator.userActivation.isActive',
+ userGesture: false,
+ })
+ )
+ )
+ return results.map((result) => result.result.value)
},
{ timeout: 10_000 }
)
- .toBe(false)
+ .toEqual([false, false])
const action = {
channel: 'sim-extension',
version: 1,
@@ -653,6 +668,55 @@ try {
await parentCdp.detach()
}
)
+ await check(
+ 'Site access stays modal without violating the packaged security policy',
+ async () => {
+ const policyErrors: string[] = []
+ panel.on('console', (message) => {
+ if (message.type() === 'error' && message.text().includes('Content Security Policy'))
+ policyErrors.push(message.text())
+ })
+ const openAccess = () =>
+ app.evaluate(
+ ({ targetOrigin, sessionId }) => {
+ parent.postMessage(
+ {
+ channel: 'sim-extension',
+ version: 1,
+ type: 'action',
+ sessionId,
+ action: 'site-access',
+ },
+ targetOrigin
+ )
+ },
+ { targetOrigin: `chrome-extension://${extensionId}`, sessionId: receiverSessionId }
+ )
+ await panel.bringToFront()
+ await openAccess()
+ const dialog = panel.getByRole('dialog', { name: 'Site access' })
+ await expect(dialog).toBeVisible()
+ await settleMessages()
+ assert(policyErrors.length === 0, `Site access violated CSP: ${policyErrors.join('\n')}`)
+ await panel.evaluate(() => document.querySelector('iframe')?.focus())
+ assert(
+ await dialog.evaluate((element) => element.contains(document.activeElement)),
+ 'The hosted frame accepted focus behind the modal'
+ )
+ await dialog.getByRole('button', { name: 'Close' }).press('Escape')
+ await expect(dialog, 'Escape closes the modal').toBeHidden()
+ await openAccess()
+ await expect(dialog).toBeVisible()
+ await dialog.getByRole('button', { name: 'Close' }).click()
+ await expect(dialog, 'The Close button dismisses the modal').toBeHidden()
+ await openAccess()
+ await expect(dialog).toBeVisible()
+ await dialog.click({ trial: true })
+ await panel.mouse.click(1, 1)
+ await expect(dialog, 'The backdrop dismisses the modal').toBeHidden()
+ assert(policyErrors.length === 0, `Site access violated CSP: ${policyErrors.join('\n')}`)
+ }
+ )
await mkdir(dirname(output), { recursive: true })
await panel.screenshot({ path: output.replace(/\.json$/, '.png'), fullPage: true })
} catch (error) {
diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts
index 27db2cf08eb..c6585bc53dc 100644
--- a/apps/extension/src/background.ts
+++ b/apps/extension/src/background.ts
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { CAPTURE_INTENT_KEY, captureIntentKey } from '@/config'
import { captureIntentSchema } from '@/intent'
+import { registerSimLinkOmnibox } from '@/omnibox'
import { digestUrl } from '@/utils'
const logger = createLogger('BrowserExtension')
@@ -50,6 +51,7 @@ function open(tab: chrome.tabs.Tab, kind: 'page' | 'selection') {
.catch(() => logger.warn('Could not attach this page; the side panel remains available'))
}
+registerSimLinkOmnibox()
chrome.runtime.onInstalled.addListener(() => {
void configure().catch(() => logger.warn('Could not initialize extension controls'))
})
diff --git a/apps/extension/src/omnibox.ts b/apps/extension/src/omnibox.ts
new file mode 100644
index 00000000000..af6a68cea80
--- /dev/null
+++ b/apps/extension/src/omnibox.ts
@@ -0,0 +1,165 @@
+import {
+ SIM_LINK_NAME_MAX_LENGTH,
+ SIM_LINK_SUGGESTIONS_PATH,
+ simLinkSuggestionsSchema,
+} from '@sim/browser-extension-protocol'
+import { createLogger } from '@sim/logger'
+import { sleep } from '@sim/utils/helpers'
+import { LRUCache } from 'lru-cache'
+import { SIM_ORIGIN } from '@/config'
+
+const logger = createLogger('BrowserExtensionOmnibox')
+
+/** Longer than the gap between a typist's keystrokes, so a name is looked up once it settles. */
+const SUGGEST_DELAY_MS = 220
+
+/** A Sim link as suggested: its name, and what it shows beside the name. */
+interface SuggestedLink {
+ name: string
+ detail: string
+}
+
+/**
+ * What was typed after the `sim` keyword, split where the name ends: `sim/jira/ABC-1?tab=all`
+ * (the `sim/` is optional) has the name `jira` and the rest `/ABC-1?tab=all`. Surrounding spaces,
+ * the prefix, leading slashes, and any `#fragment` are dropped, as the address bar would; values
+ * and the query otherwise stay exactly as typed.
+ */
+function readTyped(text: string): { name: string; rest: string } {
+ const typed = text
+ .trim()
+ .replace(/^sim\//i, '')
+ .replace(/^\/+/, '')
+ .replace(/#.*$/, '')
+ const nameEnd = typed.search(/[/?]/)
+ return nameEnd === -1
+ ? { name: typed, rest: '' }
+ : { name: typed.slice(0, nameEnd), rest: typed.slice(nameEnd) }
+}
+
+const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?"']/g, (character) => `${character.charCodeAt(0)};`)
+}
+
+/**
+ * A typed value encoded for the path as the address bar would send it: raw characters are
+ * escaped, an escape already typed (`a%2Fb`) is kept rather than escaped again, and a lone
+ * surrogate (which `encodeURIComponent` refuses) is dropped.
+ */
+function encodeSegment(segment: string): string {
+ return encodeURIComponent(segment.replace(LONE_SURROGATE, '')).replace(
+ /%25([0-9A-Fa-f]{2})/g,
+ '%$1'
+ )
+}
+
+/**
+ * The resolver address for what was typed, as the address bar would send `sim/`: each
+ * `/` starts a value, kept in position even when empty, and a trailing `/` adds none. The query
+ * is passed on as typed.
+ */
+function simLinkUrl(text: string): string {
+ const { name, rest } = readTyped(text)
+ const queryStart = rest.indexOf('?')
+ const path = `${name}${queryStart === -1 ? rest : rest.slice(0, queryStart)}`.replace(/\/$/, '')
+ const segments = path ? path.split('/').map(encodeSegment) : []
+ const url = new URL(`${SIM_ORIGIN}/${['sim', ...segments].join('/')}`)
+ if (queryStart !== -1) url.search = rest.slice(queryStart + 1)
+ return url.toString()
+}
+
+/** The links Sim suggests for `name`, or `null` when it couldn't answer (signed out, rate limited). */
+async function fetchSuggestedLinks(
+ name: string,
+ signal: AbortSignal
+): Promise {
+ const response = await fetch(
+ `${SIM_ORIGIN}${SIM_LINK_SUGGESTIONS_PATH}?${new URLSearchParams({ q: name })}`,
+ { credentials: 'include', signal }
+ )
+ if (!response.ok) return null
+ const { links } = simLinkSuggestionsSchema.parse(await response.json())
+ return links.map((link) => ({ name: link.name, detail: link.description ?? link.url }))
+}
+
+/** Each link as a suggestion that keeps whatever was typed after the name. */
+function toSuggestions(
+ links: readonly SuggestedLink[],
+ rest: string
+): chrome.omnibox.SuggestResult[] {
+ return links.map((link) => ({
+ content: `${link.name}${rest}`,
+ description: `sim/${escapeXml(`${link.name}${rest}`)} ${escapeXml(link.detail)} `,
+ }))
+}
+
+/**
+ * The `sim` address-bar keyword: `sim`, then Space, lists matching Sim links as you type, and
+ * Enter opens what was typed through the resolver, which sends a missing link to its create form.
+ */
+export function registerSimLinkOmnibox() {
+ let pending: AbortController | null = null
+ /**
+ * Links by typed name, emptied as each keyword session starts, so editing values or retyping
+ * a name in one session asks Sim once per name.
+ */
+ const linksByName = new LRUCache({ max: 100 })
+
+ chrome.omnibox.onInputStarted.addListener(() => linksByName.clear())
+
+ chrome.omnibox.onInputChanged.addListener((text, suggest) => {
+ pending?.abort()
+ const { name, rest } = readTyped(text)
+ chrome.omnibox.setDefaultSuggestion({
+ description: `Open sim/${escapeXml(`${name}${rest}`)} `,
+ })
+ // A name can't hold a space, so `sim card prices` is a search, not a link: ask Sim nothing.
+ if (/\s/.test(name) || name.length > SIM_LINK_NAME_MAX_LENGTH) {
+ suggest([])
+ return
+ }
+ const key = name.toLowerCase()
+ const known = linksByName.get(key)
+ if (known) {
+ suggest(toSuggestions(known, rest))
+ return
+ }
+ const controller = new AbortController()
+ pending = controller
+ void (async () => {
+ await sleep(SUGGEST_DELAY_MS)
+ if (controller.signal.aborted) return
+ // Signed out or rate limited: the typed link still opens through the resolver, and the name
+ // is asked about again on the next keystroke rather than remembered as having no links.
+ const links = await fetchSuggestedLinks(key, controller.signal)
+ if (links) linksByName.set(key, links)
+ if (!controller.signal.aborted) suggest(toSuggestions(links ?? [], rest))
+ })().catch(() => {
+ if (!controller.signal.aborted) logger.warn('Could not load Sim link suggestions')
+ })
+ })
+
+ chrome.omnibox.onInputCancelled.addListener(() => pending?.abort())
+
+ chrome.omnibox.onInputEntered.addListener((text, disposition) => {
+ pending?.abort()
+ const url = simLinkUrl(text)
+ const opened =
+ disposition === 'currentTab'
+ ? chrome.tabs.update({ url })
+ : chrome.tabs.create({ url, active: disposition === 'newForegroundTab' })
+ void opened.catch(() => logger.warn('Could not open the Sim link'))
+ })
+}
diff --git a/apps/extension/src/sidepanel.tsx b/apps/extension/src/sidepanel.tsx
index 2166938f23a..5e6114cecdd 100644
--- a/apps/extension/src/sidepanel.tsx
+++ b/apps/extension/src/sidepanel.tsx
@@ -4,7 +4,7 @@ import {
type ExtensionToAppMessage,
type PageSnapshot,
} from '@sim/browser-extension-protocol'
-import { Chip, ChipModal, ChipModalBody, ChipModalHeader } from '@sim/emcn'
+import { Chip, ChipModalBody, ChipModalHeader, ChipModalSurface } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { createRoot } from 'react-dom/client'
import { captureActivePage } from '@/capture'
@@ -16,6 +16,13 @@ interface SidePanelProps {
origin: string
}
+/** Native modality avoids the inline scroll-lock stylesheet blocked by the extension CSP. */
+function mountAccessDialog(dialog: HTMLDialogElement | null) {
+ if (!dialog) return
+ dialog.showModal()
+ return () => dialog.close()
+}
+
function SidePanel({ origin }: SidePanelProps) {
const frameRef = useRef(null)
const pendingRef = useRef(null)
@@ -279,20 +286,37 @@ function SidePanel({ origin }: SidePanelProps) {
referrerPolicy='no-referrer'
sandbox='allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads'
/>
-
- setAccessOpen(false)}>Site access
-
- {site ? (
- void toggleSite()}>
- {`${site.allowed ? 'Remove access to' : 'Allow access to'} ${new URL(site.origin).hostname}`}
-
- ) : (
-
- Open a web page and click the Sim toolbar icon to manage access.
-
- )}
-
-
+ {accessOpen && (
+ {
+ event.preventDefault()
+ setAccessOpen(false)
+ }}
+ onClick={(event) => {
+ if (event.target === event.currentTarget) setAccessOpen(false)
+ }}
+ >
+
+ setAccessOpen(false)}>
+ Site access
+
+
+ {site ? (
+ void toggleSite()}>
+ {`${site.allowed ? 'Remove access to' : 'Allow access to'} ${new URL(site.origin).hostname}`}
+
+ ) : (
+
+ Open a web page and click the Sim toolbar icon to manage access.
+
+ )}
+
+
+
+ )}
)
}
diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index b025d30fa4e..af6506485e3 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -8,6 +8,7 @@ import { usePostHog } from 'posthog-js/react'
import { trackFreebuffConversion } from '@/lib/analytics/freebuff'
import { trackGoogleEvent } from '@/lib/analytics/google'
import { client, useSession } from '@/lib/auth/client'
+import { DISPOSABLE_EMAIL_ERROR_CODE, DISPOSABLE_EMAIL_MESSAGE } from '@/lib/auth/constants'
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
import { getEnv, isFalsy } from '@/lib/core/config/env'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
@@ -301,6 +302,9 @@ function SignupFormContent({
if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) {
errorCode = 'user_already_exists'
setEmailError('An account with this email already exists. Please sign in instead.')
+ } else if (ctx.error.code === DISPOSABLE_EMAIL_ERROR_CODE) {
+ errorCode = 'disposable_email'
+ setEmailError(DISPOSABLE_EMAIL_MESSAGE)
} else if (
ctx.error.code?.includes('BAD_REQUEST') ||
ctx.error.message?.includes('Email and password sign up is not enabled')
diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx
index 61bc9287a17..1abcb53b72d 100644
--- a/apps/sim/app/(landing)/privacy/privacy-content.tsx
+++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx
@@ -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 13, 2026',
+ lastUpdated: 'October 31, 2026',
intro: [
{
kind: 'paragraph',
@@ -799,6 +799,52 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
},
],
},
+ {
+ id: 'browser-extension',
+ heading: '10B. Sim Browser Extension',
+ blocks: [
+ {
+ kind: 'paragraph',
+ content: richText(
+ 'When You ask Sim to attach a page or selection, the browser extension reads the requested text from the top frame of that page and transfers it, the page title, and a normalized page address to the hosted Sim composer. URL credentials, query parameters, and fragments are removed. The extractor skips form inputs, editable fields, scripts, embedded frames, and elements it detects as hidden. Page text, including text in collapsed sections, may still contain personal or sensitive information, such as personal communications or health, financial, or authentication information.'
+ ),
+ },
+ {
+ kind: 'paragraph',
+ content: richText(
+ 'Capture starts only in response to Your action. The extension does not collect page content continuously or silently in the background. Optional continuing site access is requested for an individual site and can be removed through Site access or Chrome settings. Incognito capture is disabled.'
+ ),
+ },
+ {
+ kind: 'paragraph',
+ content: richText(
+ 'Attaching a page transfers the snapshot to the hosted composer before You send a message. Captured text is held in memory and is not persisted in Chrome extension storage. Short-lived extension session records contain capture intent metadata and a one-way digest of the source address, not captured text or the raw address. You can review or remove an attachment before sending it.'
+ ),
+ },
+ {
+ kind: 'paragraph',
+ content: richText(
+ 'When You send a message, submitted content is processed and retained as part of Your Sim conversation under this Privacy Policy and applicable Account or organization controls. Sim infrastructure and the AI providers or integrations used for Your requested conversation may process that content as described in sections 6 and 10A. Removing a draft attachment or uninstalling the extension does not delete previously submitted conversations. Access and deletion controls are described in section 14.'
+ ),
+ },
+ {
+ kind: 'paragraph',
+ content: richText(
+ '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: 'subheading',
+ text: 'Chrome Web Store Limited Use',
+ },
+ {
+ kind: 'paragraph',
+ content: richText(
+ "Sim's use and transfer of data obtained through the browser extension complies with the [Chrome Web Store User Data Policy](https://developer.chrome.com/docs/webstore/program-policies/user-data-faq), including its [Limited Use requirements](https://developer.chrome.com/docs/webstore/program-policies/limited-use). This data is used only to provide or improve the extension's disclosed user-facing features. It is not sold, used or transferred for personalized advertising, or used to determine creditworthiness or for lending purposes. Transfers are limited to providing or improving those features, security needs, compliance with applicable laws, or a merger, acquisition, or asset sale as permitted by that policy. Human access is limited to specific user consent, necessary security or legal purposes, or aggregated and anonymized internal operations permitted by that policy."
+ ),
+ },
+ ],
+ },
{
id: 'links-to-other-sites',
heading: '11. Links to Other Sites',
diff --git a/apps/sim/app/_shell/desktop-update-gate.tsx b/apps/sim/app/_shell/desktop-update-gate.tsx
index ed231c04534..27f47c3f244 100644
--- a/apps/sim/app/_shell/desktop-update-gate.tsx
+++ b/apps/sim/app/_shell/desktop-update-gate.tsx
@@ -51,7 +51,7 @@ function gateActionFor(state: DesktopUpdateState): GateAction {
onClick: () => {},
}
case 'ready':
- return { label: 'Restart to update', onClick: () => updates.install() }
+ return { label: 'Restart to update', onClick: () => updates.install({ mandatory: true }) }
default:
// idle, available, and error all advance the same way: check() checks
// for an update or starts the download of one already found.
diff --git a/apps/sim/app/api/copilot/confirm/route.test.ts b/apps/sim/app/api/copilot/confirm/route.test.ts
index a8a41d6bf64..6f7fef6baf0 100644
--- a/apps/sim/app/api/copilot/confirm/route.test.ts
+++ b/apps/sim/app/api/copilot/confirm/route.test.ts
@@ -1036,7 +1036,7 @@ describe('Copilot Confirm API Route', () => {
status: 'background',
message: 'Workflow execution is continuing in the background.',
timestamp: expect.any(String),
- data: { workflowId: 'workflow-1', executionId: 'unbound-execution' },
+ data: { workflowId: 'workflow-1', executionId: 'unbound-execution', status: 'running' },
})
})
diff --git a/apps/sim/app/api/favicon/route.ts b/apps/sim/app/api/favicon/route.ts
index 1a52f8417b1..195e1e7b668 100644
--- a/apps/sim/app/api/favicon/route.ts
+++ b/apps/sim/app/api/favicon/route.ts
@@ -17,8 +17,9 @@ export const GET = defineInternalBinaryRoute({
operation: getFavicon.operation,
rateLimit: internalRateLimits.user({
bucketName: 'favicon',
- /** A page can render dozens of icons at once; the browser caches each one after its first load. */
- config: { maxTokens: 300, refillRate: 300, refillIntervalMs: 60_000 },
+ // A table can show hundreds of sites at once; requests that reach the icon providers are
+ // budgeted separately (favicon use case), so this only bounds cheap cached reads.
+ config: { maxTokens: 1200, refillRate: 1200, refillIntervalMs: 60_000 },
}),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ query }) => ({ host: query.host }),
diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts
index 7cfca662081..99323a9ce90 100644
--- a/apps/sim/app/api/files/serve/[...path]/route.test.ts
+++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts
@@ -2,6 +2,7 @@
* Tests for file serve API route
*/
+import { Readable } from 'node:stream'
import {
authMockFns,
hybridAuthMockFns,
@@ -41,7 +42,7 @@ import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
const {
mockReadFile,
mockAuthenticateWorkspaceFile,
- mockReadWorkspaceFileContentByKey,
+ mockStreamAuthorizedWorkspaceFile,
mockReadWorkspaceFileRecordByKey,
mockAuthorizeWorkspaceFileRecordByKey,
mockResolveServableDocBytes,
@@ -64,7 +65,7 @@ const {
mockReadOrganizationAssistantImage: vi.fn(),
mockReadFile: vi.fn(),
mockAuthenticateWorkspaceFile: vi.fn(),
- mockReadWorkspaceFileContentByKey: vi.fn(),
+ mockStreamAuthorizedWorkspaceFile: vi.fn(),
mockReadWorkspaceFileRecordByKey: vi.fn(),
mockAuthorizeWorkspaceFileRecordByKey: vi.fn(async () => undefined),
mockResolveServableDocBytes: vi.fn(),
@@ -117,8 +118,11 @@ vi.mock('@/lib/workspace-files/api', () => ({
internalWorkspaceFileServeAuth: { authenticate: mockAuthenticateWorkspaceFile },
}))
-vi.mock('@/lib/workspace-files/application/read-workspace-file-content-by-key', () => ({
- readWorkspaceFileContentByKey: { execute: mockReadWorkspaceFileContentByKey },
+vi.mock('@/lib/workspace-files/application/stream-authorized-workspace-file', () => ({
+ streamAuthorizedWorkspaceFile: mockStreamAuthorizedWorkspaceFile,
+}))
+
+vi.mock('@/lib/workspace-files/application/read-workspace-file-record-by-key', () => ({
readWorkspaceFileRecordByKey: {
execute: mockReadWorkspaceFileRecordByKey,
authorize: mockAuthorizeWorkspaceFileRecordByKey,
@@ -129,7 +133,9 @@ vi.mock('@/lib/uploads/documents/compile', () => ({
resolveServableDocBytes: mockResolveServableDocBytes,
}))
-vi.mock('@/app/api/files/utils', () => ({
+vi.mock('@/app/api/files/utils', async (importOriginal) => ({
+ createStoredFileStreamResponse: (await importOriginal())
+ .createStoredFileStreamResponse,
FileNotFoundError,
createFileResponse: mockCreateFileResponse,
createConditionalFileResponse: mockCreateConditionalFileResponse,
@@ -151,6 +157,10 @@ const serveLogger = getMockLogger('FilesServeAPI')
const mockDownloadCopilotFile = uploadsMockFns.mockDownloadCopilotFile
const mockIsUsingCloudStorage = uploadsMockFns.mockIsUsingCloudStorage
+function webStream(text: string): ReadableStream {
+ return new Blob([text]).stream()
+}
+
describe('File Serve API Route', () => {
beforeEach(() => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
@@ -170,14 +180,7 @@ describe('File Serve API Route', () => {
mockAuthenticateWorkspaceFile.mockResolvedValue(
createSessionPrincipal({ userId: 'test-user-id' })
)
- mockReadWorkspaceFileContentByKey.mockResolvedValue({
- file: {
- id: 'file-1',
- workspaceId: 'test-workspace-id',
- name: 'report.pdf',
- },
- content: Buffer.from('generated source'),
- })
+ mockStreamAuthorizedWorkspaceFile.mockImplementation(async () => webStream('stored bytes'))
mockReadWorkspaceFileRecordByKey.mockResolvedValue({
file: {
id: 'file-1',
@@ -248,11 +251,11 @@ describe('File Serve API Route', () => {
mockDownloadCopilotFile.mockResolvedValue(Buffer.from('bytes'))
await GET(
- createMockRequest({ url: 'http://localhost:3000/api/files/serve/copilot/doc.txt' }),
- createRouteContext({ path: ['copilot', 'doc.txt'] })
+ createMockRequest({ url: 'http://localhost:3000/api/files/serve/copilot/doc.pdf' }),
+ createRouteContext({ path: ['copilot', 'doc.pdf'] })
)
- expect(mockDownloadCopilotFile).toHaveBeenCalledWith('copilot/doc.txt', {
+ expect(mockDownloadCopilotFile).toHaveBeenCalledWith('copilot/doc.pdf', {
maxBytes: MAX_BUFFERED_TRANSFER_BYTES,
})
})
@@ -287,9 +290,9 @@ describe('File Serve API Route', () => {
it('bounds the local read rather than trusting the stored size', async () => {
await GET(
createMockRequest({
- url: 'http://localhost:3000/api/files/serve/workspace/ws/test-file.txt',
+ url: 'http://localhost:3000/api/files/serve/workspace/ws/report.pdf',
}),
- createRouteContext({ path: ['workspace', 'ws', 'test-file.txt'] })
+ createRouteContext({ path: ['workspace', 'ws', 'report.pdf'] })
)
expect(mockReadLocalFileWithinLimit).toHaveBeenCalledWith(
@@ -318,8 +321,8 @@ describe('File Serve API Route', () => {
)
const response = await GET(
- createMockRequest({ url: 'http://localhost:3000/api/files/serve/workspace/ws/huge.bin' }),
- createRouteContext({ path: ['workspace', 'ws', 'huge.bin'] })
+ createMockRequest({ url: 'http://localhost:3000/api/files/serve/workspace/ws/huge.pdf' }),
+ createRouteContext({ path: ['workspace', 'ws', 'huge.pdf'] })
)
expect(response.status).toBe(413)
@@ -334,10 +337,10 @@ describe('File Serve API Route', () => {
})
const response = await GET(
- new NextRequest('http://localhost:3000/api/files/serve/workspace/ws/report.md', {
+ new NextRequest('http://localhost:3000/api/files/serve/workspace/ws/report.pdf', {
signal: controller.signal,
}),
- createRouteContext({ path: ['workspace', 'ws', 'report.md'] })
+ createRouteContext({ path: ['workspace', 'ws', 'report.pdf'] })
)
expect(response.status).toBe(499)
@@ -400,36 +403,269 @@ describe('File Serve API Route', () => {
const response = await serveVersionedDoc(true)
expect(response.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate')
})
+ })
- it('serves stored bytes under the type their extension names, not the recorded type', async () => {
+ describe('streaming stored objects', () => {
+ const principal = createSessionPrincipal({ userId: 'test-user-id' })
+ const video = {
+ id: 'file-1',
+ workspaceId: 'test-workspace-id',
+ name: 'clip.mp4',
+ key: 'workspace/test-workspace-id/clip.mp4',
+ type: 'application/octet-stream',
+ size: 300_000_000,
+ updatedAt: new Date('2026-10-31T20:28:58Z'),
+ contentUpdatedAt: new Date('2026-10-31T20:28:58Z'),
+ }
+
+ function serveWorkspaceFile(
+ file: typeof video,
+ headers: Record = {},
+ query = ''
+ ) {
mockResolveStoredFileContext.mockResolvedValue('workspace')
mockParseWorkspaceFileKey.mockReturnValue('test-workspace-id')
mockAuthenticateWorkspaceFile.mockResolvedValue(principal)
- mockReadWorkspaceFileRecordByKey.mockResolvedValue({
- file: {
- id: 'file-1',
- workspaceId: 'test-workspace-id',
- name: 'export.csv',
- key: 'workspace/test-workspace-id/export.csv',
- type: 'application/vnd.ms-excel',
- size: 3,
- contentUpdatedAt: new Date('2026-09-06'),
- },
+ mockReadWorkspaceFileRecordByKey.mockResolvedValue({ file })
+ const segments = file.key.split('/')
+ return GET(
+ new NextRequest(`http://localhost:3000/api/files/serve/${file.key}${query}`, { headers }),
+ createRouteContext({ path: segments })
+ )
+ }
+
+ it('streams a file past the transfer ceiling instead of refusing it', async () => {
+ const response = await serveWorkspaceFile(video)
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get('Content-Length')).toBe(String(video.size))
+ expect(response.headers.get('Content-Type')).toBe('video/mp4')
+ expect(response.headers.get('Accept-Ranges')).toBe('bytes')
+ expect(await response.text()).toBe('stored bytes')
+ expect(mockStreamAuthorizedWorkspaceFile).toHaveBeenCalledWith(
+ expect.objectContaining({ file: video, principal, range: undefined })
+ )
+ expect(fileUtilsServerMockFns.mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
+ })
+
+ it('rechecks access through the same use case that admitted the file', async () => {
+ await serveWorkspaceFile(video)
+ const [{ authorize }] = mockStreamAuthorizedWorkspaceFile.mock.calls[0]
+ await authorize()
+ expect(mockAuthorizeWorkspaceFileRecordByKey).toHaveBeenCalledWith(
+ expect.objectContaining({
+ principal,
+ input: { key: video.key, assertedWorkspaceId: 'test-workspace-id' },
+ })
+ )
+ })
+
+ it('answers a byte range with 206 and reads only that span', async () => {
+ const response = await serveWorkspaceFile(video, { Range: 'bytes=100-199' })
+
+ expect(response.status).toBe(206)
+ expect(response.headers.get('Content-Range')).toBe(`bytes 100-199/${video.size}`)
+ expect(response.headers.get('Content-Length')).toBe('100')
+ expect(mockStreamAuthorizedWorkspaceFile).toHaveBeenCalledWith(
+ expect.objectContaining({ range: { start: 100, end: 199 } })
+ )
+ })
+
+ it('refuses a range past the end with 416 without opening storage', async () => {
+ const response = await serveWorkspaceFile(video, { Range: `bytes=${video.size}-` })
+
+ expect(response.status).toBe(416)
+ expect(response.headers.get('Content-Range')).toBe(`bytes */${video.size}`)
+ expect(mockStreamAuthorizedWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('answers a revalidation of unchanged bytes with 304 without opening storage', async () => {
+ const first = await serveWorkspaceFile(video)
+ const etag = first.headers.get('ETag')
+ expect(etag).toBeTruthy()
+ mockStreamAuthorizedWorkspaceFile.mockClear()
+
+ const response = await serveWorkspaceFile(video, { 'If-None-Match': etag ?? '' })
+
+ expect(response.status).toBe(304)
+ expect(mockStreamAuthorizedWorkspaceFile).not.toHaveBeenCalled()
+ })
+
+ it('stops matching once a content write moves the file to new bytes', async () => {
+ const etag = (await serveWorkspaceFile(video)).headers.get('ETag') ?? ''
+ const rewritten = {
+ ...video,
+ key: 'workspace/test-workspace-id/clip-v2.mp4',
+ contentUpdatedAt: new Date('2026-11-01T00:00:00Z'),
+ }
+
+ const revalidated = await serveWorkspaceFile(rewritten, { 'If-None-Match': etag })
+ const resumed = await serveWorkspaceFile(rewritten, { Range: 'bytes=0-9', 'If-Range': etag })
+
+ expect(revalidated.status).toBe(200)
+ expect(resumed.status).toBe(200)
+ expect(resumed.headers.get('Content-Length')).toBe(String(video.size))
+ })
+
+ it('serves stored bytes under the type their extension names, not the recorded type', async () => {
+ const response = await serveWorkspaceFile({
+ ...video,
+ name: 'export.csv',
+ key: 'workspace/test-workspace-id/export.csv',
+ type: 'application/vnd.ms-excel',
+ size: 3,
})
- fileUtilsServerMockFns.mockDownloadServableFileFromStorage.mockResolvedValue({
- buffer: Buffer.from('a,b'),
- contentType: 'application/vnd.ms-excel',
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get('Content-Type')).toBe('text/csv')
+ })
+
+ it('streams an uploaded document, which is already the finished file', async () => {
+ const response = await serveWorkspaceFile({
+ ...video,
+ name: 'scan.pdf',
+ key: 'workspace/test-workspace-id/scan.pdf',
+ type: 'application/pdf',
})
- const response = await GET(
- createMockRequest({
- url: 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/export.csv',
- }),
- createRouteContext({ path: ['workspace', 'test-workspace-id', 'export.csv'] })
+ expect(response.status).toBe(200)
+ expect(response.headers.get('Content-Length')).toBe(String(video.size))
+ expect(fileUtilsServerMockFns.mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
+ })
+
+ it('streams the raw source of a page rather than rendering it', async () => {
+ mockGetContentType.mockReturnValue('text/html')
+ const response = await serveWorkspaceFile(
+ { ...video, name: 'page.html', key: 'workspace/test-workspace-id/page.html', size: 12 },
+ {},
+ '?raw=1'
)
expect(response.status).toBe(200)
- expect(response.headers.get('Content-Type')).toBe('text/csv')
+ expect(await response.text()).toBe('stored bytes')
+ })
+
+ it.each([
+ ['a generated document', { name: 'report.pdf', type: 'text/x-pdflibjs' }, ''],
+ ['a page', { name: 'page.html', type: 'text/html' }, ''],
+ [
+ 'a preview, which may be a HEIC transcode',
+ { name: 'photo.jpg', type: 'image/jpeg' },
+ '?preview=1',
+ ],
+ ])('keeps %s on the buffered path', async (_label, override, query) => {
+ fileUtilsServerMockFns.mockDownloadServableFileFromStorage.mockResolvedValue({
+ buffer: Buffer.from('rendered'),
+ contentType: 'application/octet-stream',
+ })
+ await serveWorkspaceFile(
+ { ...video, ...override, key: `workspace/test-workspace-id/${override.name}`, size: 8 },
+ {},
+ query
+ )
+
+ expect(mockStreamAuthorizedWorkspaceFile).not.toHaveBeenCalled()
+ expect(fileUtilsServerMockFns.mockDownloadServableFileFromStorage).toHaveBeenCalled()
+ })
+
+ describe('outside the workspace-file model', () => {
+ const key = 'workspace/ws/1790000000000-0123456789abcdef-clip.mp4'
+
+ function serveAttachment(headers: Record = {}, objectKey = key) {
+ mockIsUsingCloudStorage.mockReturnValue(true)
+ storageServiceMockFns.mockDownloadFileStream.mockImplementation(async () =>
+ Readable.from([Buffer.from('attachment')])
+ )
+ return GET(
+ new NextRequest(`http://localhost:3000/api/files/serve/${objectKey}`, { headers }),
+ createRouteContext({ path: objectKey.split('/') })
+ )
+ }
+
+ describe('a document name', () => {
+ const documentKey = 'workspace/ws/1790000000000-0123456789abcdef-scan.pdf'
+
+ beforeEach(() => {
+ storageServiceMockFns.mockHeadObject.mockResolvedValue({ size: 10, version: '"v1"' })
+ storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('rendered'))
+ })
+
+ it('streams when its file record says it is a finished upload', async () => {
+ uploadsMetadataMockFns.mockGetFileMetadataByKey.mockResolvedValue({
+ key: documentKey,
+ contentType: 'application/pdf',
+ })
+
+ const response = await serveAttachment({}, documentKey)
+
+ expect(response.status).toBe(200)
+ expect(storageServiceMockFns.mockDownloadFileStream).toHaveBeenCalled()
+ expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['its record says it is a generation source', { contentType: 'text/x-pdflibjs' }],
+ ['it has no file record', null],
+ ])('stays buffered when %s', async (_label, record) => {
+ uploadsMetadataMockFns.mockGetFileMetadataByKey.mockResolvedValue(
+ record && { key: documentKey, ...record }
+ )
+
+ await serveAttachment({}, documentKey)
+
+ expect(storageServiceMockFns.mockDownloadFileStream).not.toHaveBeenCalled()
+ expect(storageServiceMockFns.mockDownloadFile).toHaveBeenCalled()
+ })
+ })
+
+ it('streams a range of an attachment sized by storage, never buffering it', async () => {
+ storageServiceMockFns.mockHeadObject.mockResolvedValue({
+ size: 500_000_000,
+ version: '"v1"',
+ })
+
+ const response = await serveAttachment({ Range: 'bytes=0-1023' })
+
+ expect(response.status).toBe(206)
+ expect(response.headers.get('Content-Range')).toBe('bytes 0-1023/500000000')
+ expect(response.headers.get('ETag')).toBeTruthy()
+ expect(await response.text()).toBe('attachment')
+ expect(storageServiceMockFns.mockDownloadFileStream).toHaveBeenCalledWith({
+ key,
+ context: 'mothership',
+ range: { start: 0, end: 1023 },
+ })
+ expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled()
+ })
+
+ it('still checks access before asking storage anything', async () => {
+ mockVerifyFileAccess.mockResolvedValue(false)
+
+ const response = await serveAttachment()
+
+ expect(response.status).toBe(404)
+ expect(storageServiceMockFns.mockHeadObject).not.toHaveBeenCalled()
+ expect(storageServiceMockFns.mockDownloadFileStream).not.toHaveBeenCalled()
+ })
+
+ it('answers 404 for an object storage no longer has', async () => {
+ storageServiceMockFns.mockHeadObject.mockResolvedValue(null)
+
+ const response = await serveAttachment()
+
+ expect(response.status).toBe(404)
+ expect(storageServiceMockFns.mockDownloadFileStream).not.toHaveBeenCalled()
+ })
+
+ it('sends everything when storage offers no version to resume against', async () => {
+ storageServiceMockFns.mockHeadObject.mockResolvedValue({ size: 10 })
+
+ const response = await serveAttachment({ Range: 'bytes=0-4', 'If-Range': '"v1"' })
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get('ETag')).toBeNull()
+ expect(response.headers.get('Content-Length')).toBe('10')
+ })
})
})
})
diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts
index ec99bbfb84b..df007d91f25 100644
--- a/apps/sim/app/api/files/serve/[...path]/route.ts
+++ b/apps/sim/app/api/files/serve/[...path]/route.ts
@@ -11,6 +11,7 @@ import {
} from '@/lib/api/server/routes'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
+import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream'
import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
@@ -18,28 +19,37 @@ import type { StorageContext } from '@/lib/uploads/config'
import { readOrganizationChatAttachment } from '@/lib/uploads/contexts/organization-assistant/application'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/file-manager'
-import { downloadFile } from '@/lib/uploads/core/storage-service'
+import { downloadFile, downloadFileStream, headObject } from '@/lib/uploads/core/storage-service'
import { resolveServableDocBytes } from '@/lib/uploads/documents/compile'
import { DocCompileUserError } from '@/lib/uploads/documents/compile-error'
-import { FILE_CACHE_CONTROL, workspaceFileCacheControl } from '@/lib/uploads/server/delivery'
+import {
+ FILE_CACHE_CONTROL,
+ storedObjectEtag,
+ workspaceFileCacheControl,
+} from '@/lib/uploads/server/delivery'
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
-import { resolveStoredFileContext } from '@/lib/uploads/server/metadata'
+import { getFileMetadataByKey, resolveStoredFileContext } from '@/lib/uploads/server/metadata'
import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
-import { inferContextFromKey, resolveEffectiveMimeType } from '@/lib/uploads/utils/file'
+import {
+ inferContextFromKey,
+ isRenderableDocumentName,
+ resolveEffectiveMimeType,
+} from '@/lib/uploads/utils/file'
import { internalWorkspaceFileServeAuth } from '@/lib/workspace-files/api'
import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer'
import { finishFileDelivery } from '@/lib/workspace-files/application/finish-file-delivery'
-import {
- readWorkspaceFileContentByKey,
- readWorkspaceFileRecordByKey,
-} from '@/lib/workspace-files/application/read-workspace-file-content-by-key'
-import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
+import { readWorkspaceFileRecordByKey } from '@/lib/workspace-files/application/read-workspace-file-record-by-key'
+import { streamAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/stream-authorized-workspace-file'
+import { isSimPageSource } from '@/lib/workspace-files/page-compile'
+import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server'
+import { servesStoredBytes } from '@/lib/workspace-files/stored-bytes'
import { type KnowledgeFileAccess, verifyFileAccess } from '@/app/api/files/authorization'
import {
createConditionalFileResponse,
createFileErrorResponse,
createFileResponse,
+ createStoredFileStreamResponse,
FileNotFoundError,
type FileResponse,
findLocalFile,
@@ -191,6 +201,86 @@ async function resolveTransformedBytes(params: {
}
}
+/**
+ * Whether a request is answered with the stored object byte-for-byte, so its bytes can stream from
+ * storage instead of being read whole and capped at the transfer ceiling.
+ *
+ * Every other answer is produced from the bytes and stays buffered: see {@link servesStoredBytes},
+ * plus a `preview`, which may be a HEIC transcode — sniffed from the content, so no name or type
+ * rules it out. What streams is exactly what {@link resolveServableBytes} and the workspace
+ * resolver would have passed through unchanged.
+ */
+function servesStoredObject(
+ filename: string,
+ recordedType: string | undefined,
+ options: ServeOptions
+): boolean {
+ if (options.raw) return true
+ if (options.preview) return false
+ return servesStoredBytes(recordedType, filename)
+}
+
+/**
+ * The type a stored object streams under: what the buffered path names the same bytes — the
+ * extension's type for a served file, the route's own table for `raw`.
+ */
+function storedObjectContentType(filename: string, options: ServeOptions): string {
+ return options.raw ? getContentType(filename) : resolveEffectiveMimeType(undefined, filename)
+}
+
+/**
+ * The type recorded for an object outside the workspace-file model, read only where it decides the
+ * answer: a document name may be a finished upload or a generation source, and the object's file
+ * record, where it has one, says which. An object with no record is judged by its name.
+ */
+async function recordedDocumentType(
+ key: string,
+ context: StorageContext,
+ filename: string,
+ options: ServeOptions
+): Promise {
+ if (options.raw || options.preview || !isRenderableDocumentName(filename)) return undefined
+ return (await getFileMetadataByKey(key, context))?.contentType
+}
+
+/**
+ * Streams a stored object outside the workspace-file model (execution outputs, chat and copilot
+ * attachments, knowledge documents) when the request is answered with it as-is, or returns null
+ * for the buffered path. Storage is asked for the object's length and version, and the version is
+ * what proves a revalidation still holds.
+ */
+async function streamStoredObject(params: {
+ key: string
+ context: StorageContext
+ filename: string
+ options: ServeOptions
+ request: NextRequest
+}): Promise {
+ const { key, context, filename, options } = params
+ const recordedType = await recordedDocumentType(key, context, filename, options)
+ if (!servesStoredObject(filename, recordedType, options)) return null
+ const stored = await headObject(key, context)
+ if (!stored) throw new FileNotFoundError(`File not found: ${key}`)
+
+ logger.info('Streaming stored file', { key, context, size: stored.size })
+ return createStoredFileStreamResponse(
+ {
+ filename,
+ contentType: storedObjectContentType(filename, options),
+ cacheControl:
+ resolveServeCacheControl(options.versioned, context, false) ?? FILE_CACHE_CONTROL.private,
+ size: stored.size,
+ etag:
+ stored.version === undefined
+ ? undefined
+ : storedObjectEtag([context, key, stored.size, stored.version]),
+ open: async (range) =>
+ nodeReadableToWebStream(await downloadFileStream({ key, context, range })),
+ },
+ params.request.headers
+ )
+}
+
const STORAGE_KEY_PREFIX_RE = /^\d{13}-[a-z0-9]{7}-/
function stripStorageKeyPrefix(segment: string): string {
@@ -372,10 +462,9 @@ export const GET = withRouteHandler(
cloudKey,
userId,
options,
- request.signal,
+ request,
storageContext,
- knowledgeAccess,
- request.method === 'HEAD'
+ knowledgeAccess
)
}
@@ -383,10 +472,9 @@ export const GET = withRouteHandler(
cloudKey,
userId,
options,
- request.signal,
+ request,
storageContext,
- knowledgeAccess,
- request.method === 'HEAD'
+ knowledgeAccess
)
} catch (error) {
if (request.signal.aborted) throw error
@@ -471,40 +559,47 @@ async function handleWorkspaceFile(
headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' },
})
}
- let file: WorkspaceFileRecord
- let resolved: ServableBytes
- if (options.raw) {
- const read = await readWorkspaceFileContentByKey.execute({ principal, input, request })
- file = read.file
- resolved = {
- buffer: read.content,
- contentType: getContentType(file.name),
- dependsOnReferencedFiles: false,
- }
- } else {
- file = (await readWorkspaceFileRecordByKey.execute({ principal, input, request })).file
- const rendered = await fetchAuthorizedServableWorkspaceFileBuffer(file, principal, {
- maxBytes: MAX_BUFFERED_TRANSFER_BYTES,
- signal: request.signal,
- })
- const preview = options.preview ? await resolveServableImageBytes(rendered.buffer, key) : null
- resolved = {
- buffer: preview?.buffer ?? rendered.buffer,
- contentType: preview?.contentType ?? renderedWorkspaceContentType(file, rendered.contentType),
- dependsOnReferencedFiles: rendered.dependsOnReferencedFiles,
- }
- assertKnownSizeWithinLimit(
- resolved.buffer.length,
- MAX_BUFFERED_TRANSFER_BYTES,
- 'served file response'
+ const { file } = await readWorkspaceFileRecordByKey.execute({ principal, input, request })
+ const authorize = () => readWorkspaceFileRecordByKey.authorize({ principal, input, request })
+
+ if (servesStoredObject(file.name, file.type, options)) {
+ logger.info('Streaming workspace file', { fileId: file.id, workspaceId, size: file.size })
+ return createStoredFileStreamResponse(
+ {
+ filename: file.name,
+ contentType: storedObjectContentType(file.name, options),
+ cacheControl: workspaceFileCacheControl(options.versioned),
+ size: file.size,
+ // A content write uploads under a fresh key and stamps `contentUpdatedAt`, so the pair
+ // changes whenever the bytes do.
+ etag: storedObjectEtag([
+ file.key,
+ file.size,
+ (file.contentUpdatedAt ?? file.updatedAt).getTime(),
+ ]),
+ open: (range) => streamAuthorizedWorkspaceFile({ file, principal, authorize, range }),
+ },
+ request.headers
)
- await finishFileDelivery({
- principal,
- authorize: () => readWorkspaceFileRecordByKey.authorize({ principal, input, request }),
- receipt: rendered.receipt,
- })
}
+ const rendered = await fetchAuthorizedServableWorkspaceFileBuffer(file, principal, {
+ maxBytes: MAX_BUFFERED_TRANSFER_BYTES,
+ signal: request.signal,
+ })
+ const preview = options.preview ? await resolveServableImageBytes(rendered.buffer, key) : null
+ const resolved: ServableBytes = {
+ buffer: preview?.buffer ?? rendered.buffer,
+ contentType: preview?.contentType ?? renderedWorkspaceContentType(file, rendered.contentType),
+ dependsOnReferencedFiles: rendered.dependsOnReferencedFiles,
+ }
+ assertKnownSizeWithinLimit(
+ resolved.buffer.length,
+ MAX_BUFFERED_TRANSFER_BYTES,
+ 'served file response'
+ )
+ await finishFileDelivery({ principal, authorize, receipt: rendered.receipt })
+
logger.info('Workspace file served', {
fileId: file.id,
workspaceId,
@@ -529,12 +624,12 @@ async function handleLocalFile(
filename: string,
userId: string,
options: ServeOptions,
- signal: AbortSignal | undefined,
+ request: NextRequest,
context: StorageContext,
- knowledgeAccess: KnowledgeFileAccess | undefined,
- head: boolean
+ knowledgeAccess: KnowledgeFileAccess | undefined
): Promise {
const ownerKey = `user:${userId}`
+ const { signal } = request
try {
const hasAccess = await verifyFileAccess(
filename,
@@ -549,7 +644,7 @@ async function handleLocalFile(
throw new FileNotFoundError(`File not found: ${filename}`)
}
- if (head)
+ if (request.method === 'HEAD')
return new NextResponse(null, {
headers: {
'Cache-Control': FILE_CACHE_CONTROL.noStore,
@@ -562,13 +657,22 @@ async function handleLocalFile(
throw new FileNotFoundError(`File not found: ${filename}`)
}
+ const segment = filename.split('/').pop() || filename
+ const displayName = stripStorageKeyPrefix(segment)
+ const streamed = await streamStoredObject({
+ key: filename,
+ context,
+ filename: displayName,
+ options,
+ request,
+ })
+ if (streamed) return streamed
+
const rawBuffer = await readLocalFileWithinLimit(
filePath,
MAX_BUFFERED_TRANSFER_BYTES,
'served file'
)
- const segment = filename.split('/').pop() || filename
- const displayName = stripStorageKeyPrefix(segment)
const workspaceId = getWorkspaceIdForCompile(filename)
const {
buffer: fileBuffer,
@@ -610,12 +714,12 @@ async function handleCloudProxy(
cloudKey: string,
userId: string,
options: ServeOptions,
- signal: AbortSignal | undefined,
+ request: NextRequest,
context: StorageContext,
- knowledgeAccess: KnowledgeFileAccess | undefined,
- head: boolean
+ knowledgeAccess: KnowledgeFileAccess | undefined
): Promise {
const ownerKey = `user:${userId}`
+ const { signal } = request
try {
logger.info(`Resolved context: ${context} for key: ${cloudKey}`)
@@ -632,13 +736,24 @@ async function handleCloudProxy(
throw new FileNotFoundError(`File not found: ${cloudKey}`)
}
- if (head)
+ if (request.method === 'HEAD')
return new NextResponse(null, {
headers: {
'Cache-Control': FILE_CACHE_CONTROL.noStore,
'X-Content-Type-Options': 'nosniff',
},
})
+ const segment = cloudKey.split('/').pop() || 'download'
+ const displayName = stripStorageKeyPrefix(segment)
+ const streamed = await streamStoredObject({
+ key: cloudKey,
+ context,
+ filename: displayName,
+ options,
+ request,
+ })
+ if (streamed) return streamed
+
let rawBuffer: Buffer
if (context === 'copilot') {
@@ -652,9 +767,6 @@ async function handleCloudProxy(
maxBytes: MAX_BUFFERED_TRANSFER_BYTES,
})
}
-
- const segment = cloudKey.split('/').pop() || 'download'
- const displayName = stripStorageKeyPrefix(segment)
const workspaceId = getWorkspaceIdForCompile(cloudKey)
const {
buffer: fileBuffer,
diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts
index f8b018c6c7f..ae79767abee 100644
--- a/apps/sim/app/api/files/utils.ts
+++ b/apps/sim/app/api/files/utils.ts
@@ -4,11 +4,13 @@ import {
isPayloadSizeLimitError,
readNodeStreamToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
+import { resolveByteRange } from '@/lib/uploads/server/byte-range'
import {
bufferedRepresentationEtag,
FILE_CACHE_CONTROL,
fileDeliveryHeaders,
} from '@/lib/uploads/server/delivery'
+import type { ByteRange } from '@/lib/uploads/shared/types'
import { sanitizeFileKey } from '@/lib/uploads/utils/file'
const logger = createLogger('FilesUtils')
@@ -218,6 +220,75 @@ export function createConditionalFileResponse(
return response
}
+/** A file answered straight from its stored object, read only as far as the response needs. */
+interface StoredFileStream {
+ filename: string
+ contentType: string
+ cacheControl: string
+ /** The stored object's exact length, which `Content-Length` and every range are measured against. */
+ size: number
+ /**
+ * Strong validator for the stored bytes. Without one, a revalidation is never answered 304 and
+ * `If-Range` never matches, so a resumed transfer restarts from the first byte.
+ */
+ etag?: string
+ /** Opens the bytes to send: the whole object, or one inclusive span of it. */
+ open(range: ByteRange | undefined): Promise>
+}
+
+/**
+ * Answers a request from a stored object without materializing it: a 304 when the client already
+ * holds these bytes, a 206 for the one byte range it asked for, a 416 for a range past the end, and
+ * otherwise a 200 streaming the whole object.
+ *
+ * Ranges are what let a media element seek, and Safari will not play a video at all from a server
+ * that refuses them. A rejected range is answered before storage is opened, so it costs no read.
+ */
+export async function createStoredFileStreamResponse(
+ file: StoredFileStream,
+ requestHeaders: Headers
+): Promise {
+ const headers = fileDeliveryHeaders(file)
+ headers.set('Accept-Ranges', 'bytes')
+ if (file.etag) headers.set('ETag', file.etag)
+
+ if (file.etag && ifNoneMatchHolds(requestHeaders.get('if-none-match'), file.etag)) {
+ return new NextResponse(null, { status: 304, headers })
+ }
+
+ const resolution = resolveByteRange({
+ range: requestHeaders.get('range'),
+ ifRange: requestHeaders.get('if-range'),
+ etag: file.etag,
+ size: file.size,
+ })
+
+ if (resolution.kind === 'unsatisfiable') {
+ return NextResponse.json(
+ { error: 'RangeNotSatisfiable', message: 'Requested range is outside the file' },
+ {
+ status: 416,
+ headers: {
+ 'Accept-Ranges': 'bytes',
+ 'Cache-Control': FILE_CACHE_CONTROL.noStore,
+ 'Content-Range': `bytes */${file.size}`,
+ 'X-Content-Type-Options': 'nosniff',
+ },
+ }
+ )
+ }
+
+ if (resolution.kind === 'full') {
+ headers.set('Content-Length', String(file.size))
+ return new NextResponse(await file.open(undefined), { status: 200, headers })
+ }
+
+ const { range } = resolution
+ headers.set('Content-Length', String(range.end - range.start + 1))
+ headers.set('Content-Range', `bytes ${range.start}-${range.end}/${file.size}`)
+ return new NextResponse(await file.open(range), { status: 206, headers })
+}
+
export function createFileErrorResponse(error: Error, status = 500): NextResponse {
const statusCode =
error instanceof FileNotFoundError
diff --git a/apps/sim/app/api/organizations/[id]/people/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/people/[memberId]/route.ts
new file mode 100644
index 00000000000..1b0a8d35a54
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/people/[memberId]/route.ts
@@ -0,0 +1,38 @@
+import { getPersonContract, updatePersonContract } from '@/lib/api/contracts/people'
+import { presentPerson } from '@/lib/api/server/people-presenters'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { getPerson, updatePerson } from '@/lib/people/application/use-cases'
+
+const policy = {
+ auth: internalSessionAuth,
+ rateLimit: internalRateLimits.user({ bucketName: 'people' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
+}
+
+export const GET = defineInternalJsonRoute({
+ ...policy,
+ contract: getPersonContract,
+ operation: getPerson.operation,
+ useCase: getPerson,
+ mapInput: ({ params }) => ({ organizationId: params.id, memberId: params.memberId }),
+ present: ({ person, chain, reports }) => ({ person: presentPerson(person), chain, reports }),
+})
+
+export const PUT = defineInternalJsonRoute({
+ ...policy,
+ contract: updatePersonContract,
+ operation: updatePerson.operation,
+ useCase: updatePerson,
+ mapInput: ({ params, body }) => ({
+ organizationId: params.id,
+ memberId: params.memberId,
+ ...body,
+ }),
+ present: (person) => ({ person: presentPerson(person) }),
+})
diff --git a/apps/sim/app/api/organizations/[id]/people/departments/route.ts b/apps/sim/app/api/organizations/[id]/people/departments/route.ts
new file mode 100644
index 00000000000..8aa92682b86
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/people/departments/route.ts
@@ -0,0 +1,20 @@
+import { listDepartmentsContract } from '@/lib/api/contracts/people'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { listDepartments } from '@/lib/people/application/use-cases'
+
+export const GET = defineInternalJsonRoute({
+ contract: listDepartmentsContract,
+ operation: listDepartments.operation,
+ useCase: listDepartments,
+ auth: internalSessionAuth,
+ rateLimit: internalRateLimits.user({ bucketName: 'people' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
+ mapInput: ({ params }) => ({ organizationId: params.id }),
+ present: (departments) => ({ departments }),
+})
diff --git a/apps/sim/app/api/organizations/[id]/people/import/route.ts b/apps/sim/app/api/organizations/[id]/people/import/route.ts
new file mode 100644
index 00000000000..5b335e9d2ff
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/people/import/route.ts
@@ -0,0 +1,20 @@
+import { importPeopleContract } from '@/lib/api/contracts/people'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { importPeople } from '@/lib/people/application/use-cases'
+
+export const POST = defineInternalJsonRoute({
+ contract: importPeopleContract,
+ operation: importPeople.operation,
+ useCase: importPeople,
+ auth: internalSessionAuth,
+ rateLimit: internalRateLimits.user({ bucketName: 'people-import' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
+ mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
+ present: (result) => result,
+})
diff --git a/apps/sim/app/api/organizations/[id]/people/route.ts b/apps/sim/app/api/organizations/[id]/people/route.ts
new file mode 100644
index 00000000000..2baa4032bff
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/people/route.ts
@@ -0,0 +1,46 @@
+import { listPeopleContract } from '@/lib/api/contracts/people'
+import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
+import { presentPerson } from '@/lib/api/server/people-presenters'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { listPeople } from '@/lib/people/application/use-cases'
+import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response'
+
+/** A cursor resumes only the list it came from: the same organization, search, and department. */
+function cursorScope(id: string, search: string | undefined, department: string | undefined) {
+ return cursorScopeKey(cursorRoute(listPeopleContract, { id }), { search, department })
+}
+
+export const GET = defineInternalJsonRoute({
+ contract: listPeopleContract,
+ operation: listPeople.operation,
+ useCase: listPeople,
+ auth: internalSessionAuth,
+ rateLimit: internalRateLimits.user({ bucketName: 'people' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
+ mapInput: ({ params, query }) => ({
+ organizationId: params.id,
+ search: query.search,
+ department: query.department,
+ cursorKeys: readSortedCursor(
+ query.cursor,
+ 'name',
+ 'asc',
+ cursorScope(params.id, query.search, query.department)
+ ),
+ }),
+ present: ({ data, nextCursorKeys }, { input }) => ({
+ people: data.map(presentPerson),
+ nextCursor: writeSortedCursor(
+ nextCursorKeys,
+ 'name',
+ 'asc',
+ cursorScope(input.organizationId, input.search, input.department)
+ ),
+ }),
+})
diff --git a/apps/sim/app/api/sim-links/suggestions/route.ts b/apps/sim/app/api/sim-links/suggestions/route.ts
new file mode 100644
index 00000000000..d7535a6cc71
--- /dev/null
+++ b/apps/sim/app/api/sim-links/suggestions/route.ts
@@ -0,0 +1,24 @@
+import { suggestSimLinksContract } from '@/lib/api/contracts/sim-links'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { suggestSimLinks } from '@/lib/sim-links/application/use-cases'
+
+export const GET = defineInternalJsonRoute({
+ contract: suggestSimLinksContract,
+ operation: suggestSimLinks.operation,
+ useCase: suggestSimLinks,
+ auth: internalSessionAuth,
+ rateLimit: internalRateLimits.user({
+ bucketName: 'sim-link-suggestions',
+ // The address bar asks again as each character is typed.
+ config: { maxTokens: 120, refillRate: 120, refillIntervalMs: 60_000 },
+ }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
+ mapInput: ({ query }) => ({ query: query.q }),
+ present: (links) => ({ links }),
+})
diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts
index 697796ca2ce..80a3a0cb24c 100644
--- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts
+++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts
@@ -113,7 +113,12 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
expect((await response.json()).error.code).toBe('NOT_FOUND')
expect(readMetadata).toHaveBeenCalledWith(
expect.objectContaining({
- input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: false },
+ input: {
+ fileId: FILE_ID,
+ acceptVfsPath: true,
+ assertedWorkspaceId: WORKSPACE_ID,
+ includeDeleted: false,
+ },
})
)
})
@@ -127,7 +132,12 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
expect((await response.json()).error.code).toBe('NOT_FOUND')
expect(readMetadata).toHaveBeenCalledWith(
expect.objectContaining({
- input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: false },
+ input: {
+ fileId: FILE_ID,
+ acceptVfsPath: true,
+ assertedWorkspaceId: WORKSPACE_ID,
+ includeDeleted: false,
+ },
})
)
})
@@ -159,7 +169,12 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
expect(readMetadata).toHaveBeenCalledWith(
expect.objectContaining({
principal: auth.principal,
- input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: true },
+ input: {
+ fileId: FILE_ID,
+ acceptVfsPath: true,
+ assertedWorkspaceId: WORKSPACE_ID,
+ includeDeleted: true,
+ },
})
)
})
diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts
index 02a57449037..d7d018d7b48 100644
--- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts
+++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts
@@ -26,6 +26,7 @@ export const GET = defineV2JsonRoute({
errorPolicy: v2FileErrorPolicies.concealResourceAuthorization,
mapInput: ({ params, query }) => ({
fileId: params.fileId,
+ acceptVfsPath: true,
assertedWorkspaceId: query.workspaceId,
includeDeleted: query.scope === 'archived',
}),
diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts
index 983f1c7b082..8cec04f28f5 100644
--- a/apps/sim/app/api/v2/files/[fileId]/route.ts
+++ b/apps/sim/app/api/v2/files/[fileId]/route.ts
@@ -63,6 +63,7 @@ export const PATCH = defineV2JsonRoute({
errorPolicy: v2FileErrorPolicies.concealResourceAuthorization,
mapInput: ({ params, body }) => ({
fileId: params.fileId,
+ acceptVfsPath: true,
assertedWorkspaceId: body.workspaceId,
name: body.name,
}),
@@ -85,6 +86,7 @@ export const DELETE = defineV2JsonRoute({
errorPolicy: v2FileErrorPolicies.concealResourceAuthorization,
mapInput: ({ params, query }) => ({
fileId: params.fileId,
+ acceptVfsPath: true,
assertedWorkspaceId: query.workspaceId,
}),
useCase: deleteWorkspaceFileOperation,
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts
index 107ff9181e1..68cf01d3b59 100644
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts
@@ -1,8 +1,8 @@
-import { ChartColumn, Home, Integration, Link, Search } from '@sim/emcn/icons'
+import { ChartColumn, Home, Integration, Link, Search, Users } from '@sim/emcn/icons'
import { organizationRoutes } from '@/lib/navigation/paths'
import type { SidebarNavItemData } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
-type OrganizationNavRoute = 'home' | 'search' | 'integrations' | 'simLinks' | 'benchmark'
+type OrganizationNavRoute = 'home' | 'search' | 'people' | 'integrations' | 'simLinks' | 'benchmark'
interface OrganizationNavEntry {
id: string
@@ -18,6 +18,7 @@ interface OrganizationNavEntry {
const ORGANIZATION_NAV_ENTRIES: readonly OrganizationNavEntry[] = [
{ id: 'home', label: 'Home', icon: Home, route: 'home' },
{ id: 'search', label: 'Search', icon: Search, route: 'search' },
+ { id: 'people', label: 'People', icon: Users, route: 'people' },
{ id: 'integrations', label: 'Integrations', icon: Integration, route: 'integrations' },
{ id: 'sim-links', label: 'Sim Links', icon: Link, route: 'simLinks' },
{ id: 'benchmark', label: 'Benchmark', icon: ChartColumn, route: 'benchmark' },
@@ -28,6 +29,7 @@ interface OrganizationNavAvailability {
mothershipAvailable: boolean
benchmarkEnabled?: boolean
simLinksEnabled?: boolean
+ peopleEnabled?: boolean
}
export function buildOrganizationNavItems(
@@ -37,12 +39,14 @@ export function buildOrganizationNavItems(
mothershipAvailable,
benchmarkEnabled = false,
simLinksEnabled = false,
+ peopleEnabled = false,
}: OrganizationNavAvailability
): SidebarNavItemData[] {
const routes = organizationRoutes(organizationId)
const available: Record = {
home: mothershipAvailable,
search: searchAvailable,
+ people: peopleEnabled,
integrations: searchAvailable,
simLinks: simLinksEnabled,
benchmark: benchmarkEnabled,
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx
index 4cdbc16426d..e465ddbc458 100644
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx
@@ -58,6 +58,7 @@ interface OrganizationChatsProps
interface OrganizationSidebarProps {
benchmarkEnabled?: boolean
simLinksEnabled?: boolean
+ peopleEnabled?: boolean
}
function OrganizationChats({ organizationId, ...props }: OrganizationChatsProps) {
@@ -77,6 +78,7 @@ function OrganizationChats({ organizationId, ...props }: OrganizationChatsProps)
export const OrganizationSidebar = memo(function OrganizationSidebar({
benchmarkEnabled = false,
simLinksEnabled = false,
+ peopleEnabled = false,
}: OrganizationSidebarProps) {
const { isCollapsed: railCollapsed, isPeeking, onToggle: toggleCollapsed } = useSidebarChrome()
/** The peek card always renders the expanded layout, whatever the rail's state. */
@@ -103,6 +105,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar({
mothershipAvailable: canUseHome,
benchmarkEnabled,
simLinksEnabled,
+ peopleEnabled,
})
const settingsPath = organizationRoutes(organization.id).settings
const isSettings = pathname === settingsPath || pathname?.startsWith(`${settingsPath}/`)
diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx
index 390471c2a26..f55676e9f04 100644
--- a/apps/sim/app/o/[organizationId]/layout.tsx
+++ b/apps/sim/app/o/[organizationId]/layout.tsx
@@ -12,6 +12,7 @@ import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
+import { canBrowsePeople } from '@/lib/people/feature-flag'
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
@@ -64,7 +65,14 @@ export default async function OrganizationLayout({
if (!context) {
return
}
- if (!context.mothershipAvailable && !context.searchAccess.memberScoped)
+ const peopleEnabledLookup = canBrowsePeople(organizationId, context.viewer.role)
+ const simLinksEnabledLookup = isSimLinksEnabled(organizationId)
+ if (
+ !context.mothershipAvailable &&
+ !context.searchAccess.memberScoped &&
+ !(await peopleEnabledLookup) &&
+ !(await simLinksEnabledLookup)
+ )
redirect(WORKSPACE_SETTINGS_PATH)
const [
@@ -77,6 +85,7 @@ export default async function OrganizationLayout({
changelogEnabled,
projectsEnabled,
simLinksEnabled,
+ peopleEnabled,
] = await Promise.all([
prefetchOrganizationSidebar(
queryClient,
@@ -94,7 +103,8 @@ export default async function OrganizationLayout({
isWorkflowTestsEnabled(organizationId),
isChangelogEnabled(organizationId),
isProjectsEnabled(),
- isSimLinksEnabled(organizationId),
+ simLinksEnabledLookup,
+ peopleEnabledLookup,
])
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
@@ -123,6 +133,7 @@ export default async function OrganizationLayout({
}
initialSidebarCollapsed={initialSidebarCollapsed}
diff --git a/apps/sim/app/o/[organizationId]/links/search-params.ts b/apps/sim/app/o/[organizationId]/links/search-params.ts
index 5549f6ea3ed..17b39723673 100644
--- a/apps/sim/app/o/[organizationId]/links/search-params.ts
+++ b/apps/sim/app/o/[organizationId]/links/search-params.ts
@@ -7,13 +7,13 @@ const simLinkViewParsers = { create: parseAsString } as const
export const serializeSimLinkView = createSerializer(simLinkViewParsers)
-/** `search` filters by name, destination, or description; only its URL write is debounced. */
+/** `search` filters by name, destination, or description. */
export const simLinksParsers = {
...simLinkViewParsers,
search: parseAsString.withDefault(''),
} as const
-/** Shared `sort` + `dir` params for the Sim Links list. Default: alphabetical. */
+/** Shared `sort` + `dir` params for the Sim Links list. */
export const simLinksSortParams = createSortParams(SIM_LINK_SORTS, {
column: 'name',
direction: 'asc',
diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx
index 4ce55a6a8af..5647e2f0b6c 100644
--- a/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx
+++ b/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx
@@ -1,6 +1,7 @@
'use client'
import { useId, useState } from 'react'
+import { SIM_LINK_NAME_MAX_LENGTH } from '@sim/browser-extension-protocol'
import {
Checkbox,
ChipModal,
@@ -10,12 +11,12 @@ import {
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
+import { SIM_DOCS_URL } from '@sim/utils/site'
import { isApiClientError } from '@/lib/api/client/errors'
import type { SimLink, SimLinkBody } from '@/lib/api/contracts/sim-links'
import {
isVariableSimLinkUrl,
SIM_LINK_DESCRIPTION_MAX_LENGTH,
- SIM_LINK_NAME_MAX_LENGTH,
SIM_LINK_VARIABLE,
simLinkFieldsSchema,
toSimLinkName,
@@ -24,7 +25,7 @@ import { useCreateSimLink, useUpdateSimLink } from '@/hooks/queries/sim-links'
type Field = 'name' | 'url' | 'defaultUrl' | 'description'
-const SIM_LINKS_DOCS_URL = 'https://docs.sim.ai/search/sim-links'
+const SIM_LINKS_DOCS_URL = `${SIM_DOCS_URL}/search/sim-links`
function openDocs() {
window.open(SIM_LINKS_DOCS_URL, '_blank', 'noopener,noreferrer')
@@ -173,7 +174,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
onCheckedChange={(checked) => change('restrictEdit', checked === true)}
/>
- Only you and admins can edit
+ Only the creator and admins can edit
diff --git a/apps/sim/app/o/[organizationId]/links/sim-links.tsx b/apps/sim/app/o/[organizationId]/links/sim-links.tsx
index a0407046721..7f00eae3bff 100644
--- a/apps/sim/app/o/[organizationId]/links/sim-links.tsx
+++ b/apps/sim/app/o/[organizationId]/links/sim-links.tsx
@@ -1,12 +1,13 @@
'use client'
-import { useMemo, useState } from 'react'
-import { Avatar, Chip, ChipConfirmModal, ChipModalError, writeTextToClipboard } from '@sim/emcn'
+import { type MouseEvent, useCallback, useMemo, useState } from 'react'
+import { Avatar, ChipConfirmModal, ChipModalError, writeTextToClipboard } from '@sim/emcn'
import { Link } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useQueryStates } from 'nuqs'
import type { SimLink } from '@/lib/api/contracts/sim-links'
import { simLinkPath } from '@/lib/navigation/paths'
+import { toSimLinkName } from '@/lib/sim-links/validation'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
import {
NEW_SIM_LINK_ACTION,
@@ -23,6 +24,7 @@ import { SimLinkContextMenu } from '@/app/o/[organizationId]/links/sim-link-cont
import { SimLinkIcon } from '@/app/o/[organizationId]/links/sim-link-icon'
import { SimLinkModal } from '@/app/o/[organizationId]/links/sim-link-modal'
import {
+ ResourceLoadError,
ResourceNoResults,
SimLinksEmptyState,
} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
@@ -89,6 +91,8 @@ export function SimLinks({ organizationId }: SimLinksProps) {
const loaded = useMemo(() => links.data?.pages.flatMap((page) => page.links) ?? [], [links.data])
const linksById = useMemo(() => new Map(loaded.map((link) => [link.id, link])), [loaded])
const menuLink = menuLinkId ? linksById.get(menuLinkId) : undefined
+ // A row can leave the results while its menu is open; forgetting it keeps the menu from
+ // reappearing at the old position when the row comes back.
if (menuLinkId && !menuLink) setMenuLinkId(null)
const rows: ResourceRow[] = useMemo(
@@ -127,6 +131,25 @@ export function SimLinks({ organizationId }: SimLinksProps) {
}
const sortConfig: SortConfig = { options: SORT_OPTIONS, active: activeSort, onSort, onClear }
const startCreate = () => void setView({ create: '' })
+ const { fetchNextPage } = links
+ const { handleContextMenu } = contextMenu
+ // Rows are memoized, so stable handlers keep a keystroke from re-rendering every row.
+ const openRow = useCallback(
+ (id: string) => {
+ const link = linksById.get(id)
+ if (link) openLink(link.name)
+ },
+ [linksById]
+ )
+ const openRowMenu = useCallback(
+ (event: MouseEvent, id: string) => {
+ if (!linksById.has(id)) return
+ setMenuLinkId(id)
+ handleContextMenu(event)
+ },
+ [linksById, handleContextMenu]
+ )
+ const loadMore = useCallback(() => void fetchNextPage(), [fetchNextPage])
if (!links.data && !links.isLoadingError) return
@@ -141,31 +164,22 @@ export function SimLinks({ organizationId }: SimLinksProps) {
{
- const link = linksById.get(id)
- if (link) openLink(link.name)
- }}
- onRowContextMenu={(event, id) => {
- if (!linksById.has(id)) return
- setMenuLinkId(id)
- contextMenu.handleContextMenu(event)
- }}
- onLoadMore={() => void links.fetchNextPage()}
+ virtualized
+ onRowClick={openRow}
+ onRowContextMenu={openRowMenu}
+ onLoadMore={loadMore}
hasMore={links.hasNextPage}
isLoadingMore={links.isFetchingNextPage}
isFetching={links.isFetching}
loadMoreFailed={links.isFetchNextPageError}
emptyState={
links.isLoadingError ? (
-
-
{links.error.message}
-
void links.refetch()}>Retry
-
+ void links.refetch()} />
) : query ? (
setSearch('')}
/>
) : (
@@ -195,7 +209,7 @@ export function SimLinks({ organizationId }: SimLinksProps) {
void setView({ create: null })}
/>
)}
diff --git a/apps/sim/app/o/[organizationId]/page.tsx b/apps/sim/app/o/[organizationId]/page.tsx
index 14bc0c9c529..82b486cc5cf 100644
--- a/apps/sim/app/o/[organizationId]/page.tsx
+++ b/apps/sim/app/o/[organizationId]/page.tsx
@@ -2,6 +2,8 @@ import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
+import { canBrowsePeople } from '@/lib/people/feature-flag'
+import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
export default async function OrganizationPage({
@@ -17,11 +19,8 @@ export default async function OrganizationPage({
}
const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
if (!context) notFound()
- redirect(
- context.mothershipAvailable && context.canBuild
- ? routes.home
- : context.searchAccess.memberScoped
- ? routes.search
- : WORKSPACE_SETTINGS_PATH
- )
+ if (context.mothershipAvailable && context.canBuild) redirect(routes.home)
+ if (context.searchAccess.memberScoped) redirect(routes.search)
+ if (await canBrowsePeople(organizationId, context.viewer.role)) redirect(routes.people)
+ redirect((await isSimLinksEnabled(organizationId)) ? routes.simLinks : WORKSPACE_SETTINGS_PATH)
}
diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx
new file mode 100644
index 00000000000..4d1e787708c
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx
@@ -0,0 +1,5 @@
+import { ApplicationLoading } from '@/app/_shell/application-loading'
+
+export default function Loading() {
+ return
+}
diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx
new file mode 100644
index 00000000000..19227dcf579
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx
@@ -0,0 +1,27 @@
+import type { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { isPeopleEnabled } from '@/lib/people/feature-flag'
+import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
+import { Person } from '@/app/o/[organizationId]/people/[memberId]/person'
+
+export const metadata: Metadata = { title: 'People' }
+
+interface PersonPageProps {
+ params: Promise<{ organizationId: string; memberId: string }>
+}
+
+export default async function PersonPage({ params }: PersonPageProps) {
+ const { organizationId, memberId } = await params
+ const session = await getSession()
+ if (!session?.user)
+ redirect(
+ buildAuthCrossLink('/login', {
+ callbackUrl: organizationRoutes(organizationId).person(memberId),
+ isInviteFlow: false,
+ })
+ )
+ if (!(await isPeopleEnabled(organizationId))) notFound()
+ return
+}
diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx
new file mode 100644
index 00000000000..ff5fa4f383b
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx
@@ -0,0 +1,168 @@
+'use client'
+
+import { useState } from 'react'
+import {
+ Chip,
+ ChipCombobox,
+ ChipDatePicker,
+ ChipModal,
+ ChipModalBody,
+ ChipModalError,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+} from '@sim/emcn'
+import type { Person, PersonBody } from '@/lib/api/contracts/people'
+import { memberProfileFieldsSchema, PROFILE_TEXT_MAX_LENGTH } from '@/lib/people/validation'
+import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
+import { usePeople, useUpdatePerson } from '@/hooks/queries/people'
+import { useDebounce } from '@/hooks/use-debounce'
+
+type TextField = 'title' | 'department' | 'location'
+
+/** The picker's value for no manager; membership ids are never this. */
+const NO_MANAGER = 'no-manager'
+
+const TEXT_FIELDS: readonly { field: TextField; title: string; placeholder: string }[] = [
+ { field: 'title', title: 'Title', placeholder: 'Staff Engineer' },
+ { field: 'department', title: 'Department', placeholder: 'Engineering' },
+ { field: 'location', title: 'Location', placeholder: 'San Francisco' },
+]
+
+interface PersonModalProps {
+ organizationId: string
+ person: Person
+ onClose: () => void
+}
+
+/** Edits one person's profile; managers are chosen from the organization's people. */
+export function PersonModal({ organizationId, person, onClose }: PersonModalProps) {
+ const update = useUpdatePerson(organizationId)
+ const [draft, setDraft] = useState({
+ title: person.title,
+ department: person.department,
+ location: person.location,
+ startDate: person.startDate,
+ managerMemberId: person.managerMemberId,
+ })
+ const [errors, setErrors] = useState>>({})
+ const [managerSearch, setManagerSearch] = useState('')
+ /**
+ * The chosen manager's name, kept with the choice: the search results may no longer include them.
+ * Only a stored manager who is suspended has an id and no name.
+ */
+ const [managerName, setManagerName] = useState(person.manager?.name ?? null)
+ const candidates = usePeople(
+ organizationId,
+ useDebounce(managerSearch.trim(), SEARCH_DEBOUNCE_MS),
+ ''
+ )
+ const found = (candidates.data?.pages ?? []).flatMap((page) =>
+ page.people.filter((candidate) => candidate.memberId !== person.memberId)
+ )
+ const managerOptions = [
+ { value: NO_MANAGER, label: 'No manager' },
+ ...found.map((candidate) => ({
+ value: candidate.memberId,
+ label: `${candidate.name} (${candidate.email})`,
+ })),
+ ]
+ const managerLabel = draft.managerMemberId ? (managerName ?? 'Suspended member') : 'No manager'
+
+ const change = (field: K, value: PersonBody[K]) => {
+ setDraft((current) => ({ ...current, [field]: value }))
+ setErrors((current) => ({ ...current, [field]: undefined }))
+ if (update.isError) update.reset()
+ }
+
+ const save = () => {
+ const parsed = memberProfileFieldsSchema.safeParse(draft)
+ if (!parsed.success) {
+ setErrors(
+ Object.fromEntries(
+ parsed.error.issues.map((issue) => [String(issue.path[0]), issue.message])
+ )
+ )
+ return
+ }
+ update.mutate({ memberId: person.memberId, body: parsed.data }, { onSuccess: onClose })
+ }
+
+ const title = `Edit ${person.name}`
+ return (
+ !open && onClose()}
+ dismissDisabled={update.isPending}
+ srTitle={title}
+ >
+ {title}
+
+ {TEXT_FIELDS.map(({ field, title: label, placeholder }) => (
+ change(field, value || null)}
+ error={errors[field]}
+ />
+ ))}
+
+ {() => (
+
+ change('startDate', value)}
+ placeholder='Not recorded'
+ disabled={update.isPending}
+ fullWidth
+ />
+ {draft.startDate && (
+ change('startDate', null)}>
+ Clear
+
+ )}
+
+ )}
+
+
+ {() => (
+ {
+ const manager = found.find((candidate) => candidate.memberId === value)
+ setManagerName(manager?.name ?? null)
+ change('managerMemberId', manager?.memberId ?? null)
+ }}
+ overlayContent={managerLabel}
+ searchable
+ onSearchChange={setManagerSearch}
+ searchPlaceholder='Search people...'
+ isLoading={candidates.isFetching && !candidates.isFetchingNextPage}
+ hasMore={candidates.hasNextPage}
+ isLoadingMore={candidates.isFetchingNextPage}
+ onLoadMore={() => void candidates.fetchNextPage()}
+ emptyMessage='No one matches'
+ disabled={update.isPending}
+ className='w-full'
+ />
+ )}
+
+ {update.error?.message}
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx
new file mode 100644
index 00000000000..772f0bbfd5e
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx
@@ -0,0 +1,191 @@
+'use client'
+
+import { useState } from 'react'
+import { Avatar, Chip, ChipLink } from '@sim/emcn'
+import { ArrowLeft } from '@sim/emcn/icons'
+import Link from 'next/link'
+import { isApiClientError } from '@/lib/api/client/errors'
+import type { PersonSummary } from '@/lib/api/contracts/people'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { PersonModal } from '@/app/o/[organizationId]/people/[memberId]/person-modal'
+import { serializePeopleView } from '@/app/o/[organizationId]/people/search-params'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import {
+ CredentialDetailHeading,
+ CredentialDetailLayout,
+ DetailSection,
+} from '@/app/workspace/[workspaceId]/components/credential-detail'
+import {
+ SettingsEmptyState,
+ SettingsQueryErrorState,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field'
+import {
+ RESOURCE_LIST_STACK,
+ SettingsResourceRow,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
+import { usePerson } from '@/hooks/queries/people'
+
+const NOT_RECORDED = Not recorded
+
+const START_DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'medium',
+ timeZone: 'UTC',
+})
+
+/** `2024-01-31` as a calendar date, with how long ago it was: "Jan 31, 2024 · 2 yrs". */
+function describeStartDate(startDate: string): string {
+ const start = new Date(`${startDate}T00:00:00Z`)
+ const now = new Date()
+ const months =
+ (now.getUTCFullYear() - start.getUTCFullYear()) * 12 +
+ now.getUTCMonth() -
+ start.getUTCMonth() -
+ (now.getUTCDate() < start.getUTCDate() ? 1 : 0)
+ const tenure =
+ months < 0
+ ? 'starts soon'
+ : months < 1
+ ? 'new'
+ : months < 12
+ ? `${months} mo`
+ : `${Math.floor(months / 12)} yr${months >= 24 ? 's' : ''}`
+ return `${START_DATE_FORMAT.format(start)} · ${tenure}`
+}
+
+interface PersonRowsProps {
+ organizationId: string
+ people: readonly PersonSummary[]
+ empty: string
+}
+
+function PersonRows({ organizationId, people, empty }: PersonRowsProps) {
+ if (!people.length) return {empty}
+ const routes = organizationRoutes(organizationId)
+ return (
+
+ {people.map((person) => (
+ }
+ title={person.name}
+ description={person.title ?? person.email}
+ href={routes.person(person.memberId)}
+ clickLabel={`Open ${person.name}`}
+ navigable
+ />
+ ))}
+
+ )
+}
+
+interface PersonProps {
+ organizationId: string
+ memberId: string
+}
+
+/** One person's profile: what they do, where they are, and where they sit in the organization. */
+export function Person({ organizationId, memberId }: PersonProps) {
+ const { viewer } = useOrganizationContext()
+ const query = usePerson(organizationId, memberId)
+ const [editing, setEditing] = useState(false)
+ const routes = organizationRoutes(organizationId)
+ const data = query.data
+
+ return (
+
+ People
+
+ }
+ actions={
+ viewer.isAdmin && data ? setEditing(true)}>Edit : undefined
+ }
+ >
+ {query.error ? (
+ isApiClientError(query.error) && query.error.status === 404 ? (
+
+ This person isn't in your organization.
+
+ ) : (
+ void query.refetch()}
+ variant='inline'
+ />
+ )
+ ) : !data ? (
+ Loading…
+ ) : (
+ <>
+
+ }
+ title={data.person.name}
+ subtitle={data.person.title}
+ />
+
+
+
+
+ {data.person.email}
+
+
+
+ {data.person.department ? (
+
+ {data.person.department}
+
+ ) : (
+ NOT_RECORDED
+ )}
+
+
{data.person.location ?? NOT_RECORDED}
+
+ {data.person.startDate ? describeStartDate(data.person.startDate) : NOT_RECORDED}
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {editing && data && (
+ setEditing(false)}
+ />
+ )}
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/people/columns.ts b/apps/sim/app/o/[organizationId]/people/columns.ts
new file mode 100644
index 00000000000..9a7a45f83fd
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/people/columns.ts
@@ -0,0 +1,15 @@
+import { Upload } from '@sim/emcn/icons'
+import type { ResourceColumn } from '@/app/workspace/[workspaceId]/components/resource/resource'
+
+/** Shared by the page and its loading chrome so the skeleton matches the table. */
+export const PEOPLE_COLUMNS = [
+ { id: 'name', header: 'Name', widthMultiplier: 1.4 },
+ { id: 'title', header: 'Title', widthMultiplier: 1.4 },
+ { id: 'department', header: 'Department' },
+ { id: 'location', header: 'Location' },
+ { id: 'manager', header: 'Manager', widthMultiplier: 1.2 },
+] as const satisfies readonly ResourceColumn[]
+
+export const PEOPLE_SEARCH_PLACEHOLDER = 'Search people...'
+
+export const IMPORT_PEOPLE_ACTION = { text: 'Import', icon: Upload } as const
diff --git a/apps/sim/app/o/[organizationId]/people/loading.tsx b/apps/sim/app/o/[organizationId]/people/loading.tsx
new file mode 100644
index 00000000000..3ad0c674af3
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/people/loading.tsx
@@ -0,0 +1,17 @@
+'use client'
+
+import { Users } from '@sim/emcn/icons'
+import { PEOPLE_COLUMNS, PEOPLE_SEARCH_PLACEHOLDER } from '@/app/o/[organizationId]/people/columns'
+import { ResourceChromeFallback } from '@/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback'
+
+export default function PeopleLoading() {
+ return (
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/people/page.tsx b/apps/sim/app/o/[organizationId]/people/page.tsx
new file mode 100644
index 00000000000..3395dbd7c7a
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/people/page.tsx
@@ -0,0 +1,27 @@
+import type { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { isPeopleEnabled } from '@/lib/people/feature-flag'
+import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
+import { People } from '@/app/o/[organizationId]/people/people'
+
+export const metadata: Metadata = { title: 'People' }
+
+interface PeoplePageProps {
+ params: Promise<{ organizationId: string }>
+}
+
+export default async function PeoplePage({ params }: PeoplePageProps) {
+ const { organizationId } = await params
+ const session = await getSession()
+ if (!session?.user)
+ redirect(
+ buildAuthCrossLink('/login', {
+ callbackUrl: organizationRoutes(organizationId).people,
+ isInviteFlow: false,
+ })
+ )
+ if (!(await isPeopleEnabled(organizationId))) notFound()
+ return
+}
diff --git a/apps/sim/app/o/[organizationId]/people/people-import-modal.tsx b/apps/sim/app/o/[organizationId]/people/people-import-modal.tsx
new file mode 100644
index 00000000000..384a4daf808
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/people/people-import-modal.tsx
@@ -0,0 +1,180 @@
+'use client'
+
+import { useRef, useState } from 'react'
+import {
+ ChipModal,
+ ChipModalBody,
+ ChipModalError,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+ cn,
+ scrollFadeAttributes,
+ scrollFadeClass,
+ useScrollEdges,
+} from '@sim/emcn'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { PeopleImportResult } from '@/lib/api/contracts/people'
+import { saveBlob } from '@/lib/uploads/client/download'
+import {
+ type PeopleSheet,
+ readPeopleSheet,
+} from '@/app/o/[organizationId]/people/read-people-sheet'
+import { useImportPeople } from '@/hooks/queries/people'
+
+const ACCEPTED_FILES = '.csv,.tsv,.xlsx,.xls'
+const TEMPLATE_CSV =
+ 'email,title,department,location,start_date,manager_email\nada@example.com,Engineer,Platform,Berlin,2024-01-31,grace@example.com\n'
+
+function downloadTemplate() {
+ saveBlob(new Blob([TEMPLATE_CSV], { type: 'text/csv' }), 'people-template.csv')
+}
+
+function plural(count: number, noun: string): string {
+ return `${count} ${noun}${count === 1 ? '' : 's'}`
+}
+
+/** The preview's issues in file order, with a count of any past the ones listed. */
+interface ImportIssueListProps {
+ result: PeopleImportResult
+}
+
+function ImportIssueList({ result }: ImportIssueListProps) {
+ const listRef = useRef(null)
+ const edges = useScrollEdges(listRef)
+ const unlisted = result.skipped + result.warnings - result.issues.length
+ return (
+
+ {result.issues.map((issue) => (
+
+ Row {issue.row} {' '}
+
+ {issue.email ? `${issue.email}: ` : ''}
+ {issue.message}
+
+
+ ))}
+ {unlisted > 0 && (
+ and {plural(unlisted, 'more issue')}
+ )}
+
+ )
+}
+
+interface PeopleImportModalProps {
+ organizationId: string
+ onClose: () => void
+}
+
+/**
+ * Imports people data from an HR export: choosing a file previews what would change, and
+ * nothing is written until the preview is confirmed.
+ */
+export function PeopleImportModal({ organizationId, onClose }: PeopleImportModalProps) {
+ const importPeople = useImportPeople(organizationId)
+ const [sheet, setSheet] = useState(null)
+ const [readError, setReadError] = useState(null)
+ const [reading, setReading] = useState(false)
+ const [preview, setPreview] = useState(null)
+ const pending = reading || importPeople.isPending
+
+ const choose = async ([file]: File[]) => {
+ if (!file) return
+ setReadError(null)
+ setSheet(null)
+ setPreview(null)
+ importPeople.reset()
+ setReading(true)
+ try {
+ const next = await readPeopleSheet(file)
+ setSheet(next)
+ importPeople.mutate(
+ { headers: next.headers, rows: next.rows, apply: false },
+ { onSuccess: setPreview }
+ )
+ } catch (error) {
+ setReadError(getErrorMessage(error, 'Could not read the file'))
+ } finally {
+ setReading(false)
+ }
+ }
+
+ const apply = () => {
+ if (!sheet) return
+ importPeople.mutate(
+ { headers: sheet.headers, rows: sheet.rows, apply: true },
+ { onSuccess: onClose }
+ )
+ }
+
+ return (
+ !open && onClose()}
+ dismissDisabled={importPeople.isPending}
+ srTitle='Import people data'
+ >
+ Import people data
+
+ void choose(files)}
+ />
+ {preview && (
+
+ {() => (
+
+
+ {preview.updated
+ ? `${plural(preview.updated, 'profile')} will be updated.`
+ : 'Nothing to update.'}{' '}
+
+ {[
+ preview.unchanged && `${preview.unchanged} unchanged`,
+ preview.skipped && `${preview.skipped} skipped`,
+ preview.warnings && plural(preview.warnings, 'warning'),
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+ {preview.issues.length > 0 &&
}
+
+ )}
+
+ )}
+ {importPeople.error?.message}
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/people/people.tsx b/apps/sim/app/o/[organizationId]/people/people.tsx
new file mode 100644
index 00000000000..8cd5913c685
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/people/people.tsx
@@ -0,0 +1,174 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import { Avatar, ChipCombobox } from '@sim/emcn'
+import { Users } from '@sim/emcn/icons'
+import { useRouter } from 'next/navigation'
+import { useQueryStates } from 'nuqs'
+import type { PersonSummary } from '@/lib/api/contracts/people'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
+import {
+ IMPORT_PEOPLE_ACTION,
+ PEOPLE_COLUMNS,
+ PEOPLE_SEARCH_PLACEHOLDER,
+} from '@/app/o/[organizationId]/people/columns'
+import PeopleLoading from '@/app/o/[organizationId]/people/loading'
+import { PeopleImportModal } from '@/app/o/[organizationId]/people/people-import-modal'
+import { peopleParsers, peopleUrlKeys } from '@/app/o/[organizationId]/people/search-params'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import {
+ ResourceLoadError,
+ ResourceNoResults,
+} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
+import {
+ type FilterTag,
+ ResourceFilterPanel,
+ ResourceFilterSection,
+ type SearchConfig,
+} from '@/app/workspace/[workspaceId]/components/resource/components/resource-options'
+import {
+ Resource,
+ type ResourceRow,
+} from '@/app/workspace/[workspaceId]/components/resource/resource'
+import { useDepartments, usePeople } from '@/hooks/queries/people'
+import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
+import { useSearchFilterValue } from '@/hooks/use-search-filter-value'
+
+/** The filter's value for every department: stored text can't hold a NUL, so no department is this. */
+const ALL_DEPARTMENTS = '\u0000all'
+
+function personCell(person: PersonSummary) {
+ return {
+ icon: ,
+ label: person.name,
+ }
+}
+
+interface PeopleProps {
+ organizationId: string
+}
+
+/** Everyone in the organization, with the profile facts an admin has recorded or imported. */
+export function People({ organizationId }: PeopleProps) {
+ const router = useRouter()
+ const { viewer } = useOrganizationContext()
+ const [{ search, department }, setView] = useQueryStates(peopleParsers, peopleUrlKeys)
+ const [importing, setImporting] = useState(false)
+ const setSearch = useDebouncedSearchSetter((value, options) =>
+ setView({ search: value }, options)
+ )
+ const debouncedSearch = useSearchFilterValue(search, SEARCH_DEBOUNCE_MS)
+ const query = debouncedSearch.trim()
+ const people = usePeople(organizationId, query, department)
+ const routes = organizationRoutes(organizationId)
+
+ const departments = useDepartments(organizationId).data ?? []
+
+ const rows: ResourceRow[] = useMemo(
+ () =>
+ (people.data?.pages ?? []).flatMap((page) =>
+ page.people.map((person) => ({
+ id: person.memberId,
+ cells: {
+ name: personCell(person),
+ title: { label: person.title },
+ department: { label: person.department },
+ location: { label: person.location },
+ manager: person.manager ? personCell(person.manager) : { label: null },
+ },
+ }))
+ ),
+ [people.data]
+ )
+
+ const searchConfig: SearchConfig = {
+ value: search,
+ onChange: setSearch,
+ onClearAll: () => setSearch(''),
+ placeholder: PEOPLE_SEARCH_PLACEHOLDER,
+ }
+ const setDepartment = (value: string) => void setView({ department: value })
+ const filterTags: FilterTag[] = department
+ ? [{ label: `Department: ${department}`, onRemove: () => setDepartment('') }]
+ : []
+
+ if (!people.data && !people.isLoadingError) return
+
+ return (
+
+ setImporting(true),
+ },
+ ]
+ : []
+ }
+ />
+
+
+ ({
+ value: name,
+ label: `${name} (${count})`,
+ })),
+ ]}
+ value={department || ALL_DEPARTMENTS}
+ onChange={(value) => setDepartment(value === ALL_DEPARTMENTS ? '' : value)}
+ overlayContent={department || 'All'}
+ searchable
+ searchPlaceholder='Search departments...'
+ emptyMessage='No departments match'
+ className='w-full'
+ />
+
+
+ ),
+ }}
+ filterTags={filterTags}
+ />
+ router.push(routes.person(memberId))}
+ onLoadMore={() => void people.fetchNextPage()}
+ hasMore={people.hasNextPage}
+ isLoadingMore={people.isFetchingNextPage}
+ isFetching={people.isFetching}
+ loadMoreFailed={people.isFetchNextPageError}
+ emptyState={
+ people.isLoadingError ? (
+ void people.refetch()}
+ />
+ ) : (
+ void setView({ search: '', department: '' })}
+ />
+ )
+ }
+ />
+ {importing && viewer.isAdmin && (
+ setImporting(false)} />
+ )}
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/people/read-people-sheet.test.ts b/apps/sim/app/o/[organizationId]/people/read-people-sheet.test.ts
new file mode 100644
index 00000000000..2f4c5b5877e
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/people/read-people-sheet.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest'
+import * as XLSX from 'xlsx'
+import { readPeopleSheet } from '@/app/o/[organizationId]/people/read-people-sheet'
+
+/** January 31, 2024 as a date serial in each of Excel's two date systems. */
+const SERIAL_1900 = 45322
+const SERIAL_1904 = SERIAL_1900 - 1462
+
+function workbookFile(rows: unknown[][], { format = 'm/d/yy', date1904 = false } = {}): File {
+ const sheet = XLSX.utils.aoa_to_sheet(rows, { dense: false })
+ for (const [address, cell] of Object.entries(sheet)) {
+ if (!address.startsWith('!') && (cell as XLSX.CellObject).t === 'n') {
+ ;(cell as XLSX.CellObject).z = format
+ }
+ }
+ const workbook = XLSX.utils.book_new()
+ XLSX.utils.book_append_sheet(workbook, sheet, 'People')
+ workbook.Workbook = { WBProps: { date1904 } }
+ const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' })
+ return new File([bytes], 'people.xlsx')
+}
+
+describe('readPeopleSheet', () => {
+ it.each(['m/d/yy', 'dd/mm/yyyy', 'yyyy-mm-dd'])(
+ 'reads a date cell formatted %s as its calendar date, not its displayed text',
+ async (format) => {
+ const sheet = await readPeopleSheet(
+ workbookFile(
+ [
+ ['email', 'start date'],
+ ['ada@example.com', SERIAL_1900],
+ ],
+ { format }
+ )
+ )
+ expect(sheet.rows).toEqual([['ada@example.com', '2024-01-31']])
+ }
+ )
+
+ it('reads dates from a workbook saved in the 1904 date system', async () => {
+ const sheet = await readPeopleSheet(
+ workbookFile(
+ [
+ ['email', 'start date'],
+ ['ada@example.com', SERIAL_1904],
+ ],
+ { date1904: true }
+ )
+ )
+ expect(sheet.rows).toEqual([['ada@example.com', '2024-01-31']])
+ })
+
+ it('passes an out-of-range date serial through as text for the server to refuse', async () => {
+ const sheet = await readPeopleSheet(
+ workbookFile([
+ ['email', 'start date'],
+ ['ada@example.com', -5],
+ ])
+ )
+ expect(sheet.rows[0]?.[0]).toBe('ada@example.com')
+ expect(sheet.rows[0]?.[1]).not.toMatch(/^\d{4}-\d{2}-\d{2}$/)
+ })
+
+ it('keeps blank rows in place so issue row numbers match the sheet', async () => {
+ const sheet = await readPeopleSheet(
+ workbookFile([
+ ['email', 'title'],
+ ['ada@example.com', 'Engineer'],
+ [],
+ ['bo@example.com', 'Lead'],
+ ])
+ )
+ expect(sheet.rows).toEqual([
+ ['ada@example.com', 'Engineer'],
+ ['', ''],
+ ['bo@example.com', 'Lead'],
+ ])
+ })
+
+ it('refuses a header row that repeats a column name', async () => {
+ await expect(
+ readPeopleSheet(
+ workbookFile([
+ ['email', 'title', 'title'],
+ ['ada@example.com', 'a', 'b'],
+ ])
+ )
+ ).rejects.toThrow('The title column appears more than once')
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/people/read-people-sheet.ts b/apps/sim/app/o/[organizationId]/people/read-people-sheet.ts
new file mode 100644
index 00000000000..0607514fe9c
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/people/read-people-sheet.ts
@@ -0,0 +1,70 @@
+import { PEOPLE_IMPORT_MAX_ROWS } from '@/lib/api/contracts/people'
+import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard'
+
+const MAX_FILE_BYTES = 5 * 1024 * 1024
+
+export interface PeopleSheet {
+ fileName: string
+ headers: string[]
+ /** Each row's cells, in `headers` order. */
+ rows: string[][]
+}
+
+/**
+ * The first sheet's named columns and its rows of cells, one per sheet row (blank ones included) so the
+ * row numbers in the preview match the spreadsheet. Delimited text is read verbatim so the server
+ * interprets dates the same way for every file. A spreadsheet date cell is read from its stored
+ * value, not the text its locale displays, so `31/01/2024` and `1/31/24` both arrive as
+ * `2024-01-31`.
+ */
+export async function readPeopleSheet(file: File): Promise {
+ if (file.size > MAX_FILE_BYTES) throw new Error('Choose a file under 5 MB')
+ const data = new Uint8Array(await file.arrayBuffer())
+ if (/\.xlsx$/i.test(file.name)) await assertOoxmlPreviewWithinLimits(data)
+ const XLSX = await import('xlsx')
+ const workbook = XLSX.read(data, {
+ type: 'array',
+ raw: /\.(csv|tsv)$/i.test(file.name),
+ dense: true,
+ // Keeps each cell's number format, which is how a date cell is told from a number.
+ cellNF: true,
+ })
+ const sheet = workbook.Sheets[workbook.SheetNames[0] ?? '']
+ // Workbooks saved in the 1904 date system count their date serials from a different epoch.
+ const dateSystem = { date1904: Boolean(workbook.Workbook?.WBProps?.date1904) }
+ if (!sheet?.['!ref']) throw new Error('The file has no rows')
+ const range = XLSX.utils.decode_range(sheet['!ref'])
+ const grid = sheet['!data'] ?? []
+ const text = (row: number, column: number): string => {
+ const cell = grid[row]?.[column]
+ if (!cell || cell.v === undefined || cell.v === null) return ''
+ if (cell.t === 'n' && XLSX.SSF.is_date(cell.z ?? '')) {
+ // A serial outside Excel's calendar has no date; its text goes on for the server to refuse.
+ const date = XLSX.SSF.parse_date_code(Number(cell.v), dateSystem)
+ if (date)
+ return `${date.y}-${String(date.m).padStart(2, '0')}-${String(date.d).padStart(2, '0')}`
+ }
+ return String(cell.w ?? cell.v).trim()
+ }
+ const columns: { header: string; index: number }[] = []
+ for (let index = range.s.c; index <= range.e.c; index++) {
+ const header = text(range.s.r, index)
+ if (!header) continue
+ if (columns.some((column) => column.header === header)) {
+ throw new Error(`The ${header} column appears more than once`)
+ }
+ columns.push({ header, index })
+ }
+ if (!columns.length) throw new Error('The first row should name the columns')
+ const rowCount = range.e.r - range.s.r
+ if (rowCount > PEOPLE_IMPORT_MAX_ROWS) {
+ throw new Error(`Import at most ${PEOPLE_IMPORT_MAX_ROWS} rows at a time`)
+ }
+ return {
+ fileName: file.name,
+ headers: columns.map(({ header }) => header),
+ rows: Array.from({ length: rowCount }, (_, offset) =>
+ columns.map(({ index }) => text(range.s.r + 1 + offset, index))
+ ),
+ }
+}
diff --git a/apps/sim/app/o/[organizationId]/people/search-params.ts b/apps/sim/app/o/[organizationId]/people/search-params.ts
new file mode 100644
index 00000000000..a0e1f1589eb
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/people/search-params.ts
@@ -0,0 +1,12 @@
+import { createSerializer, parseAsString } from 'nuqs/server'
+
+/** `search` matches name, email, title, department, or location; `department` narrows to one. */
+export const peopleParsers = {
+ search: parseAsString.withDefault(''),
+ department: parseAsString.withDefault(''),
+} as const
+
+export const peopleUrlKeys = { history: 'replace', clearOnDefault: true } as const
+
+/** Links into the People page, such as a profile's department narrowing the list to it. */
+export const serializePeopleView = createSerializer(peopleParsers)
diff --git a/apps/sim/app/oauth-error/page.tsx b/apps/sim/app/oauth-error/page.tsx
index b198d99562e..a93cbac1735 100644
--- a/apps/sim/app/oauth-error/page.tsx
+++ b/apps/sim/app/oauth-error/page.tsx
@@ -1,5 +1,10 @@
import type { Metadata } from 'next'
-import { SSO_REQUIRED_ERROR_CODE, SSO_REQUIRED_MESSAGE } from '@/lib/auth/constants'
+import {
+ DISPOSABLE_EMAIL_ERROR_CODE,
+ DISPOSABLE_EMAIL_MESSAGE,
+ SSO_REQUIRED_ERROR_CODE,
+ SSO_REQUIRED_MESSAGE,
+} from '@/lib/auth/constants'
import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell'
export const metadata: Metadata = {
@@ -46,6 +51,12 @@ const FRIENDLY: Record = {
* refused. Retrying the same provider can never succeed — name the way in.
*/
[SSO_REQUIRED_ERROR_CODE]: SSO_REQUIRED_MESSAGE,
+ /**
+ * Account creation refused for a disposable email domain. Better Auth 1.7 redirects with the
+ * error's code; 1.6 redirects with its message, spaces joined by underscores.
+ */
+ [DISPOSABLE_EMAIL_ERROR_CODE]: DISPOSABLE_EMAIL_MESSAGE,
+ [DISPOSABLE_EMAIL_MESSAGE.split(' ').join('_')]: DISPOSABLE_EMAIL_MESSAGE,
/** The provider returned no email claim, so there is nothing to sign in as. */
email_not_found:
'Your identity provider didn’t share an email address with us, so we couldn’t complete sign-in. Please contact your administrator.',
diff --git a/apps/sim/app/sim/[[...path]]/page.tsx b/apps/sim/app/sim/[[...path]]/page.tsx
index 209123a1cd5..59da34a8eb6 100644
--- a/apps/sim/app/sim/[[...path]]/page.tsx
+++ b/apps/sim/app/sim/[[...path]]/page.tsx
@@ -2,12 +2,10 @@ import { ChipLink, StatusPageContent } from '@sim/emcn'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
-import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { organizationRoutes, simLinkPath } from '@/lib/navigation/paths'
-import { resolveOrganizationLanding } from '@/lib/organizations/surface'
+import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization'
import { resolveSimLink, type SimLinkResolution } from '@/lib/sim-links/application/use-cases'
-import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
-import { simLinkNameSchema } from '@/lib/sim-links/validation'
+import { parseTypedSimLink, simLinkNameSchema } from '@/lib/sim-links/validation'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { LogoShell } from '@/app/(landing)/components/logo-shell'
import { serializeSimLinkView } from '@/app/o/[organizationId]/links/search-params'
@@ -19,6 +17,7 @@ export const metadata: Metadata = {
interface SimLinkPageProps {
params: Promise<{ path?: string[] }>
+ searchParams: Promise>
}
/**
@@ -32,6 +31,18 @@ function decodeSimLinkPath(path: string[]): string[] {
return segments.length === 1 ? segments[0].split('/').filter(Boolean) : segments
}
+/**
+ * The query, from the params Next has already parsed and normalized (it re-serializes every
+ * request's query before app code sees it, so the typed spelling isn't recoverable here).
+ */
+function toQueryString(searchParams: Record): string {
+ const query = new URLSearchParams()
+ for (const [key, value] of Object.entries(searchParams)) {
+ for (const item of [value ?? []].flat()) query.append(key, item)
+ }
+ return query.toString()
+}
+
/**
* Why a link did not redirect. Only a valid link name is echoed back, so an arbitrary path
* can't put its own text on this page.
@@ -47,32 +58,34 @@ function describeUnresolved(result: SimLinkResolution, validName: boolean, name:
}
/**
- * Where `sim//` lands: the viewer's organization link's destination,
+ * Where `sim//?` lands: the viewer's organization link's destination,
* or a page that offers to create the link when nobody has yet.
*/
-export default async function SimLinkPage({ params }: SimLinkPageProps) {
- const { path = [] } = await params
+export default async function SimLinkPage({ params, searchParams }: SimLinkPageProps) {
+ const [{ path = [] }, query] = await Promise.all([params, searchParams.then(toQueryString)])
const segments = decodeSimLinkPath(path)
const session = await getSession()
if (!session?.user)
redirect(
buildAuthCrossLink('/login', {
- callbackUrl: simLinkPath(...segments),
+ callbackUrl: `${simLinkPath(...segments)}${query ? `?${query}` : ''}`,
isInviteFlow: false,
})
)
- const organizationId = await resolveOrganizationLanding(
- session.user.id,
- getActiveOrganizationId(session)
- )
- if (!organizationId || !(await isSimLinksEnabled(organizationId))) notFound()
+ const principal = {
+ kind: 'session',
+ userId: session.user.id,
+ sessionId: session.session.id,
+ } as const
+ const organizationId = await resolveSimLinkOrganization(principal)
+ if (!organizationId) notFound()
const library = organizationRoutes(organizationId).simLinks
- const [name, ...values] = segments
+ const { name, values } = parseTypedSimLink(segments)
if (!name) redirect(library)
const result = await resolveSimLink.execute({
- principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id },
- input: { organizationId, name, values },
+ principal,
+ input: { organizationId, name, values, query },
})
if (result.kind === 'redirect') redirect(result.url)
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts
index 1dd0bb241bc..b176ccdde08 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts
@@ -3,6 +3,7 @@
import type { MouseEvent } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
+import { useBeforeUnloadWhile } from '@/hooks/use-before-unload-while'
interface UseUnsavedChangesGuardParams {
isDirty: boolean
@@ -29,6 +30,8 @@ export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesG
const [isReleased, setIsReleased] = useState(false)
const hasSentinelRef = useRef(false)
+ useBeforeUnloadWhile(isDirty && !isReleased)
+
useEffect(() => {
// The caller is navigating away — popping the seeded entry would cancel it. But
// Back during that window consumes the entry with no listener left to re-push
@@ -58,19 +61,12 @@ export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesG
window.history.pushState(null, '', window.location.href)
hasSentinelRef.current = true
}
- const handleBeforeUnload = (event: BeforeUnloadEvent) => {
- event.preventDefault()
- }
const handlePopState = () => {
window.history.pushState(null, '', window.location.href)
setShowUnsavedAlert(true)
}
- window.addEventListener('beforeunload', handleBeforeUnload)
window.addEventListener('popstate', handlePopState)
- return () => {
- window.removeEventListener('beforeunload', handleBeforeUnload)
- window.removeEventListener('popstate', handlePopState)
- }
+ return () => window.removeEventListener('popstate', handlePopState)
}, [isDirty, isReleased])
const handleBackClick = useCallback(
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts
index 5a253fa2547..b278c03e8e7 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts
@@ -4,6 +4,7 @@ export { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/re
export { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state'
export { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state'
export { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state'
+export { ResourceLoadError } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error'
export { ResourceNoResults } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results'
export { SimLinksEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/sim-links-empty-state'
export { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error.tsx
new file mode 100644
index 00000000000..eaff2e66928
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error.tsx
@@ -0,0 +1,16 @@
+import { Chip } from '@sim/emcn'
+
+interface ResourceLoadErrorProps {
+ message: string
+ onRetry: () => void
+}
+
+/** What a resource table shows when its first page failed to load. */
+export function ResourceLoadError({ message, onRetry }: ResourceLoadErrorProps) {
+ return (
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
index 1332f42984d..71fcdf559ce 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
@@ -11,6 +11,7 @@ import {
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { resolveMediaMimeType } from '@/lib/uploads/utils/file'
import {
+ useReresolveFileRecord,
useWorkspaceFileBinary,
useWorkspaceFileContent,
useWorkspaceImageDimensionsAdapter,
@@ -19,6 +20,7 @@ import {
createWorkspaceFileContentSource,
type FileContentSource,
FileContentSourceProvider,
+ useFileContentSource,
} from '@/hooks/use-file-content-source'
import { CsvTablePreview } from './csv-table-preview'
import { DocxPreview } from './docx-preview'
@@ -408,20 +410,103 @@ function useBlobUrl(workspaceId: string, fileId: string, fileKey: string) {
return { fileData, isLoading, error, blobUrl, replaceBlobUrl }
}
+interface MediaPreviewProps {
+ file: WorkspaceFileRecord
+ workspaceId: string
+ kind: 'audio' | 'video'
+}
+
/**
- * Shared blob-backed preview for audio and video files — the fetch, blob-URL
- * lifecycle, and error/loading handling are identical; only the rendered
- * player differs.
+ * Audio and video preview. Where the content source answers byte ranges the player streams straight
+ * from its URL, so playback and seeking start while the file downloads, at any size. Elsewhere the
+ * file is fetched whole into a blob first.
*/
-const MediaPreview = memo(function MediaPreview({
+const MediaPreview = memo(function MediaPreview({ file, workspaceId, kind }: MediaPreviewProps) {
+ const source = useFileContentSource()
+ if (!source.servesByteRanges) {
+ return
+ }
+ /** `v` lets the browser cache the bytes across re-opens; a content write moves the key. */
+ const src = source.buildUrl(file.key, {
+ version: Number(new Date(file.updatedAt)) || file.size,
+ })
+ /** Keyed by URL so a re-resolved key starts the player, and its load state, afresh. */
+ return (
+
+ )
+})
+
+function StreamedMediaPlayer({
+ src,
file,
workspaceId,
kind,
-}: {
- file: WorkspaceFileRecord
- workspaceId: string
- kind: 'audio' | 'video'
-}) {
+}: MediaPreviewProps & { src: string }) {
+ const [status, setStatus] = useState<'loading' | 'ready' | 'unplayable' | 'unavailable'>(
+ 'loading'
+ )
+ const reresolveRecord = useReresolveFileRecord(workspaceId, file.id)
+ const diagnosing = useRef(false)
+
+ /**
+ * A media element reports only that it failed, not why, so the URL is asked directly for its
+ * first byte, which opens storage. A 404 means the key was replaced; the record is re-resolved,
+ * and a moved key re-renders this preview at the new URL. Bytes that are there but would not play
+ * are the browser's format support.
+ */
+ const handleError = useCallback(async () => {
+ if (diagnosing.current) return
+ diagnosing.current = true
+ // boundary-raw-fetch: ranged probe of the binary serve path, which has no contract
+ const probe = await fetch(src, { headers: { Range: 'bytes=0-0' }, cache: 'no-store' }).catch(
+ () => null
+ )
+ await probe?.body?.cancel()
+ if (probe?.status === 404) await reresolveRecord()
+ setStatus(probe?.ok ? 'unplayable' : 'unavailable')
+ }, [src, reresolveRecord])
+
+ /**
+ * The element starts loading as soon as it is in the document, which can be before hydration
+ * attaches its listeners — a small file then finishes before `loadedmetadata` is heard. Reading
+ * the element's own state on mount covers what the listeners missed.
+ */
+ const syncWithElement = useCallback(
+ (media: HTMLMediaElement | null) => {
+ if (!media || status !== 'loading') return
+ if (media.error) void handleError()
+ else if (media.readyState >= HTMLMediaElement.HAVE_METADATA) setStatus('ready')
+ },
+ [status, handleError]
+ )
+
+ if (status === 'unplayable') {
+ return (
+
+ )
+ }
+ if (status === 'unavailable') {
+ return
+ }
+
+ return (
+ setStatus('ready')}
+ onError={handleError}
+ />
+ )
+}
+
+/** Fetches the whole file into a blob, for content sources that send only whole bodies. */
+function BufferedMediaPreview({ file, workspaceId, kind }: MediaPreviewProps) {
const {
fileData,
isLoading,
@@ -444,27 +529,66 @@ const MediaPreview = memo(function MediaPreview({
return
}
+ return
+}
+
+interface MediaPlayerProps {
+ file: WorkspaceFileRecord
+ kind: 'audio' | 'video'
+ src: string | null
+ /** Covers the player until it can describe the media, for players that load in place. */
+ loading?: boolean
+ mediaRef?: (media: HTMLMediaElement | null) => void
+ onLoadedMetadata?: () => void
+ onError?: () => void
+}
+
+function MediaPlayer({
+ file,
+ kind,
+ src,
+ loading,
+ mediaRef,
+ onLoadedMetadata,
+ onError,
+}: MediaPlayerProps) {
if (kind === 'audio') {
return (
-
+
- {blobUrl && (
+ {src && (
// biome-ignore lint/a11y/useMediaCaption: audio from workspace files
-
+
)}
+ {loading && PREVIEW_LOADING_OVERLAY}
)
}
return (
-
- {blobUrl && (
+
+ {src && (
// biome-ignore lint/a11y/useMediaCaption: video from workspace files
-
+
)}
+ {loading && PREVIEW_LOADING_OVERLAY}
)
-})
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
index f1b6be2eee9..29954e3d954 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
@@ -27,7 +27,8 @@ import {
import type { FileDownloadSource } from '@/lib/uploads/client/download'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file'
-import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
+import { isSimPageSource } from '@/lib/workspace-files/page-compile'
+import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import { EditorContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu'
import { FileSaveConflict } from '@/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
index 08cf1f6be66..76aad2ac614 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -48,7 +48,7 @@ import {
isFileBrowserSize,
isFileBrowserType,
} from '@/lib/workspace-files/browser'
-import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
+import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar/find-bar'
import { useFindShortcut } from '@/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
@@ -170,6 +170,7 @@ import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useResourceListPreferences } from '@/hooks/use-resource-list-preferences'
import { useSearchFilterValue } from '@/hooks/use-search-filter-value'
import { useUrlSort } from '@/hooks/use-url-sort'
+import { useInFlightUploadsStore } from '@/stores/in-flight-uploads/store'
import type { ResourceListPreference } from '@/stores/resource-list-preferences'
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
@@ -976,10 +977,15 @@ function FilesContent() {
setUploadProgress({ completed: 0, total: 0, currentPercent: 0 })
}
}
- uploadQueueRef.current = uploadQueueRef.current.then(uploadBatch).catch((error) => {
- logger.error('Error uploading files:', error)
- toast.error(getErrorMessage(error, 'Failed to upload files'))
- })
+ // Counted from the moment it queues, since the batch runs on after this page unmounts.
+ const releaseUpload = useInFlightUploadsStore.getState().begin()
+ uploadQueueRef.current = uploadQueueRef.current
+ .then(uploadBatch)
+ .catch((error) => {
+ logger.error('Error uploading files:', error)
+ toast.error(getErrorMessage(error, 'Failed to upload files'))
+ })
+ .finally(releaseUpload)
return uploadQueueRef.current
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-continue-sign-in.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-continue-sign-in.tsx
new file mode 100644
index 00000000000..58d3b3f2b72
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-continue-sign-in.tsx
@@ -0,0 +1,106 @@
+'use client'
+
+import {
+ Chip,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+} from '@sim/emcn'
+import { X } from '@sim/emcn/icons'
+import { BrowserNotice } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-notice'
+import type { ContinueSignInController } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in'
+
+interface BrowserContinueSignInMenuItemsProps {
+ controller: ContinueSignInController
+ /** Wraps an action so it runs once the menu has closed. */
+ afterClose: (action: () => void) => () => void
+}
+
+/** "Continue in Chrome", or a picker when several browser profiles can take the page. */
+export function BrowserContinueSignInMenuItems({
+ controller,
+ afterClose,
+}: BrowserContinueSignInMenuItemsProps) {
+ const { canContinue, profiles, continueIn } = controller
+ if (!canContinue) return null
+
+ return (
+ <>
+
+ {profiles.length === 1 ? (
+
void continueIn(profiles[0]))}>
+ Continue in {profiles[0].label}
+
+ ) : (
+
+ Continue in Browser
+
+ {profiles.map((profile) => (
+ void continueIn(profile))}
+ >
+ {profile.label}
+
+ ))}
+
+
+ )}
+ >
+ )
+}
+
+interface BrowserContinueSignInNoticeProps {
+ controller: ContinueSignInController
+}
+
+/** Asks to bring a pending sign-in back, or suggests continuing on a known sign-in page. */
+export function BrowserContinueSignInNotice({ controller }: BrowserContinueSignInNoticeProps) {
+ const {
+ pending,
+ starting,
+ importing,
+ hintVisible,
+ profiles,
+ continueIn,
+ bringBack,
+ cancel,
+ dismissHint,
+ } = controller
+
+ if (pending) {
+ return (
+
+ void bringBack()}>
+ {importing ? 'Bringing back…' : 'Bring session back'}
+
+
+ Cancel
+
+ >
+ }
+ />
+ )
+ }
+
+ if (!hintVisible) return null
+ const [profile] = profiles
+ return (
+
+ void continueIn(profile)}>
+ Continue in {profile.label}
+
+
+ >
+ }
+ />
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-notice.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-notice.tsx
new file mode 100644
index 00000000000..9e94c309dea
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-notice.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from 'react'
+import { Banner } from '@sim/emcn'
+
+interface BrowserNoticeProps {
+ message: ReactNode
+ actions: ReactNode
+}
+
+/** A non-modal notice docked under the browser toolbar; a long message wraps rather than truncating. */
+export function BrowserNotice({ message, actions }: BrowserNoticeProps) {
+ return (
+
+
+
{message}
+
{actions}
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-prompt.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-prompt.test.tsx
new file mode 100644
index 00000000000..ab577600944
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-prompt.test.tsx
@@ -0,0 +1,56 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import type { BrowserPagePrompt } from '@sim/browser-protocol'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { BrowserPagePromptModal } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-prompt'
+
+const mountedRoots: Root[] = []
+
+function renderPrompt(prompt: BrowserPagePrompt, onRespond = vi.fn()) {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const root = createRoot(document.createElement('div'))
+ mountedRoots.push(root)
+ const render = (next: BrowserPagePrompt) =>
+ act(() => {
+ root.render( )
+ })
+ render(prompt)
+ return { onRespond, render }
+}
+
+/** A prompt kind a newer shell could send that this renderer has no modal for. */
+function promptFromNewerShell(requestId: string): BrowserPagePrompt {
+ // double-cast-allowed: models a future protocol kind this renderer's types cannot name
+ return { requestId, kind: 'future-kind' } as unknown as BrowserPagePrompt
+}
+
+afterEach(() => {
+ for (const root of mountedRoots.splice(0)) act(() => root.unmount())
+})
+
+describe('BrowserPagePromptModal', () => {
+ it('refuses a prompt kind it cannot show at once, once per request', () => {
+ const { onRespond, render } = renderPrompt(promptFromNewerShell('r1'))
+ render(promptFromNewerShell('r1'))
+ expect(onRespond).toHaveBeenCalledExactlyOnceWith('r1', { allowed: false })
+
+ render(promptFromNewerShell('r2'))
+ expect(onRespond).toHaveBeenCalledTimes(2)
+ expect(onRespond).toHaveBeenLastCalledWith('r2', { allowed: false })
+ })
+
+ it('leaves a prompt it can show for the user to answer', () => {
+ const { onRespond } = renderPrompt({
+ requestId: 'r1',
+ kind: 'http-auth',
+ origin: 'https://intranet.example',
+ proxy: false,
+ insecure: false,
+ retry: false,
+ })
+ expect(onRespond).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-prompt.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-prompt.tsx
new file mode 100644
index 00000000000..bff47da8446
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-prompt.tsx
@@ -0,0 +1,364 @@
+'use client'
+
+import { type ReactNode, useEffect, useRef, useState } from 'react'
+import type {
+ BrowserAppLaunchNotice,
+ BrowserClientCertificatePrompt,
+ BrowserExternalAppPrompt,
+ BrowserHttpAuthPrompt,
+ BrowserPagePrompt,
+ BrowserPagePromptResponse,
+ BrowserPasskeyPrompt,
+} from '@sim/browser-protocol'
+import {
+ Checkbox,
+ Chip,
+ ChipConfirmModal,
+ ChipModal,
+ ChipModalBody,
+ ChipModalDescription,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+ ChipSelect,
+ type ChipSelectOption,
+ Label,
+} from '@sim/emcn'
+import { X } from '@sim/emcn/icons'
+import { BrowserNotice } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-notice'
+
+/** Keeps a click or keystroke aimed at the page from answering a prompt as it appears. */
+const ACTIVATION_DELAY_MS = 500
+
+type RespondToPrompt = (requestId: string, response: BrowserPagePromptResponse) => void
+
+/**
+ * False until the prompt has been visible long enough to be read. Hiding it
+ * disarms it again, so a prompt shown after waiting behind another dialog
+ * starts its delay only once it appears.
+ */
+function useActivationDelay(visible: boolean): boolean {
+ const [armed, setArmed] = useState(false)
+ useEffect(() => {
+ if (!visible) {
+ setArmed(false)
+ return
+ }
+ const timer = setTimeout(() => setArmed(true), ACTIVATION_DELAY_MS)
+ return () => clearTimeout(timer)
+ }, [visible])
+ return armed
+}
+
+interface PromptModalProps {
+ prompt: Prompt
+ open: boolean
+ onRespond: RespondToPrompt
+}
+
+/**
+ * A page raised this, so it never takes focus, and signing in takes a click on
+ * Sign in: Enter in a field never submits credentials typed for the page.
+ */
+function HttpAuthPromptModal({ prompt, open, onRespond }: PromptModalProps) {
+ const armed = useActivationDelay(open)
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const cancel = () => onRespond(prompt.requestId, { allowed: false })
+ const submit = () =>
+ onRespond(prompt.requestId, { allowed: true, kind: 'http-auth', username, password })
+ return (
+ !next && cancel()}
+ srTitle='Sign in'
+ size='sm'
+ focusOnOpen={false}
+ >
+ Sign in
+
+
+ {prompt.proxy ? `The proxy ${prompt.origin}` : prompt.origin} requires a username and
+ password.{prompt.insecure ? ' Your connection to this site is not private.' : ''}
+
+
+
+
+
+
+ )
+}
+
+interface ChoicePromptModalProps {
+ open: boolean
+ title: string
+ description: string
+ fieldTitle: string
+ placeholder: string
+ options: ChipSelectOption[]
+ hintFor?: (choiceId: string) => string | undefined
+ cancelLabel?: string
+ onCancel: () => void
+ /** Closing without the Cancel button; defaults to {@link ChoicePromptModalProps.onCancel}. */
+ onDismiss?: () => void
+ onChoose: (choiceId: string) => void
+}
+
+/**
+ * Picks one of the page's offered identities. It never takes focus, nothing is
+ * chosen up front, Enter dismisses, and Continue waits out the activation delay,
+ * so a keystroke meant for the page can never send an identity.
+ */
+function ChoicePromptModal({
+ open,
+ title,
+ description,
+ fieldTitle,
+ placeholder,
+ options,
+ hintFor,
+ cancelLabel,
+ onCancel,
+ onDismiss = onCancel,
+ onChoose,
+}: ChoicePromptModalProps) {
+ const armed = useActivationDelay(open)
+ const [choiceId, setChoiceId] = useState('')
+ return (
+ !next && onDismiss()}
+ srTitle={title}
+ size='sm'
+ focusOnOpen={false}
+ >
+ {title}
+
+ {description}
+
+
+
+
+ onChoose(choiceId),
+ disabled: !armed || !choiceId,
+ }}
+ />
+
+ )
+}
+
+function passkeyAccountLabel(account: BrowserPasskeyPrompt['accounts'][number]): string {
+ const { displayName, name } = account
+ if (displayName && name && displayName !== name) return `${displayName} (${name})`
+ return displayName || name || 'Unnamed account'
+}
+
+function PasskeyPromptModal({ prompt, open, onRespond }: PromptModalProps) {
+ return (
+ ({
+ label: passkeyAccountLabel(account),
+ value: account.id,
+ }))}
+ onCancel={() => onRespond(prompt.requestId, { allowed: false })}
+ onChoose={(choiceId) =>
+ onRespond(prompt.requestId, { allowed: true, kind: 'passkey-account', choiceId })
+ }
+ />
+ )
+}
+
+function ClientCertificatePromptModal({
+ prompt,
+ open,
+ onRespond,
+}: PromptModalProps) {
+ const hintFor = (choiceId: string) => {
+ const chosen = prompt.certificates.find((certificate) => certificate.id === choiceId)
+ if (!chosen) return undefined
+ return `Issued by ${chosen.issuer || 'unknown issuer'} · Expires ${new Date(chosen.expiresAt).toLocaleDateString()}`
+ }
+ return (
+ ({
+ label: certificate.subject || certificate.id,
+ value: certificate.id,
+ }))}
+ hintFor={hintFor}
+ cancelLabel="Don't send"
+ onCancel={() => onRespond(prompt.requestId, { allowed: false, remember: true })}
+ onDismiss={() => onRespond(prompt.requestId, { allowed: false })}
+ onChoose={(choiceId) =>
+ onRespond(prompt.requestId, { allowed: true, kind: 'client-certificate', choiceId })
+ }
+ />
+ )
+}
+
+function ExternalAppPromptModal({
+ prompt,
+ open,
+ onRespond,
+}: PromptModalProps) {
+ const armed = useActivationDelay(open)
+ const [alwaysAllow, setAlwaysAllow] = useState(false)
+ const checkboxId = `browser-always-open-${prompt.requestId}`
+ return (
+ !next && onRespond(prompt.requestId, { allowed: false })}
+ title={`Open ${prompt.appName}?`}
+ text={[{ text: prompt.origin, bold: true }, ' wants to open this app.']}
+ defaultAction='dismiss'
+ confirm={{
+ label: 'Open',
+ variant: 'primary',
+ disabled: !armed,
+ onClick: () =>
+ onRespond(prompt.requestId, {
+ allowed: true,
+ kind: 'external-app',
+ alwaysAllow: prompt.canAlwaysAllow && alwaysAllow,
+ }),
+ }}
+ >
+ {prompt.canAlwaysAllow && (
+
+ setAlwaysAllow(checked === true)}
+ />
+ Always allow on this site
+
+ )}
+
+ )
+}
+
+type PromptModal = (props: PromptModalProps) => ReactNode
+
+const PROMPT_MODALS: {
+ [Kind in BrowserPagePrompt['kind']]: (
+ props: PromptModalProps>
+ ) => ReactNode
+} = {
+ 'http-auth': HttpAuthPromptModal,
+ 'passkey-account': PasskeyPromptModal,
+ 'client-certificate': ClientCertificatePromptModal,
+ 'external-app': ExternalAppPromptModal,
+}
+
+interface BrowserPagePromptModalProps {
+ prompt: BrowserPagePrompt | undefined
+ open: boolean
+ onRespond: RespondToPrompt
+}
+
+/**
+ * The sign-in or app-launch decision the visible page is waiting on. A kind this
+ * renderer cannot show, from a newer shell, is refused at once rather than left
+ * for the page to wait out.
+ */
+export function BrowserPagePromptModal({ prompt, open, onRespond }: BrowserPagePromptModalProps) {
+ const refusedRequestIdRef = useRef(null)
+ const Modal =
+ prompt && Object.hasOwn(PROMPT_MODALS, prompt.kind)
+ ? (PROMPT_MODALS[prompt.kind] as PromptModal)
+ : undefined
+ const unsupportedRequestId = prompt && !Modal ? prompt.requestId : null
+
+ useEffect(() => {
+ if (!unsupportedRequestId || refusedRequestIdRef.current === unsupportedRequestId) return
+ refusedRequestIdRef.current = unsupportedRequestId
+ onRespond(unsupportedRequestId, { allowed: false })
+ }, [unsupportedRequestId, onRespond])
+
+ if (!prompt || !Modal) return null
+ return
+}
+
+interface BrowserAppLaunchNoticeBarProps {
+ notice: BrowserAppLaunchNotice
+ onRespond: RespondToPrompt
+}
+
+/** A page's unprompted request to open another app; nothing opens until the user chooses Open. */
+export function BrowserAppLaunchNoticeBar({ notice, onRespond }: BrowserAppLaunchNoticeBarProps) {
+ const armed = useActivationDelay(true)
+ return (
+
+
+ onRespond(notice.requestId, { allowed: true, kind: 'app-launch-notice' })
+ }
+ >
+ Open
+
+ onRespond(notice.requestId, { allowed: false })}
+ />
+ >
+ }
+ />
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
index 786c75934aa..4fae5497216 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
@@ -4,6 +4,7 @@ import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState }
import type {
BrowserMediaPermissionRequest,
BrowserOmniboxFocusMode,
+ BrowserPagePromptResponse,
BrowserPanelAnchor,
BrowserPanelBounds,
BrowserPanelSnapshot,
@@ -66,11 +67,19 @@ import {
} from '@/lib/desktop/appearance'
import { trackPanelFocus } from '@/lib/desktop/panel-focus'
import { addMothershipContext } from '@/lib/mothership/events'
+import {
+ BrowserContinueSignInMenuItems,
+ BrowserContinueSignInNotice,
+} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-continue-sign-in'
import { BrowserDownloads } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads'
import { BrowserFindBar } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-find-bar'
import { BrowserLoadingBar } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-loading-bar'
import { BrowserPageDialogModal } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-dialog'
import { BrowserPageIssueView } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue'
+import {
+ BrowserAppLaunchNoticeBar,
+ BrowserPagePromptModal,
+} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-prompt'
import {
type BrowserPanelOverlay,
type BrowserPanelOverlayController,
@@ -90,6 +99,7 @@ import {
type OmniboxSuggestion,
type UrlSuggestion,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions'
+import { useContinueSignIn } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in'
import { ResourceZoomMenuItems } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/resource-zoom-menu-items'
import { useDebounce } from '@/hooks/use-debounce'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -444,9 +454,11 @@ export function BrowserSession({
const [importOpen, setImportOpen] = useState(false)
const [importVersion, setImportVersion] = useState(0)
const canImport = Boolean(getDesktopBridge()?.browserImport?.importFromChrome)
+ const continueSignIn = useContinueSignIn({ scopeId, pageUrl: pageState?.url })
const mediaPermissionRequest = pageState?.mediaPermissionRequest
const sitePermissionRequest = pageState?.sitePermissionRequest
const permissionRequest = sitePermissionRequest ?? mediaPermissionRequest
+ const pagePrompt = pageState?.prompt
const panelRef = useRef(null)
const hostRef = useRef(null)
// Lets the occlusion handshake reject a capture taken before a modal's
@@ -532,12 +544,14 @@ export function BrowserSession({
)
}, [permissionRequest, respondToPermission, visible])
- // The shell answers page dialogs itself until this renderer declares it shows them.
+ // The shell answers page dialogs and refuses page prompts until this renderer declares it shows them.
useEffect(() => {
- sendBrowserPanelAction('enable-page-dialogs', {}, scopeId)
+ sendBrowserPanelAction('enable-page-dialogs', { pagePrompts: true }, scopeId)
}, [scopeId])
const answerPageDialog = (requestId: string, accept: boolean) =>
sendBrowserPanelAction('respond-dialog', { requestId, allowed: accept }, scopeId)
+ const respondToPagePrompt = (requestId: string, response: BrowserPagePromptResponse) =>
+ sendBrowserPanelAction('respond-prompt', { requestId, response }, scopeId)
const permissionModalOpen = shouldShowBrowserPermissionRequest(
permissionRequest?.requestId,
@@ -965,7 +979,7 @@ export function BrowserSession({
// Keep the page's exact captured frame underneath it while it is open so
// pointer events reach the Sim popover instead of the WebContentsView.
useEffect(() => {
- if (mediaPermissionRequest || sitePermissionRequest) {
+ if (mediaPermissionRequest || sitePermissionRequest || pagePrompt) {
void closeOverlay('suggestions')
return
}
@@ -982,6 +996,7 @@ export function BrowserSession({
closeOverlay,
hasRendererPage,
mediaPermissionRequest,
+ pagePrompt,
requestOverlay,
sitePermissionRequest,
suggestions.length,
@@ -1299,6 +1314,10 @@ export function BrowserSession({
Find in Page
⌘F
+
sendBrowserPanelAction('zoom-in', {}, scopeId))}
@@ -1339,6 +1358,14 @@ export function BrowserSession({
{themeNoticeVersion > 0 && (
)}
+
+ {pageState?.appLaunchNotice && (
+
+ )}
{/* Host area: the real page is overlaid exactly on this rect. */}
@@ -1394,6 +1421,11 @@ export function BrowserSession({
open={visible && !permissionModalOpen}
onAnswer={answerPageDialog}
/>
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx
index 8df491836cb..9ef343f9513 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx
@@ -1,9 +1,10 @@
'use client'
import { useState } from 'react'
-import { Banner, Chip } from '@sim/emcn'
+import { Chip } from '@sim/emcn'
import { X } from '@sim/emcn/icons'
import { sendBrowserPanelAction } from '@/lib/browser-agent/transport'
+import { BrowserNotice } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-notice'
interface BrowserThemeNoticeProps {
scopeId: string
@@ -16,16 +17,14 @@ export function BrowserThemeNotice({ scopeId }: BrowserThemeNoticeProps) {
if (!visible) return null
return (
-
-
-
- Some sites apply theme changes after a reload.
-
-
+
sendBrowserPanelAction('reload', {}, scopeId)}>Reload page
setVisible(false)} />
-
-
-
+ >
+ }
+ />
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.test.ts
new file mode 100644
index 00000000000..c6d81c575cd
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import {
+ continuablePage,
+ signInHintHost,
+} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts'
+
+const APP_ORIGIN = 'https://www.sim.ai'
+
+function hintFor(url: string | undefined): string | null {
+ const page = continuablePage(url, APP_ORIGIN)
+ return page ? signInHintHost(page) : null
+}
+
+describe('signInHintHost', () => {
+ it.each([
+ 'https://accounts.google.com/v3/signin/identifier',
+ 'https://signin.aws.amazon.com/oauth',
+ 'https://us-east-1.signin.aws.amazon.com/oauth',
+ 'https://d-1234567890.awsapps.com/start',
+ 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
+ 'https://login.live.com/login.srf',
+ 'https://acme.okta.com/app/UserHome',
+ 'https://acme.oktapreview.com/',
+ 'https://github.com/login/oauth/authorize?client_id=x',
+ 'https://github.com/sessions/two-factor/webauthn',
+ 'https://appleid.apple.com/sign-in',
+ 'https://acme.us.auth0.com/u/login',
+ ])('offers the hint on %s', (url) => {
+ expect(hintFor(url)).toBe(new URL(url).hostname)
+ })
+
+ it.each([
+ ['a lookalike that only starts with the host', 'https://accounts.google.com.evil.com/'],
+ ['a lookalike that only ends with the suffix', 'https://evil-okta.com/'],
+ ['the bare wildcard suffix', 'https://okta.com/'],
+ ['the same host over plain http', 'http://accounts.google.com/'],
+ ['a GitHub page that is not a sign-in', 'https://github.com/simstudioai/sim'],
+ ['a GitHub path that only starts with login', 'https://github.com/loginx'],
+ ['an ordinary site', 'https://example.com/login'],
+ ['a blank tab', 'about:blank'],
+ ['nothing', undefined],
+ ['garbage', 'not a url'],
+ ])('stays quiet on %s', (_label, url) => {
+ expect(hintFor(url)).toBeNull()
+ })
+})
+
+describe('continuablePage', () => {
+ it("never offers Sim's own pages, which the shell refuses to hand off", () => {
+ expect(continuablePage(`${APP_ORIGIN}/workspace/w1/home`, APP_ORIGIN)).toBeNull()
+ expect(continuablePage('https://sim.ai/login', APP_ORIGIN)?.hostname).toBe('sim.ai')
+ })
+
+ it.each([
+ 'https://user@accounts.google.com/signin',
+ 'https://user:secret@accounts.google.com/signin',
+ 'https://:secret@accounts.google.com/signin',
+ ])('never offers %s, whose embedded credentials the shell refuses', (url) => {
+ expect(continuablePage(url, APP_ORIGIN)).toBeNull()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.ts
new file mode 100644
index 00000000000..e8f35ff2bc0
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts.ts
@@ -0,0 +1,60 @@
+interface SignInHost {
+ host: string
+ /** Match the host's subdomains instead of the host itself. */
+ subdomains?: true
+ /** Restrict to these path prefixes; omit for the whole host. */
+ paths?: readonly string[]
+}
+
+/**
+ * Sign-in pages that commonly offer a passkey or security key the built-in
+ * browser cannot complete. On these the browser panel suggests finishing in
+ * the user's own browser. A hint only: the browser menu offers the same action
+ * on every https page, so a missing host costs a suggestion, not the feature.
+ */
+const SIGN_IN_HOSTS: readonly SignInHost[] = [
+ { host: 'accounts.google.com' },
+ { host: 'signin.aws.amazon.com' },
+ { host: 'signin.aws.amazon.com', subdomains: true },
+ { host: 'awsapps.com', subdomains: true },
+ { host: 'login.microsoftonline.com' },
+ { host: 'login.live.com' },
+ { host: 'okta.com', subdomains: true },
+ { host: 'oktapreview.com', subdomains: true },
+ { host: 'github.com', paths: ['/login', '/sessions/two-factor'] },
+ { host: 'appleid.apple.com' },
+ { host: 'auth0.com', subdomains: true },
+]
+
+/**
+ * The page as a URL when another browser can continue it: https without embedded
+ * credentials, and not one of Sim's own pages on `appOrigin`, all of which the
+ * shell refuses to hand off.
+ */
+export function continuablePage(url: string | undefined, appOrigin: string): URL | null {
+ if (!url) return null
+ try {
+ const parsed = new URL(url)
+ if (parsed.username || parsed.password) return null
+ return parsed.protocol === 'https:' && parsed.origin !== appOrigin ? parsed : null
+ } catch {
+ return null
+ }
+}
+
+function matchesPath(pathname: string, prefixes: readonly string[]): boolean {
+ return prefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))
+}
+
+/**
+ * The host to key the hint by when `page` is a known sign-in page, or null.
+ * Hosts match on label boundaries, so `accounts.google.com.evil.com` or
+ * `evil-okta.com` never qualify.
+ */
+export function signInHintHost({ hostname, pathname }: URL): string | null {
+ const matched = SIGN_IN_HOSTS.some(({ host, subdomains, paths }) => {
+ const hostMatches = subdomains ? hostname.endsWith(`.${host}`) : hostname === host
+ return hostMatches && (!paths || matchesPath(pathname, paths))
+ })
+ return matched ? hostname : null
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts
new file mode 100644
index 00000000000..2211c6817c2
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/use-continue-sign-in.ts
@@ -0,0 +1,128 @@
+'use client'
+
+import { useCallback, useSyncExternalStore } from 'react'
+import type { BrowserContinueSignInError, BrowserImportProfile } from '@sim/desktop-bridge'
+import { toast } from '@sim/emcn'
+import { IMPORT_ERROR_MESSAGES } from '@/components/browser-import/browser-import-dialog'
+import {
+ continuablePage,
+ signInHintHost,
+} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/sign-in-hosts'
+import {
+ supportsContinueSignIn,
+ useBringSignInBack,
+ useBrowserImportProfiles,
+ useCancelSignInHandoff,
+ usePendingSignIn,
+ useStartSignInHandoff,
+} from '@/hooks/queries/browser-import'
+
+const CONTINUE_ERROR_MESSAGES: Record = {
+ 'chrome-not-found': IMPORT_ERROR_MESSAGES['chrome-not-found'],
+ 'unsupported-page': 'This page can’t be continued in another browser.',
+ 'launch-failed': 'Could not open that browser.',
+ unknown: 'Could not open this page in another browser.',
+}
+
+/** Hosts whose hint the user dismissed, for as long as the app stays open. */
+const dismissedHintHosts = new Set()
+const dismissalListeners = new Set<() => void>()
+
+function subscribeToDismissals(listener: () => void): () => void {
+ dismissalListeners.add(listener)
+ return () => {
+ dismissalListeners.delete(listener)
+ }
+}
+
+const dismissalCount = () => dismissedHintHosts.size
+
+interface UseContinueSignInProps {
+ scopeId: string
+ pageUrl: string | undefined
+}
+
+/**
+ * Finishing a sign-in in the user's own browser and bringing the session back
+ * (see `continueSignIn` on the desktop bridge). A shell without the bridge
+ * methods, a platform without the importer, or a device with no importable
+ * browser leaves `canContinue` false, and every surface built on this hides.
+ */
+export function useContinueSignIn({ scopeId, pageUrl }: UseContinueSignInProps) {
+ const supported = supportsContinueSignIn()
+ const { data: profiles = [] } = useBrowserImportProfiles(supported)
+ const { data: pendingData = null, isSuccess: pendingKnown } = usePendingSignIn(scopeId, supported)
+ const pending = supported ? pendingData : null
+ const { mutateAsync: startHandoff, isPending: starting } = useStartSignInHandoff(scopeId)
+ const { mutateAsync: importBack, isPending: importing } = useBringSignInBack(scopeId)
+ const { mutate: cancelHandoff } = useCancelSignInHandoff(scopeId)
+ useSyncExternalStore(subscribeToDismissals, dismissalCount, dismissalCount)
+
+ // Server rendering has no origin to compare and no shell to continue in, so it offers nothing.
+ const page = supported ? continuablePage(pageUrl, window.location.origin) : null
+ // The profiles cache is shared with the import dialog, which older shells
+ // also support, so cached profiles alone must not reveal this feature.
+ const canContinue = supported && profiles.length > 0 && page !== null
+ const hintHost = page ? signInHintHost(page) : null
+ // Until the shell answers, an existing handoff may be pending; the hint's Continue would replace it.
+ const hintVisible =
+ canContinue &&
+ pendingKnown &&
+ pending === null &&
+ hintHost !== null &&
+ !dismissedHintHosts.has(hintHost)
+
+ const continueIn = useCallback(
+ async (profile: BrowserImportProfile) => {
+ try {
+ const result = await startHandoff(profile.id)
+ if (result.error) toast.error(CONTINUE_ERROR_MESSAGES[result.error])
+ } catch {
+ toast.error(CONTINUE_ERROR_MESSAGES.unknown)
+ }
+ },
+ [startHandoff]
+ )
+
+ const bringBack = useCallback(async () => {
+ if (!pending) return
+ const { site, browserLabel } = pending
+ try {
+ const result = await importBack()
+ if (result.error) {
+ toast.error(IMPORT_ERROR_MESSAGES[result.error])
+ } else if (result.cookiesImported === 0) {
+ toast.warning(
+ `No sign-in for ${site} found in ${browserLabel}. Make sure you finished signing in.`
+ )
+ } else {
+ toast.success(`Brought back your ${site} session from ${browserLabel}`)
+ }
+ } catch {
+ toast.error(IMPORT_ERROR_MESSAGES.unknown)
+ }
+ }, [importBack, pending])
+
+ const cancel = useCallback(() => cancelHandoff(), [cancelHandoff])
+
+ const dismissHint = useCallback(() => {
+ if (!hintHost) return
+ dismissedHintHosts.add(hintHost)
+ for (const listener of dismissalListeners) listener()
+ }, [hintHost])
+
+ return {
+ profiles,
+ canContinue,
+ pending,
+ starting,
+ importing,
+ hintVisible,
+ continueIn,
+ bringBack,
+ cancel,
+ dismissHint,
+ }
+}
+
+export type ContinueSignInController = ReturnType
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
index c4f55ca3292..790062e140f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
@@ -11,7 +11,7 @@ import {
} from '@/lib/mothership/resources/types'
import type { FileDownloadSource } from '@/lib/uploads/client/download'
import { getFileExtension } from '@/lib/uploads/utils/file'
-import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
+import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import {
isCsvStreamOnly,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index cd256878d47..5577b79af38 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -4539,10 +4539,8 @@ export function useChat(
}
window.addEventListener('pagehide', clearClaim)
- window.addEventListener('beforeunload', clearClaim)
return () => {
window.removeEventListener('pagehide', clearClaim)
- window.removeEventListener('beforeunload', clearClaim)
}
}, [])
useEffect(() => {
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
index 7f25177ef2f..09a21912c77 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
@@ -77,6 +77,7 @@ import {
useUpdateChunk,
useUpdateDocument,
} from '@/hooks/queries/kb/knowledge'
+import { useBeforeUnloadWhile } from '@/hooks/use-before-unload-while'
import { useContextMenu } from '@/hooks/use-context-menu'
import { useDebounce } from '@/hooks/use-debounce'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
@@ -499,14 +500,7 @@ export function Document({
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isInEditorView])
- useEffect(() => {
- if (!isDirty) return
- const handler = (e: BeforeUnloadEvent) => {
- e.preventDefault()
- }
- window.addEventListener('beforeunload', handler)
- return () => window.removeEventListener('beforeunload', handler)
- }, [isDirty])
+ useBeforeUnloadWhile(isDirty)
const navigateToChunk = useCallback(
async (direction: 'prev' | 'next') => {
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts
index 34320dd67ab..81c5ed3e678 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts
@@ -12,6 +12,7 @@ import { runWithConcurrency, WHOLE_FILE_PARALLEL_UPLOADS } from '@/lib/uploads/c
import { uploadKnowledgeDocumentSession } from '@/lib/uploads/client/session-upload'
import type { UploadProgressEvent } from '@/lib/uploads/client/types'
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
+import { useInFlightUploadsStore } from '@/stores/in-flight-uploads/store'
const logger = createLogger('KnowledgeUpload')
@@ -204,6 +205,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
throw new KnowledgeUploadError('Knowledge base ID is required', 'INVALID_KB_ID')
}
+ const releaseUpload = useInFlightUploadsStore.getState().begin()
try {
assertMultiFileUploadAdmission(files)
setUploadError(null)
@@ -246,6 +248,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
options.onError?.(error)
throw err
} finally {
+ releaseUpload()
setUploadProgress({ stage: 'idle', filesCompleted: 0, totalFiles: 0 })
}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx
index df091963571..b43f3647e73 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx
@@ -37,6 +37,7 @@ import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/cust
import { DesktopOAuthConnectListener } from '@/app/workspace/[workspaceId]/providers/desktop-oauth-connect-listener'
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
+import { InFlightUploadGuard } from '@/app/workspace/[workspaceId]/providers/in-flight-upload-guard'
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
import { WorkspaceHostProvider } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
@@ -177,6 +178,7 @@ async function WorkspaceContent({
initialOrgSettings={initialOrgSettings}
>
+
diff --git a/apps/sim/app/workspace/[workspaceId]/providers/in-flight-upload-guard.tsx b/apps/sim/app/workspace/[workspaceId]/providers/in-flight-upload-guard.tsx
new file mode 100644
index 00000000000..533c328cb74
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/providers/in-flight-upload-guard.tsx
@@ -0,0 +1,14 @@
+'use client'
+
+import { useBeforeUnloadWhile } from '@/hooks/use-before-unload-while'
+import { useInFlightUploadsStore } from '@/stores/in-flight-uploads/store'
+
+/**
+ * Asks before the page unloads while any upload is still sending. Mounted once in
+ * the workspace layout, so it outlives the page that started the upload.
+ */
+export function InFlightUploadGuard() {
+ const uploading = useInFlightUploadsStore((state) => state.count > 0)
+ useBeforeUnloadWhile(uploading)
+ return null
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
index 3d6a8e8724d..7868f9468af 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
@@ -33,6 +33,7 @@ import {
} from '@/lib/table/import'
import type { TableDefinition } from '@/lib/table/types'
import { type CsvImportMode, useImportCsvIntoTable } from '@/hooks/queries/tables'
+import { trackUpload } from '@/stores/in-flight-uploads/store'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
const logger = createLogger('ImportCsvDialog')
@@ -315,8 +316,10 @@ export function ImportCsvDialog({
let importId: string | null = null
onOpenChange(false)
toast.success(`Importing "${parsed.file.name}" into "${table.name}" in the background`)
- importMutation.mutate(
- {
+ // The dialog closes before the upload ends, so its outcome is handled on the promise,
+ // which settles even after the dialog unmounts.
+ trackUpload(() =>
+ importMutation.mutateAsync({
workspaceId,
tableId: table.id,
file: parsed.file,
@@ -335,19 +338,18 @@ export function ImportCsvDialog({
onProgress: (percent) => {
if (importId) useImportTrayStore.getState().setUploadPercent(importId, percent)
},
+ })
+ ).then(
+ () => {
+ if (importId) {
+ useImportTrayStore.getState().endUpload(importId)
+ useImportTrayStore.getState().consumeCanceled(importId)
+ }
+ onImported?.({})
},
- {
- onSuccess: () => {
- if (importId) {
- useImportTrayStore.getState().endUpload(importId)
- useImportTrayStore.getState().consumeCanceled(importId)
- }
- onImported?.({})
- },
- onError: (error) => {
- if (importId) useImportTrayStore.getState().endUpload(importId)
- setSubmitError(summarizeImportError(error.message))
- },
+ (error: unknown) => {
+ if (importId) useImportTrayStore.getState().endUpload(importId)
+ setSubmitError(summarizeImportError(getErrorMessage(error)))
}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
index 01dab7d7975..75d0c5ce977 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
@@ -109,6 +109,7 @@ import { useResourceListPreferences } from '@/hooks/use-resource-list-preference
import { useSearchFilterValue } from '@/hooks/use-search-filter-value'
import { useUrlSort } from '@/hooks/use-url-sort'
import type { WorkflowFolder } from '@/stores/folders/types'
+import { useInFlightUploadsStore } from '@/stores/in-flight-uploads/store'
import type { ResourceListPreference } from '@/stores/resource-list-preferences'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
@@ -1175,6 +1176,7 @@ function TablesContent() {
return
}
+ const releaseUpload = useInFlightUploadsStore.getState().begin()
try {
setUploadProgress({ completed: 0, total: csvFiles.length })
for (let index = 0; index < csvFiles.length; index++) {
@@ -1212,6 +1214,7 @@ function TablesContent() {
logger.error('Error uploading CSV:', err)
toast.error('Failed to import CSV')
} finally {
+ releaseUpload()
setUploadProgress({ completed: 0, total: 0 })
if (csvInputRef.current) {
csvInputRef.current.value = ''
diff --git a/apps/sim/components/browser-import/browser-import-dialog.tsx b/apps/sim/components/browser-import/browser-import-dialog.tsx
index 93032ca0e1c..20c9e863bbd 100644
--- a/apps/sim/components/browser-import/browser-import-dialog.tsx
+++ b/apps/sim/components/browser-import/browser-import-dialog.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useEffect, useRef, useState } from 'react'
+import { useRef, useState } from 'react'
import type {
BrowserChromeImportResult,
BrowserImportError,
@@ -9,8 +9,9 @@ import type {
import { toast } from '@sim/emcn'
import { ImportModal } from '@/components/browser-import/import-modal'
import { getDesktopBridge } from '@/lib/desktop'
+import { useBrowserImportProfiles } from '@/hooks/queries/browser-import'
-const IMPORT_ERROR_MESSAGES: Record = {
+export const IMPORT_ERROR_MESSAGES: Record = {
'unsupported-platform': 'Importing from another browser is only supported on macOS.',
'chrome-not-found': 'Could not find that browser profile.',
'keychain-unavailable':
@@ -43,38 +44,15 @@ interface BrowserImportDialogProps {
/** Shares the same user-initiated import flow between Settings and the browser panel. */
export function BrowserImportDialog({ open, onOpenChange, onImported }: BrowserImportDialogProps) {
- const [profiles, setProfiles] = useState([])
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState()
+ const available = Boolean(getDesktopBridge()?.browserImport?.listChromeProfiles)
+ const profilesQuery = useBrowserImportProfiles(open && available)
const [pending, setPending] = useState(false)
const importInFlight = useRef(false)
-
- useEffect(() => {
- if (!open) return
- let active = true
- setLoading(true)
- setError(undefined)
- setProfiles([])
- const listProfiles = getDesktopBridge()?.browserImport?.listChromeProfiles
- if (!listProfiles) {
- setError('Browser import is not available on this device.')
- setLoading(false)
- return
- }
- void listProfiles()
- .then((next) => {
- if (active) setProfiles(next)
- })
- .catch(() => {
- if (active) setError('Could not load browser profiles. Close this dialog and try again.')
- })
- .finally(() => {
- if (active) setLoading(false)
- })
- return () => {
- active = false
- }
- }, [open])
+ const error = !available
+ ? 'Browser import is not available on this device.'
+ : profilesQuery.isError
+ ? 'Could not load browser profiles. Close this dialog and try again.'
+ : undefined
async function importFromBrowser(profile: BrowserImportProfile) {
const runImport = getDesktopBridge()?.browserImport?.importFromChrome
@@ -118,8 +96,8 @@ export function BrowserImportDialog({ open, onOpenChange, onImported }: BrowserI
void importFromBrowser(profile)}
diff --git a/apps/sim/content/library/ai-agent-output-controls-guardrails-structured-outputs-streaming/index.mdx b/apps/sim/content/library/ai-agent-output-controls-guardrails-structured-outputs-streaming/index.mdx
new file mode 100644
index 00000000000..c5f430d634a
--- /dev/null
+++ b/apps/sim/content/library/ai-agent-output-controls-guardrails-structured-outputs-streaming/index.mdx
@@ -0,0 +1,231 @@
+---
+slug: ai-agent-output-controls-guardrails-structured-outputs-streaming
+title: 'AI Agent Output Controls Compared: Guardrails, Structured Outputs, and Streaming'
+description: 'Compare AI agent guardrails, structured outputs, and streaming across Sim, LangGraph, OpenAI Agents SDK, Guardrails AI, Dify, and n8n.'
+seoTitle: 'AI Agent Output Controls: Guardrails and Streaming'
+date: 2026-10-31
+updated: 2026-10-31
+authors:
+ - andrew
+readingTime: 9
+tags: [AI Agents, Guardrails, Structured Outputs, Streaming, Sim]
+ogImage: /library/ai-agent-output-controls-guardrails-structured-outputs-streaming/cover.jpg
+draft: false
+faq:
+ - q: "Which AI agent platform is best for production output controls?"
+ a: "Sim fits teams that need a visual workflow builder combining a Guardrails block, structured Agent response formats, conditional routing, streaming deployments, self-hosting, and multi-model choice."
+ - q: "Does Sim's Guardrails block stop a workflow automatically?"
+ a: "Sim's Guardrails block does not stop a workflow automatically; it reports passed or failed, and a downstream Condition must route the workflow to block, retry, redact, or escalate the output."
+ - q: "What checks does Sim's Guardrails block support?"
+ a: "Sim's Guardrails block supports PII detection, hallucination checks, regex matching, and JSON validation."
+ - q: "Can Sim enforce structured JSON output?"
+ a: "Sim can request schema-based structured output through the Agent block response format and can separately check JSON validity with the Guardrails block before downstream use."
+ - q: "Does Sim support streaming responses?"
+ a: "Sim supports streaming responses in Chat and API deployments so users can receive output while an agent is still running."
+ - q: "Do streaming outputs bypass guardrails?"
+ a: "Streaming outputs can bypass post-generation guardrails if text is sent before validation, so Sim and other platforms should use pre-generation checks, validated chunks, buffering, or safe progress events for sensitive applications."
+ - q: "What is the difference between guardrails and structured outputs?"
+ a: "Guardrails determine whether content is acceptable, while structured outputs determine whether a response conforms to a machine-readable shape such as a JSON Schema."
+ - q: "What is the difference between token streaming and event streaming?"
+ a: "Token streaming returns generated text incrementally, while event streaming can return state changes, tool activity, node progress, custom status messages, or model tokens."
+ - q: "Which AI agent platform has the most flexible streaming controls?"
+ a: "LangGraph provides code-level controls for streaming state values, updates, messages, custom events, and debugging information."
+ - q: "Which AI agent platform is easiest for visual output controls?"
+ a: "Sim fits teams that want to compose guardrails, Agent response formats, Conditions, and deployments in a visual workflow builder."
+ - q: "Is Guardrails AI a complete AI agent builder?"
+ a: "Guardrails AI is primarily a specialist validation framework rather than a complete visual agent builder, so teams commonly pair it with a separate orchestration and deployment layer."
+ - q: "Does n8n support AI guardrails and structured output?"
+ a: "n8n supports AI Guardrails and Structured Output Parser nodes that can be combined with conditional workflow routing and applicable streaming response paths."
+ - q: "Is n8n open source?"
+ a: "n8n is source-available under the Sustainable Use License, but that license is not approved by the Open Source Initiative."
+ - q: "Is Sim open source?"
+ a: "Sim's core is open source under Apache 2.0, while code in apps/sim/ee is governed by the separate Sim Enterprise License and requires an active Enterprise subscription for production use."
+ - q: "Can Sim use local models when self-hosted?"
+ a: "Sim can use Ollama, vLLM, LM Studio, or LiteLLM on any self-hosted deployment without requiring Sim Enterprise."
+ - q: "Can Sim Cloud users bring their own model keys?"
+ a: "Sim Cloud supports workspace BYOK keys on every plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise."
+ - q: "Which platform is better for guardrails, Sim or n8n?"
+ a: "Sim fits visual agent-specific output control across PII detection, hallucination checks, regex matching, JSON validation, structured Agent responses, and streaming deployments, while n8n fits application automation that connects AI controls to other systems."
+ - q: "Which platform is better for streaming, Sim or LangGraph?"
+ a: "Sim fits teams seeking visual workflows and deployable Chat or API streaming, while LangGraph fits developers who need granular, code-defined streaming events and state updates."
+ - q: "Which platform is better for structured output, Sim or the OpenAI Agents SDK?"
+ a: "Sim fits visual multi-model workflows, while the OpenAI Agents SDK fits code-first applications that use typed outputs within the OpenAI agent ecosystem."
+ - q: "What is the best AI agent builder?"
+ a: "The right AI agent builder depends on the required interface, deployment model, output controls, integrations, and level of code ownership."
+ - q: "What is the best self-hosted platform for guarded AI agent outputs?"
+ a: "Sim fits teams that want a self-hosted visual workspace combining guardrails, structured responses, streaming deployments, and local model support."
+---
+
+## TL;DR
+
+Production output control has three separate layers: guardrails decide whether content is acceptable, structured outputs make responses machine-readable, and streaming returns partial results before a run finishes. A platform may support one layer without supporting the other two.
+
+Sim fits teams that need guardrails, schema-based responses, and streaming deployments in one self-hostable AI workspace. LangGraph and the OpenAI Agents SDK suit code-first applications, Guardrails AI provides a specialist validation layer, Dify offers a visual LLM application environment, and n8n connects AI controls to automation workflows.
+
+Feature, deployment, and licensing claims in this comparison were checked against official vendor documentation and repositories as of October 2026.
+
+## Which platforms combine guardrails, structured outputs, and streaming?
+
+The six options approach production output controls from different directions:
+
+- **Choose Sim** for visual agent workflows that combine a Guardrails block, schema-based Agent responses, streaming Chat or API deployments, self-hosting, and broad model choice.
+- **Choose LangGraph** for code-first orchestration where developers want precise control over state, validation, and streaming events.
+- **Choose the OpenAI Agents SDK** for code-first applications centered on OpenAI's agent runtime, typed outputs, and input or output guardrails.
+- **Choose Guardrails AI** when validation is the primary requirement and a specialist validation layer can sit inside a separately built application.
+- **Choose Dify** for a visual LLM application environment with structured output, moderation controls, streaming applications, and self-hosting.
+- **Choose n8n** for automation-heavy workflows that need AI Guardrails and Structured Output Parser nodes alongside application integrations.
+
+## Output-control comparison
+
+| Platform | Guardrails | Structured output | Streaming | Self-hosting and license | Model choice |
+|---|---|---|---|---|---|
+| **Sim** | The [Guardrails block](https://docs.sim.ai/workflows/blocks/guardrails) checks PII, hallucinations, regex matches, or JSON validity; a downstream Condition routes on pass or fail | The [Agent block](https://docs.sim.ai/workflows/blocks/agent) can return structured JSON | [API deployments](https://docs.sim.ai/workflows/deployment/api) can return SSE streams, and Chat deployments can stream agent output | Sim's core is Apache 2.0 and self-hostable; `apps/sim/ee` uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) | Hosted providers, workspace BYOK, and local models on self-hosted Sim |
+| **LangGraph** | Application-defined checks can run in graph nodes and application code | LangChain agents built on LangGraph support [schema-based response formats](https://docs.langchain.com/oss/python/langchain/structured-output) | Sync and async APIs expose [multiple stream modes](https://docs.langchain.com/oss/python/langgraph/streaming) | The framework uses the [MIT License](https://github.com/langchain-ai/langgraph/blob/main/LICENSE) and runs in application infrastructure | Model behavior is defined in the surrounding [LangChain application](https://docs.langchain.com/oss/python/langchain/structured-output) |
+| **OpenAI Agents SDK** | [Input and output guardrails](https://openai.github.io/openai-agents-python/guardrails/) can trigger a tripwire | Agent output schemas capture JSON Schema and validate or parse model output | `run_streamed` exposes [asynchronous run events](https://openai.github.io/openai-agents-python/streaming/) | The SDK uses the [MIT License](https://github.com/openai/openai-agents-python/blob/main/LICENSE) and runs in application infrastructure | OpenAI models plus custom model providers, including a documented [LiteLLM provider](https://openai.github.io/openai-agents-python/ref/extensions/models/litellm_provider/) |
+| **Guardrails AI** | Guards wrap model calls and apply reusable validators | A Guard can accept a Pydantic model for [structured-data validation](https://www.guardrailsai.com/docs/the_guard) | Streaming behavior depends on how the validation layer is integrated into the application | The library uses the [Apache 2.0 License](https://github.com/guardrails-ai/guardrails/blob/main/LICENSE) | The application configures the [model call that the Guard wraps](https://www.guardrailsai.com/docs/the_guard) |
+| **Dify** | [Moderation extensions](https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/moderation-api-extension) can review application input and model output | The [LLM node](https://docs.dify.ai/en/self-host/use-dify/nodes/llm) supports structured output | Application APIs can return [Server-Sent Events](https://docs.dify.ai/en/api-reference/guides/streaming) | Dify can run with Docker under its [modified license terms](https://github.com/langgenius/dify/blob/main/LICENSE) | Workspaces can [configure model providers](https://docs.dify.ai/en/self-host/use-dify/workspace/model-providers) |
+| **n8n** | The [Guardrails node](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.guardrails) checks or sanitizes text | The [Structured Output Parser](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.outputparserstructured) returns fields based on JSON Schema | Chat Trigger and Webhook workflows can [stream supported node output](https://docs.n8n.io/build/understand-workflows/understand-executions/stream-real-time-responses) | n8n supports self-hosting under its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) | Workflows connect a [Chat Model node](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.guardrails) where a control requires one |
+
+The table distinguishes platform-level schema handling from provider-native constrained generation. Strict token-level schema enforcement depends on whether the selected model provider supports it. Otherwise, a platform may parse, validate, retry, or reject the completed response.
+
+## How guardrails prevent bad outputs in production
+
+These platforms can all participate in a guarded production design, but the control may be a visual block, SDK hook, specialist validator, or workflow node.
+
+Sim's [Guardrails block](https://docs.sim.ai/workflows/blocks/guardrails) provides four checks: PII detection, hallucination detection, regex matching, and JSON validation. The block reports whether the check passed or failed; it does not stop a run by itself. To block, retry, redact, or escalate an output, connect the result to a downstream Condition.
+
+LangGraph makes checks programmable through graph nodes and application logic. Teams can place validation before or after model calls and route invalid state to correction or escalation paths. That flexibility suits policies that depend on application state, but it also requires code and test coverage.
+
+The OpenAI Agents SDK exposes [input and output guardrails](https://openai.github.io/openai-agents-python/guardrails/) with tripwire behavior. Guardrails AI focuses on validation around model calls. Dify's moderation extensions and n8n's Guardrails node add visual controls to their respective application and automation environments.
+
+Guardrails should be evaluated by action, not merely detection. A production design must specify what happens after failure: block the response, redact sensitive fields, retry with corrective instructions, request human review, or return a safe fallback.
+
+## Structured output and schema enforcement
+
+Sim fits teams that want structured agent output in a visual workflow. Its [Agent block](https://docs.sim.ai/workflows/blocks/agent) can return structured JSON, while the Guardrails block can independently report whether JSON is valid before downstream systems use it.
+
+Structured output matters when an agent feeds a CRM, database, API, ticketing system, or user interface. A response that looks correct to a person can still break automation if it omits a required key, changes a data type, or wraps JSON in explanatory prose.
+
+LangChain agents built on LangGraph support [provider-native and tool-based structured-output strategies](https://docs.langchain.com/oss/python/langchain/structured-output). The OpenAI Agents SDK represents typed output through a schema that validates and parses model output. Guardrails AI fits applications that need a separate validation and correction layer around model calls.
+
+Test schema support at three levels:
+
+1. **Generation:** Does the model provider constrain output to the schema while generating?
+2. **Validation:** Does the platform detect missing fields, invalid types, or malformed JSON?
+3. **Failure handling:** Can the workflow retry, repair, reject, or escalate an invalid result?
+
+Parsing JSON after generation is not equivalent to provider-native constrained decoding. Both approaches can be useful, but buyers should establish which mechanism applies to each selected model.
+
+## Streaming partial output while an agent runs
+
+Sim fits teams that want partial agent output through deployed Chat and API experiences. LangGraph suits developers who need granular, code-level event streaming.
+
+Streaming reduces perceived latency by returning text or events before an entire run finishes. It is useful for research assistants, support agents, embedded assistants, and interfaces where users would otherwise wait through model generation or tool calls without feedback.
+
+LangGraph documents stream modes for [values, updates, messages, custom events, checkpoints, tasks, and debugging data](https://docs.langchain.com/oss/python/langgraph/streaming). The OpenAI Agents SDK exposes streamed run events. Dify supports SSE responses from application APIs, while n8n streams output from supported nodes through Chat Trigger or Webhook workflows.
+
+Teams should distinguish token streaming from workflow-event streaming. Token streaming reveals generated text incrementally. Event streaming can also reveal tool starts, tool results, node transitions, or custom progress messages. The user interface and security policy determine which events should be exposed.
+
+## Streaming responses for real-time chat
+
+Sim fits teams that want a visual agent workflow and a streaming Chat deployment without building the entire interface and orchestration layer from scratch.
+
+Real-time chat requires more than enabling a streaming flag. The application must preserve the intended conversation state, handle disconnects, present tool activity safely, and prevent rejected output from reaching the user. Streaming can make a system feel faster, but it can also expose unsafe text before a post-generation check runs.
+
+For sensitive applications, use one of three patterns:
+
+- Apply input checks before generation and stream only after the request passes.
+- Buffer output until sentence, field, or chunk-level validation completes.
+- Stream low-risk progress events while withholding generated content until final validation passes.
+
+LangGraph fits applications that need custom event channels and fine-grained state control. The OpenAI Agents SDK fits code-first OpenAI applications. Sim fits teams that want these controls within a visual workflow and deployable Chat or API surface.
+
+## Which platform fits each output-control requirement?
+
+### Sim
+
+Sim fits teams that want visual guardrails, structured agent responses, streaming deployments, self-hosting, and multi-model choice in one AI workspace.
+
+- **Fits:** Product and operations teams working with engineers on production agents.
+- **Control model:** Visual blocks and conditional routing around agent execution.
+- **Tradeoff:** Complex custom policies may still require code or external services.
+
+### LangGraph
+
+LangGraph fits engineering teams that need programmable state, custom validation logic, and granular streaming events.
+
+- **Fits:** Code-first, stateful agent applications with bespoke runtime behavior.
+- **Control model:** Graph nodes, state transitions, and application code.
+- **Tradeoff:** Teams build more of the deployment and user experience themselves.
+
+### OpenAI Agents SDK
+
+The OpenAI Agents SDK fits developers building typed, streamed applications around OpenAI's agent ecosystem.
+
+- **Fits:** Python applications using SDK-level orchestration.
+- **Control model:** Input guardrails, output guardrails, typed outputs, and streamed run events.
+- **Tradeoff:** Visual workflow building is not its primary interface.
+
+### Guardrails AI
+
+Guardrails AI fits teams that need a dedicated validation layer rather than an all-in-one visual agent builder.
+
+- **Fits:** Applications where output validation, correction, and reusable validators are the central concern.
+- **Control model:** Schemas and validators surrounding model calls.
+- **Tradeoff:** Teams still need a separate orchestration, deployment, and interface layer.
+
+### Dify
+
+Dify fits teams that want a self-hostable visual LLM application environment with structured outputs and streaming application delivery.
+
+- **Fits:** Visual chat, retrieval, and workflow applications using configured model providers.
+- **Control model:** LLM nodes, moderation extensions, workflow branches, and application settings.
+- **Tradeoff:** Teams should test whether each desired control is native, extension-based, or implemented through workflow logic.
+
+### n8n
+
+n8n fits integration-heavy automation that combines AI output controls with application nodes.
+
+- **Fits:** Teams whose agent output immediately triggers SaaS or data-system actions.
+- **Control model:** Guardrails, Structured Output Parser, conditional nodes, and workflow routing.
+- **Tradeoff:** n8n uses the source-available Sustainable Use License rather than an OSI-approved open-source license.
+
+## License, self-hosting, and billing facts
+
+All six options permit some form of self-managed deployment or application-hosted runtime, but their licenses and billing models are not equivalent.
+
+As of October 2026:
+
+- **Sim:** Sim's core is Apache 2.0 and can be [self-hosted](https://docs.sim.ai/platform/self-hosting). Code in `apps/sim/ee` is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which permits development, testing, and internal non-production use but requires an active Enterprise subscription for production use.
+- **LangGraph:** The framework is [MIT-licensed](https://github.com/langchain-ai/langgraph/blob/main/LICENSE) and runs in application infrastructure. Model and optional managed-service costs are separate from the framework.
+- **OpenAI Agents SDK:** The SDK is [MIT-licensed](https://github.com/openai/openai-agents-python/blob/main/LICENSE) and runs in application infrastructure. The configured model provider handles model usage.
+- **Guardrails AI:** The library is [Apache 2.0 licensed](https://github.com/guardrails-ai/guardrails/blob/main/LICENSE) and can run with the application. Model-provider costs remain external to the library.
+- **Dify:** Dify supports [Docker-based self-hosting](https://docs.dify.ai/en/self-host/deploy/overview) under the repository's modified license terms.
+- **n8n:** n8n supports [self-hosting](https://docs.n8n.io/deploy/host-n8n) under its source-available Sustainable Use License.
+
+[Workspace BYOK keys](https://docs.sim.ai/platform/costs#bring-your-own-key-byok) work on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise. [Local models](https://docs.sim.ai/platform/self-hosting) are a self-hosting capability: Ollama, vLLM, LM Studio, and LiteLLM can be configured on any self-hosted Sim deployment without requiring Enterprise.
+
+## How to test guardrails, structured outputs, and streaming
+
+Every platform should be tested with adversarial, malformed, and interrupted runs before production approval.
+
+Use a representative test set that includes:
+
+1. Prompts containing names, email addresses, phone numbers, account identifiers, and other sensitive data.
+2. Requests designed to trigger hallucinations or unsupported claims.
+3. Valid JSON, malformed JSON, missing required fields, extra fields, incorrect types, and schema-breaking nested objects.
+4. Slow model responses, long tool calls, client disconnects, retries, and partial streamed output.
+5. Guardrail failures that must block, redact, retry, escalate, or request human review.
+6. Models with and without provider-native structured output support.
+
+Measure false positives, false negatives, schema-valid response rate, time to first streamed event, time to final validated response, retry rate, and the percentage of rejected content exposed before validation.
+
+## How output controls differ from approvals and observability
+
+Output controls, human approval, and observability are complementary production layers rather than interchangeable features.
+
+Guardrails and schemas control what an agent can return. Human approval controls whether a person must authorize a consequential step. Observability records what occurred so teams can debug and evaluate the run.
+
+For approval patterns, read [Best AI Agent Builders for Human Approval Workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows) and [What Is Human-in-the-Loop in AI Agents?](https://www.sim.ai/library/what-is-human-in-the-loop-in-ai-agents). For traces, metrics, and evaluations, read [What Is AI Agent Observability?](https://www.sim.ai/library/ai-agent-observability).
+
+Broader selection criteria appear in [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026), while deployment options are compared in [Best Self-Hosted AI Workflow Automation Platforms in 2026](https://www.sim.ai/library/best-self-hosted-ai-workflow-automation-platforms-2026).
diff --git a/apps/sim/content/library/best-ai-agents-jira-triage-datadog-incident-response/index.mdx b/apps/sim/content/library/best-ai-agents-jira-triage-datadog-incident-response/index.mdx
new file mode 100644
index 00000000000..c82bc4cb818
--- /dev/null
+++ b/apps/sim/content/library/best-ai-agents-jira-triage-datadog-incident-response/index.mdx
@@ -0,0 +1,192 @@
+---
+slug: best-ai-agents-jira-triage-datadog-incident-response
+title: 'Best AI Agents for Jira Triage and Datadog Incident Response'
+seoTitle: 'AI Agents for Jira and Datadog Incident Response'
+description: 'Compare six AI agent options for Jira triage and Datadog incident response, including cross-tool workflows, native investigation, and approvals.'
+date: 2026-10-31
+updated: 2026-10-31
+authors:
+ - andrew
+readingTime: 7
+tags: [AI Agents, Incident Response, Jira, Datadog, Sim]
+ogImage: /library/best-ai-agents-jira-triage-datadog-incident-response/cover.jpg
+draft: false
+faq:
+ - q: "Our engineering team wants to automate ticket triage in Jira using AI agents, which platforms offer the strongest Jira integration and workflow customization?"
+ a: "Sim fits triage that combines AI classification, custom routing, other engineering tools, and human approval. Atlassian Rovo fits work centered in Jira, while Workato, n8n, and Zapier offer broader integration automation."
+ - q: "Top AI agent tools that integrate with Jira"
+ a: "Sim, Atlassian Rovo, Workato, n8n, and Zapier can support AI or automation connected to Jira. The right fit depends on whether the process needs cross-tool agent logic, Jira-native context, enterprise integration governance, self-hosting, or straightforward cloud automation."
+ - q: "Our ops team needs AI agents that can monitor Datadog alerts and trigger automated incident response workflows, what platforms handle this integration best?"
+ a: "Sim fits a cross-tool response in which Datadog alerts are enriched, reviewed, recorded in Jira, and communicated in Slack. Datadog Bits AI fits Datadog-native investigation, while Workato and n8n suit teams with established integration programs."
+ - q: "Which AI agent platforms integrate with Datadog for monitoring automation?"
+ a: "Sim, Datadog Bits AI, Workato, n8n, and Zapier can participate in Datadog monitoring automation. Sim fits a customizable agent spanning Datadog, Jira, Slack, and approval steps, while Datadog Bits AI focuses on investigation within Datadog."
+ - q: "Which AI platform is best for both Jira and Datadog?"
+ a: "Sim fits teams that need one AI workflow connecting Jira and Datadog. It can receive alert data through a webhook, enrich and classify it with AI, pause for human input, and then perform approved Jira and Slack actions."
+ - q: "Can an AI agent create or update a Jira issue from a Datadog alert?"
+ a: "Sim can create or update a Jira issue after receiving a Datadog alert through a webhook and evaluating its context. The workflow can search for duplicates, propose labels and ownership, request human approval, and route the approved result to Jira."
+ - q: "Can an AI agent deduplicate Jira issues created from monitoring alerts?"
+ a: "Sim can support Jira deduplication by searching for open issues with matching services, monitor identifiers, error signatures, and incident windows before choosing whether to create or update an issue. Teams should keep deterministic matching rules around model judgment so a person can review ambiguous incidents."
+ - q: "Can an AI agent link Jira issues to code or pull requests?"
+ a: "Sim can add repository, commit, and pull-request context to Jira when the workflow has authorized access to a source-control tool or MCP server. The agent should use least-privilege credentials and preserve source references so engineers can verify the generated context."
+ - q: "Should an AI incident-response agent require human approval?"
+ a: "Human approval is appropriate before high-impact or externally visible incident actions when an alert may be ambiguous. Low-risk enrichment can run automatically, while issue creation, severity changes, broad Slack announcements, and escalations can sit behind a Human in the Loop step and downstream Condition."
+ - q: "When should a team choose Atlassian Rovo instead of a cross-tool agent?"
+ a: "Atlassian Rovo fits when Jira and other Atlassian products contain most of the required context and the desired work remains in that ecosystem. Sim fits when Datadog, Slack, source control, and custom approval logic are equally important parts of the process."
+ - q: "When should a team choose Datadog Bits AI instead of a cross-tool agent?"
+ a: "Datadog Bits AI fits when telemetry investigation inside Datadog is the primary job. Sim fits when the response must continue into Jira, Slack, human review, and other engineering systems through a customizable workflow."
+ - q: "Is n8n open source?"
+ a: "n8n is source-available under the Sustainable Use License, not open source under an OSI-approved license. Its self-hosted community and enterprise editions have different license terms."
+ - q: "Is Sim free?"
+ a: "Costs depend on the deployment and features a team chooses. Review Sim's core and enterprise license terms alongside the managed cloud options."
+ - q: "Is Sim a good alternative to n8n for Jira and Datadog workflows?"
+ a: "Sim fits teams prioritizing multi-model agent logic and human review across Jira and Datadog. n8n fits technical teams that prefer its node ecosystem and accept its source-available Sustainable Use License."
+ - q: "Is Sim a good alternative to Zapier for Jira and Datadog automation?"
+ a: "Sim fits Jira and Datadog automation that needs model-driven reasoning, branching incident logic, self-hosting, or structured human approval. Zapier fits direct cloud app actions that do not require a deeply customized incident agent."
+ - q: "How does Sim compare with Gumloop for engineering incident workflows?"
+ a: "Sim documents the integration, webhook, Slack, and Human in the Loop building blocks needed for this Jira-and-Datadog workflow. Teams comparing the products should evaluate Gumloop's current Jira, Datadog, deployment, and approval capabilities against its official documentation."
+ - q: "What is the best AI agent builder?"
+ a: "The right AI agent builder depends on the systems, deployment needs, model choices, and approval controls a team requires. Sim fits teams that need an open-source core, visual cross-tool workflows, flexible model choices, and human approval."
+---
+
+## TL;DR
+
+Six options address different parts of Jira triage and Datadog incident response:
+
+- **Choose Sim** when one agent must receive a Datadog alert, reason over its context, create or update a Jira issue, notify Slack, and request human approval.
+- **Choose [Workato](https://www.workato.com/pricing)** when a large enterprise prioritizes centralized integration governance and managed connectivity.
+- **Choose [n8n](https://docs.n8n.io/choose-how-to-use-n8n)** when a technical team wants self-hosting and broad workflow control under a source-available license.
+- **Choose [Zapier](https://zapier.com/pricing)** when a cloud-first team values straightforward app automation over deep incident logic.
+- **Choose [Datadog Bits AI](https://docs.datadoghq.com/bits_ai/)** when investigation should remain primarily inside Datadog.
+- **Choose [Atlassian Rovo](https://www.atlassian.com/software/rovo)** when Jira context and Atlassian knowledge matter more than cross-tool incident orchestration.
+
+As of October 2026, this guide compares the options by Jira fit, Datadog fit, cross-tool orchestration, AI customization, and approval controls. The order is not a universal ranking. Each product fits a different operating model.
+
+## How should teams compare AI agents for Jira and Datadog?
+
+Start with the boundary of the incident process. A Jira-centered assistant, a Datadog investigation agent, and a cross-tool workflow solve related but different problems.
+
+| Platform | Deployment and license model | Workflow fit |
+|---|---|---|
+| [Sim](https://github.com/simstudioai/sim/blob/main/LICENSE) | Self-hosted or managed cloud; Apache 2.0 core with [separately licensed enterprise code](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) | Custom agent workflows across Jira, Datadog, Slack, models, and approval steps |
+| [Workato](https://www.workato.com/pricing) | Managed commercial integration platform | Enterprise integration governance and centralized administration |
+| [n8n](https://docs.n8n.io/choose-how-to-use-n8n) | Cloud or self-hosted; source-available Sustainable Use License | Developer-operated workflows with API, webhook, code, and AI steps |
+| [Zapier](https://zapier.com/pricing) | Managed cloud automation | Direct app actions and accessible multi-step automation |
+| [Datadog Bits AI](https://docs.datadoghq.com/bits_ai/) | Datadog-native AI capabilities | Alert investigation and operational work within Datadog |
+| [Atlassian Rovo](https://www.atlassian.com/software/rovo) | Atlassian AI capabilities | Jira and Atlassian knowledge, search, chat, and agents |
+
+The practical questions are whether Datadog starts the process, whether Jira is only a destination or the main system of record, how much context comes from Slack and source control, and which actions require human review.
+
+## What are the key facts about each option?
+
+- **Sim:** [Sim's core uses the Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE), while code in `apps/sim/ee` is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). Sim supports managed cloud and [self-hosted deployment](https://docs.sim.ai/platform/self-hosting). Ollama, vLLM, LM Studio, and LiteLLM work with self-hosted Sim deployments.
+- **Workato:** [Workato's current editions](https://www.workato.com/pricing) combine application integration and process automation with administration and security features. Its documentation describes a [usage-based billing model](https://docs.workato.com/pricing) for applicable customers.
+- **n8n:** n8n offers [cloud and self-hosted deployment](https://docs.n8n.io/choose-how-to-use-n8n), and its self-hosted community edition uses the [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license). That license is source-available rather than OSI-approved open source.
+- **Zapier:** [Zapier's plans](https://zapier.com/pricing) provide managed cloud automation with usage measured in tasks.
+- **Datadog Bits AI:** [Datadog describes Bits AI](https://docs.datadoghq.com/bits_ai/) as an agentic teammate for development, security, and operational work in Datadog, including alert investigation.
+- **Atlassian Rovo:** [Atlassian describes Rovo](https://www.atlassian.com/software/rovo) as AI that uses organizational context to assist with decisions and workflows across Atlassian work.
+
+## When does Sim fit Jira triage and Datadog incident response?
+
+Sim fits when the incident workflow must reason across Datadog, Jira, and Slack instead of remaining inside one vendor's product.
+
+Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Its workflow builder can combine Datadog and Jira blocks, Slack actions, webhook triggers, model calls, Conditions, and Human in the Loop steps.
+
+That combination suits engineering workflows that must:
+
+- Classify Jira issues by service, severity, component, or probable owner.
+- Detect likely duplicates and add context to an existing issue instead of opening another one.
+- Enrich a Datadog alert with logs, metrics, deployment details, or repository context.
+- Create or update Jira issues and notify the relevant Slack channel.
+- Pause before external actions and collect an explicit approval or rejection.
+- Add code context through an MCP-enabled workflow, as described in [How to Use the GitHub MCP Server With AI Agents](https://www.sim.ai/library/github-mcp-server-ai-agents).
+
+Sim is less suitable when a team only wants a built-in assistant inside Jira or Datadog and does not need a cross-tool workflow.
+
+## When does Workato fit Jira and Datadog operations?
+
+Workato fits enterprises that prioritize managed integrations, centralized administration, and governed automation across a large application estate. Its [platform editions include application integration, process automation, lifecycle management, and access controls](https://www.workato.com/pricing).
+
+Its clearest use case is an organization that already operates Workato as an integration layer and wants Jira and Datadog processes to follow the same administration model as its other systems. Formal integration ownership may matter more to such a team than an open-source core or a developer-operated workspace.
+
+## When does n8n fit Jira and Datadog operations?
+
+n8n fits technical teams that want broad workflow control and a self-hosted option but accept a source-available license rather than an OSI-approved open-source license.
+
+As of October 2026, n8n provides official pages for [Jira Software integrations](https://n8n.io/integrations/jira-software/) and [Datadog integrations](https://n8n.io/integrations/datadog/), alongside webhook and API building blocks. This makes n8n a practical option for teams prepared to design and maintain branching incident workflows.
+
+The [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) permits many internal uses and restricts some commercial use cases. [Sim's core uses Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), while enterprise-only code has separate terms under the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE).
+
+## When does Zapier fit Jira and Datadog operations?
+
+Zapier fits cloud-first teams that need understandable app automation without operating infrastructure.
+
+As of October 2026, Zapier lists an official [Jira Software Cloud integration](https://zapier.com/apps/jira-software-cloud/integrations) and workflows that connect [Jira Software Cloud with Datadog](https://zapier.com/apps/datadog/integrations/jira-software-cloud/255638391/add-metrics-in-datadog-for-new-jira-software-cloud-issues). This suits direct actions such as creating a Jira issue from an incoming event or sending a notification after a record changes.
+
+Teams should examine another option when their process needs extensive incident enrichment, several reasoning branches, durable engineering context, or a deeply customized approval path.
+
+## When does Datadog Bits AI fit incident response?
+
+Datadog Bits AI fits when Datadog is the operational system of record and the main job is to investigate telemetry inside Datadog.
+
+As of October 2026, Datadog positions [Bits Investigation](https://www.datadoghq.com/product/ai/bits-investigation/) around autonomous alert investigation and root-cause analysis. Native access to Datadog context is useful when responders want to investigate alerts and related telemetry without first moving the process into another system.
+
+A cross-tool platform fits better when the agent must coordinate a larger process across Datadog, Jira, Slack, source control, and a human approval checkpoint.
+
+## When does Atlassian Rovo fit Jira triage?
+
+Atlassian Rovo fits when the workflow centers on Jira and the wider Atlassian knowledge graph.
+
+As of October 2026, Atlassian presents [Rovo Search, Chat, and Agents](https://www.atlassian.com/software/rovo/features) as connected capabilities for finding context and taking action across Atlassian work. This makes Rovo relevant when issue classification, ownership, and context depend primarily on Jira, Confluence, and connected information.
+
+A cross-tool agent usually fits better when Datadog initiates the workflow and the response coordinates several non-Atlassian systems with custom logic.
+
+## When does a cross-tool agent fit better than a native agent?
+
+A native agent usually fits when:
+
+- Most required evidence already lives in its native product.
+- The desired actions remain within Jira or Datadog.
+- The team wants a direct path to vendor-specific assistance.
+- Cross-tool approval and branching are limited.
+
+A cross-tool agent usually fits when:
+
+- Datadog detects the problem but Jira tracks the engineering work.
+- Slack is the coordination surface for responders.
+- Repository or deployment context must enrich the alert.
+- The team needs one approval policy before issues, messages, or escalations are sent.
+- The workflow should support several models, APIs, and deployment choices.
+
+## How can Sim respond to a Datadog alert, create a Jira issue, and post to Slack?
+
+Sim can receive a Datadog alert through a webhook, enrich the incident with an AI step, pause for human input, and then create or update a Jira issue and post to Slack.
+
+A practical workflow looks like this:
+
+1. **Receive the alert:** Configure Datadog to send an alert payload to a Sim webhook trigger, including the service, monitor, severity, timestamp, and relevant links.
+2. **Normalize the event:** Map the payload into consistent fields so downstream steps do not depend on monitor-specific formatting.
+3. **Enrich the incident:** Ask the model to summarize the alert, identify the affected service, estimate severity, propose an owner, and extract troubleshooting steps from the supplied evidence.
+4. **Check for an existing Jira issue:** Search Jira using the service, error signature, monitor identifier, and active incident window. Route likely duplicates toward an update rather than creating another issue.
+5. **Optionally add code context:** Use GitHub through MCP to find relevant repositories, commits, pull requests, or ownership files and include references in the proposed Jira record.
+6. **Pause for human input:** Use Sim's Human in the Loop block to pause the run and collect fields such as approve or reject, severity, owner, and reviewer notes.
+7. **Route the decision:** Use a downstream Condition to inspect the approval field. Human in the Loop collects the response but does not independently select the next branch.
+8. **Act on approval:** Create or update the Jira issue and post a structured incident summary to Slack with links back to Datadog and Jira.
+9. **Handle rejection:** Record the reviewer's explanation, notify the appropriate channel if needed, and end the run without performing the rejected actions.
+
+The human checkpoint should appear before externally visible actions when a false positive could create noise, misroute work, or trigger an unnecessary escalation. [What Is Human-in-the-Loop in AI Agents?](https://www.sim.ai/library/what-is-human-in-the-loop-in-ai-agents) explains how to design these checkpoints.
+
+## How should Jira issue triage differ from customer support triage?
+
+Engineering issue triage is a service-reliability process, not a customer support queue.
+
+Engineering triage commonly uses signals such as affected service, component ownership, deployment timing, error signature, severity, duplicate incidents, repository context, and operational impact. Customer support triage more often prioritizes customer identity, sentiment, account context, product area, and response targets.
+
+Incident and engineering workflows should use their own labels, evidence, ownership rules, and approval thresholds. Teams comparing broader selection criteria can read [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026).
+
+## What related comparisons should engineering teams read?
+
+Platform selection changes when the primary criterion is approval design, integration breadth, or production observability:
+
+- [Best AI Agent Builders for Human Approval Workflows](https://www.sim.ai/library/best-ai-agent-builders-for-human-approval-workflows) focuses on review checkpoints and governed actions.
+- [6 Best AI Observability Tools for Production Agents in 2026](https://www.sim.ai/library/6-best-ai-observability-tools-for-production-agents-in-2026) covers monitoring the agents themselves.
+- [How to Use the GitHub MCP Server With AI Agents](https://www.sim.ai/library/github-mcp-server-ai-agents) explains how repository context can enter an agent workflow.
diff --git a/apps/sim/content/library/best-multi-agent-frameworks-2026/index.mdx b/apps/sim/content/library/best-multi-agent-frameworks-2026/index.mdx
index 64c33beef48..6d87a41d6ed 100644
--- a/apps/sim/content/library/best-multi-agent-frameworks-2026/index.mdx
+++ b/apps/sim/content/library/best-multi-agent-frameworks-2026/index.mdx
@@ -3,10 +3,10 @@ slug: best-multi-agent-frameworks-2026
title: 'Best Multi-Agent Frameworks for Production in 2026'
description: 'Compare the best multi-agent frameworks for production in 2026 across orchestration, state, observability, licensing, self-hosting, deployment, and pricing.'
date: 2026-08-25
-updated: 2026-10-24
+updated: 2026-10-31
authors:
- andrew
-readingTime: 34
+readingTime: 35
tags: [AI Agents, Multi-Agent Systems, Agent Frameworks, Open Source, Comparison, Sim]
ogImage: /library/best-multi-agent-frameworks-2026/cover.jpg
draft: false
@@ -87,6 +87,32 @@ faq:
a: "Best AI Agent Platforms and Builders in 2026 compares the broader market for AI agent platforms and builders."
- q: "Which guide explains multi-agent orchestration patterns?"
a: "AI Agent Orchestration Frameworks Explained covers the main architectural patterns used to coordinate agents, tools, state, and handoffs."
+ - q: "What are the best tools for orchestrating multi-agent systems in production?"
+ a: "Sim, LangGraph, Microsoft Agent Framework, OpenAI Agents SDK, CrewAI, Google Agent Development Kit, and n8n fit different production requirements. AutoGen is archived and should be considered only for maintaining an existing system during migration."
+ - q: "What is an agent handoff?"
+ a: "An agent handoff is an explicit transfer of task ownership, relevant state, permitted actions, and return conditions from one agent to another."
+ - q: "What is the difference between an agent handoff and an agent calling another agent as a tool?"
+ a: "Sim treats a workflow-as-tool call as bounded delegation that returns a result to the caller, while a full handoff transfers control so the receiving agent becomes responsible for the next part of the interaction."
+ - q: "What is the difference between A2A and MCP?"
+ a: "A2A standardizes communication and task coordination between agents, while MCP standardizes how an agent accesses tools, data, prompts, and reusable capabilities."
+ - q: "Does Sim support A2A?"
+ a: "Sim supports A2A through its documented A2A integration for connecting workflows with interoperable external agents."
+ - q: "Does Sim support MCP?"
+ a: "Sim supports MCP connectivity and can turn reusable workflows into MCP tools for authorized clients."
+ - q: "Can Sim agents call other agents as tools?"
+ a: "Sim agents can call reusable workflows as tools, allowing a coordinator to delegate a bounded task and receive a structured result."
+ - q: "Can Sim be self-hosted?"
+ a: "Sim's core can be self-hosted, and self-hosted deployments can connect to local models through Ollama, vLLM, LM Studio, or LiteLLM without requiring Sim Enterprise solely for local-model access; apps/sim/ee remains subject to the separate Sim Enterprise License."
+ - q: "Is OpenAI Agents SDK good for multi-agent handoffs?"
+ a: "OpenAI Agents SDK fits OpenAI-native applications because handoffs are a first-class SDK primitive."
+ - q: "Is Google Agent Development Kit good for A2A agents?"
+ a: "Google Agent Development Kit fits code-first A2A systems because A2A interoperability is part of its documented agent-development model."
+ - q: "Can n8n orchestrate multiple AI agents?"
+ a: "n8n can orchestrate multiple AI agents by allowing a supervising agent to invoke another agent as a tool and by combining those calls with conventional workflow nodes."
+ - q: "How many agents should a multi-agent system have?"
+ a: "A multi-agent system should begin with the smallest useful structure, usually one coordinator and two or three specialists, and add agents only when a new responsibility requires distinct tools, context, permissions, or evaluation criteria."
+ - q: "How do you stop agents from handing tasks back and forth forever?"
+ a: "Teams can prevent endless handoff loops with explicit routing conditions, delegation limits, timeouts, terminal states, and escalation paths outside the agents' free-form reasoning."
---
*Last updated: October 2026*
@@ -106,6 +132,9 @@ This guide is published by Sim, the open-source AI workspace for building, deplo
- **Best framework for Microsoft development stacks: [Microsoft Agent Framework](https://learn.microsoft.com/en-us/agent-framework/overview/).**
- **Existing AutoGen systems: maintain [AutoGen](https://github.com/microsoft/autogen) only while migrating, because Microsoft has archived it and directs new users to Agent Framework.**
- **Best integration-led visual automation platform: [n8n](https://docs.n8n.io/).**
+- **Code-first development with A2A interoperability: [Google Agent Development Kit](https://github.com/google/adk-python).**
+
+A2A coordinates tasks between agents, while MCP connects agents to tools and context. The [multi-agent communication guide](https://www.sim.ai/library/can-ai-agents-talk-to-each-other-multi-agent-communication-explained) explains how these protocol roles differ.
[Try Sim](https://www.sim.ai) if you want a production multi-agent workflow that technical and nontechnical contributors can inspect, deploy, and self-host without maintaining a Python orchestration stack.
@@ -121,28 +150,28 @@ Code-first frameworks express that orchestration in application code. Visual fra
- **Author:** Andrew Caslow
- **Affiliation:** Sim
-- **Facts checked:** October 6, 2026
+- **Facts checked:** October 2026
- **Review basis:** Official vendor pricing pages, documentation, and source repositories only
- **Primary query:** Best multi-agent frameworks for production in 2026
- **Comparison scope:** Sim, LangGraph, OpenAI Agents SDK, CrewAI, Microsoft Agent Framework, AutoGen, and n8n
This article treats license accuracy, current product status, and pricing units as comparison inputs because incorrect or outdated platform facts can materially change a production decision.
-## What is the best multi-agent framework for production in 2026?
+## What are the best multi-agent frameworks for production in 2026?
-**Sim fits teams that want an integrated visual production environment, while LangGraph is the best code-first multi-agent framework for engineering teams that need granular control over state and execution.**
+**Sim fits teams that want an integrated visual production environment, while LangGraph is the best code-first multi-agent framework for engineering teams that need granular control over state and execution.** All protocol and product details in this decision table are current as of October 2026.
-| Framework or platform | Best fit |
-|---|---|
-| Sim | Visual multi-agent orchestration, deployment, approvals, and broad model choice |
-| LangGraph | Stateful, code-first agent systems |
-| Microsoft Agent Framework | New Microsoft-oriented production agent systems |
-| Microsoft Semantic Kernel | SDK-level orchestration in established Microsoft applications |
-| n8n | Agent steps within conventional business automation |
-| Google Agent Development Kit | Code-first agents around Google's ecosystem |
-| OpenAI Agents SDK | Compact applications centered on OpenAI models and tools |
-| CrewAI | Role-based multi-agent abstractions |
-| Microsoft AutoGen | Existing conversational-agent systems that need a migration plan; Microsoft has archived AutoGen |
+| Framework or platform | Best fit | A2A | MCP | Handoff or delegation | Deployment |
+|---|---|---|---|---|---|
+| Sim | Visual multi-agent orchestration, deployment, approvals, and broad model choice | [A2A integration](https://docs.sim.ai/integrations/a2a) | [Connect to MCP servers](https://docs.sim.ai/agents/mcp) and [expose workflows as MCP tools](https://docs.sim.ai/workflows/deployment/mcp) | Agents call reusable workflows as tools, with explicit workflow routing | [Sim Cloud or self-hosted](https://docs.sim.ai/platform/self-hosting) |
+| LangGraph | Stateful, code-first agent systems | [LangSmith Agent Server exposes an A2A endpoint](https://docs.langchain.com/langsmith/server-a2a) | [LangChain MCP adapter](https://docs.langchain.com/oss/python/langchain/mcp) | [Subagent and handoff patterns](https://docs.langchain.com/oss/python/langchain/multi-agent/handoffs) | [Self-managed framework](https://github.com/langchain-ai/langgraph) or [LangSmith Deployment](https://docs.langchain.com/langsmith/deployment) |
+| Microsoft Agent Framework | New Microsoft-oriented production agent systems | [A2A client and hosting support](https://learn.microsoft.com/en-us/agent-framework/journey/agent-to-agent) | [MCP server tools](https://learn.microsoft.com/en-us/agent-framework/agents/tools/local-mcp-tools) | [Built-in handoff orchestration](https://learn.microsoft.com/en-us/agent-framework/workflows/orchestrations/handoff) | [Self-managed application with Microsoft integrations](https://learn.microsoft.com/en-us/agent-framework/overview/) |
+| Microsoft Semantic Kernel | SDK-level orchestration in established Microsoft applications | Application integration | Application integration | [Agent and process abstractions](https://github.com/microsoft/semantic-kernel) | [Self-managed application](https://github.com/microsoft/semantic-kernel) |
+| OpenAI Agents SDK | Compact applications centered on OpenAI models and tools | External integration | [MCP server connectivity](https://openai.github.io/openai-agents-python/mcp/) | [First-class handoffs](https://openai.github.io/openai-agents-python/handoffs/) | [Self-managed application](https://github.com/openai/openai-agents-python) |
+| CrewAI | Role-based multi-agent abstractions | [A2A client and server delegation](https://docs.crewai.com/en/learn/a2a-agent-delegation) | [MCP servers as tools](https://docs.crewai.com/en/mcp/overview) | [Tasks and delegation between crew members](https://docs.crewai.com/en/concepts/crews) | [Self-managed framework or CrewAI AMP](https://crewai.com/pricing) |
+| Microsoft AutoGen | Existing conversational-agent systems that need a migration plan; [Microsoft has archived AutoGen](https://github.com/microsoft/autogen) | No current recommendation for a new system | Existing extension support | Conversational patterns in existing systems | [Self-managed legacy application](https://github.com/microsoft/autogen) |
+| Google Agent Development Kit | Code-first agents around Google's ecosystem | [A2A modules and remote agents](https://github.com/google/adk-python/tree/main/src/google/adk/a2a) | [MCP toolset](https://google.github.io/adk-docs/api-reference/java/com/google/adk/utils/AdditionalAdkComponentProvider.html) | Coordinator, specialist, and transfer patterns | [Self-managed application](https://github.com/google/adk-python) |
+| n8n | Agent steps within conventional business automation | Application integration | [MCP client](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp) and [MCP server trigger](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcptrigger) | [A supervising agent can call specialist agents as tools](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolaiagent) | [n8n Cloud or self-hosted](https://docs.n8n.io/hosting/) |
## How were the multi-agent frameworks evaluated for production use?
@@ -159,7 +188,7 @@ This article treats license accuracy, current product status, and pricing units
A visual workspace is not inherently better than an SDK; code-first frameworks fit better where explicit application-level control is the requirement.
-## What should teams evaluate in a production multi-agent framework?
+## What should production teams evaluate before choosing a multi-agent framework?
**The best production multi-agent framework should preserve state, make agent handoffs explicit, expose execution details, support the required deployment surfaces, and use license terms that fit the intended commercial use.** These criteria reveal how an orchestration system behaves after a prototype becomes a long-running production service.
@@ -188,35 +217,25 @@ A visual workspace is not inherently better than an SDK; code-first frameworks f
## Which framework version should a production team evaluate?
-**Production teams should evaluate the latest stable release documented by each project, record the exact package version, and pin it rather than relying on an unbounded latest tag.** As of October 6, 2026, each project still maintains an official release record, and none of the releases below is marked as a preview or prerelease.
+**Production teams should evaluate the latest stable release documented by each active project, record the exact package version, and pin it rather than relying on an unbounded latest tag.** The entries below are an October 2026 snapshot; AutoGen's final listed release is retained only for teams maintaining an existing system during migration.
| Framework or platform | Current stable release and date |
|---|---|
| Sim | [v0.9.14, October 5, 2026](https://github.com/simstudioai/sim/releases/tag/v0.9.14) |
-| LangGraph | [langgraph 1.2.14, October 6, 2026](https://github.com/langchain-ai/langgraph/releases/tag/1.2.14) |
-| Microsoft Agent Framework | [.NET 1.23.0, October 1, 2026](https://github.com/microsoft/agent-framework/releases/tag/dotnet-1.23.0); [Python 1.20.0, October 2, 2026](https://github.com/microsoft/agent-framework/releases/tag/python-1.20.0) |
+| LangGraph | [langgraph 1.2.18, October 26, 2026](https://github.com/langchain-ai/langgraph/releases/tag/1.2.18) |
+| Microsoft Agent Framework | [.NET 1.23.0, October 1, 2026](https://github.com/microsoft/agent-framework/releases/tag/dotnet-1.23.0); [Python 1.24.0, October 30, 2026](https://github.com/microsoft/agent-framework/releases/tag/python-1.24.0) |
| Microsoft Semantic Kernel | [.NET 1.80.1, September 3, 2026](https://github.com/microsoft/semantic-kernel/releases/tag/dotnet-1.80.1); [Python 1.45.0, October 6, 2026](https://github.com/microsoft/semantic-kernel/releases/tag/python-1.45.0) |
| n8n | [2.42.3 stable, October 5, 2026](https://github.com/n8n-io/n8n/releases/tag/stable) |
| Google Agent Development Kit | [Python 2.11.0, October 2, 2026](https://github.com/google/adk-python/releases/tag/v2.11.0); [JavaScript 2.2.0, September 30, 2026](https://github.com/google/adk-js/releases/tag/main-v2.2.0); Go [1.8.0, October 5, 2026](https://github.com/google/adk-go/releases/tag/v1.8.0) and [2.1.0, July 23, 2026](https://github.com/google/adk-go/releases/tag/v2.1.0) maintained release lines |
-| OpenAI Agents SDK | [Python 0.23.1, October 2, 2026](https://github.com/openai/openai-agents-python/releases/tag/v0.23.1); [JavaScript/TypeScript 0.19.0, October 5, 2026](https://github.com/openai/openai-agents-js/releases/tag/%40openai/agents%400.19.0) |
+| OpenAI Agents SDK | [Python 0.24.0, October 15, 2026](https://github.com/openai/openai-agents-python/releases); [JavaScript/TypeScript 0.19.0, October 5, 2026](https://github.com/openai/openai-agents-js/releases/tag/%40openai/agents%400.19.0) |
| CrewAI | [1.15.23, September 28, 2026](https://github.com/crewAIInc/crewAI/releases/tag/1.15.23) |
| Microsoft AutoGen | [Python 0.7.5, September 30, 2025](https://github.com/microsoft/autogen/releases/tag/python-v0.7.5) |
A production record should also capture the supported runtime, migration notes, security advisories, persistence compatibility, and whether state written by the pinned release can survive an upgrade.
-## How do the seven multi-agent frameworks compare?
-
-**Each row summarizes one framework's production fit, orchestration model, license, and commercial unit.** Row order matches the detailed profiles below and is not a score.
+## How do the multi-agent frameworks compare?
-| Framework | Best production fit | Orchestration model | License and self-hosting | Current commercial unit or status |
-| --------- | ------------------- | ------------------- | ------------------------ | -------------------------------- |
-| [Sim](https://www.sim.ai) | Mixed technical and nontechnical teams building production multi-agent workflows | Visual graph combining agents with deterministic workflow steps | [Apache 2.0 core; free open-source self-hosting is documented](https://docs.sim.ai/platform/self-hosting); [`apps/sim/ee` uses the Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) | [Hosted usage is credit-metered; paid plans also use per-user subscriptions](https://www.sim.ai/pricing) |
-| [LangGraph](https://github.com/langchain-ai/langgraph) | Python teams needing low-level stateful graph control | [Code-first graph framework](https://docs.langchain.com/oss/python/langgraph/overview) | MIT; the framework can be self-hosted | [LangSmith charges by seats, traces, compute units, and usage units](https://www.langchain.com/pricing) |
-| [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) | Developers wanting a lightweight production agent SDK | [Code-first agents, handoffs, tools, guardrails, sessions, and tracing](https://openai.github.io/openai-agents-python/) | MIT; SDK code runs in the team's chosen infrastructure | The SDK has no separate framework fee; model and infrastructure usage are separate |
-| [CrewAI](https://crewai.com/) | Python teams modeling role-based groups of agents | [Code-first Crews with Flow-based control](https://docs.crewai.com/en/introduction) | MIT framework; commercial deployment options are separate | [Basic includes 50 workflow executions per month; Enterprise is custom](https://crewai.com/pricing) |
-| [Microsoft Agent Framework](https://learn.microsoft.com/en-us/agent-framework/overview/) | Teams starting new agent systems in Microsoft's ecosystem | Code-first agent and graph-workflow framework | MIT | [Microsoft identifies it as the successor path for new AutoGen users](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-autogen/) |
-| [AutoGen](https://github.com/microsoft/autogen) | Existing AutoGen systems that need maintenance or migration | Code-first conversational and event-driven agents | [Python framework packages use MIT](https://github.com/microsoft/autogen/blob/HEAD/LICENSE-CODE) | [Archived; Microsoft directs new users to Agent Framework](https://github.com/microsoft/autogen) |
-| [n8n](https://n8n.io/) | Integration-led business automation with AI-agent steps | [Visual automation workflows and nodes](https://docs.n8n.io/workflows/) | [Sustainable Use License v1.0; source-available and not OSI-approved](https://docs.n8n.io/privacy-and-security/sustainable-use-license/) | [Cloud plans charge by complete workflow executions](https://n8n.io/pricing/) |
+**The single decision table above compares production fit, A2A, MCP, handoffs, and deployment across all nine options.** The detailed profiles below preserve the license, pricing, orchestration, and operational facts behind that summary, and row order is not a score.
## Which multi-agent framework is best for production?
@@ -226,6 +245,8 @@ A production record should also capture the supported runtime, migration notes,
## 1. Sim
+### What is Sim best for?
+
**Sim fits teams that want agent reasoning, deterministic controls, and deployment in one visual workspace.**
### Best for
@@ -270,6 +291,8 @@ According to the [Sim cost documentation](https://docs.sim.ai/platform/costs), o
## 2. LangGraph
+### What is LangGraph best for?
+
**LangGraph is the code-first option for low-level stateful graph control.**
### Best for
@@ -311,6 +334,8 @@ As of October 2026, [LangSmith Plus includes one free small serverless deploymen
## 3. OpenAI Agents SDK
+### What is OpenAI Agents SDK best for?
+
**OpenAI Agents SDK is the third detailed profile and the lightweight-SDK option in this comparison.**
### Best for
@@ -345,6 +370,8 @@ The official project [supports Python](https://openai.github.io/openai-agents-py
## 4. CrewAI
+### What is CrewAI best for?
+
**CrewAI is the fourth detailed profile and the role-based option in this comparison.**
### Best for
@@ -384,6 +411,8 @@ As of October 2026, [CrewAI does not publish a self-service mid-tier](https://cr
## 5. Microsoft Agent Framework
+### What is Microsoft Agent Framework best for?
+
**Microsoft Agent Framework is the current Microsoft-backed option for new production agent systems.**
### Best for
@@ -418,6 +447,8 @@ The framework [supports Python, .NET, and Go development](https://learn.microsof
## 6. AutoGen
+### What is AutoGen best for?
+
**AutoGen is the Microsoft comparison for existing systems and migration planning.**
### Best for
@@ -452,6 +483,8 @@ That status changes AutoGen's production fit. A team with a working AutoGen appl
## 7. n8n
+### What is n8n best for?
+
**n8n is the incumbent workflow-automation comparison for integration-led production work.**
### Best for
@@ -494,11 +527,41 @@ The free Community Edition can be self-hosted subject to [Sustainable Use Licens
## When is Microsoft Semantic Kernel the right choice?
-**Microsoft Semantic Kernel is the right choice for teams that want SDK-level agent orchestration inside established Microsoft applications.** Its [MIT-licensed SDK](https://github.com/microsoft/semantic-kernel/blob/main/LICENSE) provides model, plugin, process, and agent abstractions for .NET, Python, and Java applications. The team remains responsible for deployment, security, observability, and the complete operating environment around the SDK.
+### Best for
+
+**Microsoft Semantic Kernel fits teams that want SDK-level agent orchestration inside established Microsoft applications.** Its [MIT-licensed SDK](https://github.com/microsoft/semantic-kernel/blob/main/LICENSE) provides model, plugin, process, and agent abstractions for .NET, Python, and Java applications.
+
+### Pros
+
+Semantic Kernel fits existing Microsoft application stacks and provides SDK-level abstractions without imposing a hosted runtime.
+
+### Cons
+
+The team remains responsible for deployment, security, observability, and the complete operating environment around the SDK.
+
+### Pricing
+
+The MIT-licensed SDK has no separate framework fee. Model, infrastructure, and Microsoft cloud service costs depend on the implementation.
## When should a team choose Google Agent Development Kit?
-**Google Agent Development Kit is a strong choice for developers building code-first agent applications around Google's models, services, and deployment infrastructure.** The project provides agents, tools, sessions, and execution behavior under the [Apache 2.0 license](https://github.com/google/adk-python/blob/main/LICENSE). It is less suitable when non-developers need to inspect or modify orchestration frequently.
+### What is Google Agent Development Kit best for?
+
+### Best for
+
+**Google Agent Development Kit fits developers building code-first agent applications around Google's models, services, and deployment infrastructure.** The project provides agents, tools, sessions, and execution behavior under the [Apache 2.0 license](https://github.com/google/adk-python/blob/main/LICENSE).
+
+### Pros
+
+Google ADK provides [A2A modules and remote-agent support](https://github.com/google/adk-python/tree/main/src/google/adk/a2a) alongside MCP tooling and coordinator-specialist patterns.
+
+### Cons
+
+Google ADK is less suitable when non-developers need to inspect or modify orchestration frequently, and the application team owns its production operating environment.
+
+### Pricing
+
+The Apache 2.0 development kit has no separate framework fee. Model, hosting, and optional Google Cloud service costs are billed separately.
## Is n8n a multi-agent framework?
@@ -526,6 +589,40 @@ The free Community Edition can be self-hosted subject to [Sustainable Use Licens
[Self-hosted Sim](https://docs.sim.ai/platform/self-hosting) supports Ollama, vLLM, LM Studio, and LiteLLM without requiring Enterprise. [Workspace BYOK](https://docs.sim.ai/platform/costs#bring-your-own-key-byok) works on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise. Sim's core uses [Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), while [`apps/sim/ee` uses the separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and requires an active Enterprise subscription for production use.
+## How do I orchestrate multiple AI agents together?
+
+**Orchestrate multiple agents by giving each one a narrow responsibility, explicit inputs and outputs, and a controlled delegation path.** Start with one coordinator and only the specialists needed for distinct tools, context, permissions, or evaluation criteria.
+
+1. Define a bounded contract for each specialist, including its input, output, allowed tools, and return condition.
+2. Use workflow-as-tool calls for internal delegation, A2A for interoperable external agents, and MCP for standardized access to tools and data.
+3. Keep task state, decisions, intermediate results, and current ownership in explicit fields.
+4. Route retries, escalation, approval, and termination with deterministic conditions rather than free-form agent conversation.
+5. Trace every handoff and test malformed output, unavailable tools, timeouts, and repeated delegation.
+
+## Best platform for agents that need to hand off tasks to other agents mid workflow
+
+**Sim fits teams that need mid-workflow delegation to remain visible, bounded, and auditable.** A Sim agent can call a reusable workflow as a tool, while explicit inputs, outputs, conditions, and run records keep the delegation boundary inspectable. Code-first teams can instead use [LangGraph handoff state](https://docs.langchain.com/oss/python/langchain/multi-agent/handoffs), [OpenAI Agents SDK handoffs](https://openai.github.io/openai-agents-python/handoffs/), or [Microsoft Agent Framework handoff orchestration](https://learn.microsoft.com/en-us/agent-framework/workflows/orchestrations/handoff).
+
+A reliable handoff transfers the task, relevant state, permitted actions, and the rule for returning or terminating control. Teams should cap repeated transfers and define an escalation path outside the agents' free-form reasoning.
+
+## Top AI agent tools for coordinating specialized agents that each handle one part of a task
+
+**Coordinator-specialist orchestration fits tasks that can be split into bounded responsibilities.** Sim represents each specialist as a reusable workflow, LangGraph uses stateful graph routes, Microsoft Agent Framework provides built-in orchestration patterns, OpenAI Agents SDK provides handoffs and agents-as-tools, CrewAI uses role-based crews, Google ADK supports coordinator and remote-agent patterns, and n8n lets a [supervising agent call specialist agents as tools](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolaiagent). AutoGen should be limited to existing systems during migration because [Microsoft has archived it](https://github.com/microsoft/autogen).
+
+## Best AI agent platforms that support agent to agent communication protocols
+
+**Sim fits teams that want A2A communication, MCP tools, and visual workflow delegation in one workspace.** [Sim's A2A integration](https://docs.sim.ai/integrations/a2a) sends messages and structured data to interoperable external agents. Microsoft Agent Framework, CrewAI, Google ADK, and LangSmith Agent Server also document A2A support, while protocol support in other options depends on an external or application-level integration.
+
+Protocol support is only one production criterion. Teams should also compare authentication, authorization, task persistence, retries, tracing, versioning, and how each receiving agent's permissions are constrained.
+
+## Which AI agent tools support standardized protocols for agents talking to other agents?
+
+**Sim, Microsoft Agent Framework, CrewAI, Google Agent Development Kit, and LangSmith Agent Server document A2A interoperability.** MCP serves a different boundary: A2A coordinates tasks and status between agents, while MCP gives an agent standardized access to tools and context. A production system can use A2A between agents and MCP between each agent and its authorized tools.
+
+## Can you recommend a multi-agent system for a growing team?
+
+**Sim fits growing teams that need engineers and non-engineers to inspect the same multi-agent execution logic.** A code-first team that prioritizes explicit graph state may prefer LangGraph, a Microsoft-centered organization may prefer Microsoft Agent Framework, a role-based team may prefer CrewAI, and an automation-heavy team may prefer n8n. Teams maintaining AutoGen should plan a migration rather than select the archived framework for a new system.
+
## How should production teams design multi-agent reliability?
**Production teams should make agent boundaries, state transitions, retries, timeouts, termination conditions, and side effects explicit before adding more agents.** A reliable design identifies the owner of each decision, the durable state store, retry-safe operations, fallback behavior, approval points, loop budgets, duplicate-side-effect prevention, and safe resume behavior. Every additional handoff creates a boundary where context, schemas, costs, and responsibility can drift.
@@ -546,7 +643,7 @@ The free Community Edition can be self-hosted subject to [Sustainable Use Licens
**A team can reduce maintenance by using fewer agents, defining typed handoff contracts, isolating provider-specific code, pinning dependencies, and testing each boundary as an interface.** A new agent is justified when it creates a meaningful permission, context, specialist, or accountability boundary; deterministic workflow logic remains preferable for routing, validation, retries, and irreversible actions.
-## Which multi-agent framework should your team choose?
+## Which multi-agent framework should you choose?
**Shortlist Sim for an integrated visual production environment and LangGraph for maximum code-level control.** Choose Microsoft Agent Framework or Semantic Kernel for their respective Microsoft-oriented development models, n8n for integration-heavy automation, Google Agent Development Kit for a Google-aligned Apache 2.0 code base, OpenAI Agents SDK for a compact OpenAI-centered application, CrewAI for role-based collaboration, and Microsoft Agent Framework as the migration target for an existing AutoGen system.
@@ -584,8 +681,8 @@ Sim also fits teams for which visual collaboration and permissive self-hosting o
**This article focuses on production multi-agent frameworks.** Use these dedicated Sim Library comparisons for adjacent buyer questions instead of repeating those broader comparisons here:
-- [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026): the canonical answer for "best AI agent builder" and "best agentic workflow builder."
-- [Best AI Automation Tools in 2026](https://www.sim.ai/library/best-ai-automation-tools-2026): the canonical answer for broader AI automation-tool comparisons.
+- [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026): a focused comparison for "best AI agent builder" and "best agentic workflow builder."
+- [Best AI Automation Tools in 2026](https://www.sim.ai/library/best-ai-automation-tools-2026): a focused comparison of broader AI automation tools.
- [Best LangGraph Alternatives](https://www.sim.ai/library/langgraph-alternatives): alternatives for teams evaluating a different orchestration model.
- [Best n8n Alternatives](https://www.sim.ai/library/n8n-alternatives): alternatives for AI-agent workflow automation.
- [AI Agent Orchestration Frameworks Explained](https://www.sim.ai/library/ai-agent-orchestration-frameworks-explained): a definitional guide to orchestration frameworks.
diff --git a/apps/sim/executor/handlers/agent/memory-harness.integration.ts b/apps/sim/executor/handlers/agent/memory-harness.integration.ts
index c6fd1683db0..d03d64dcd57 100644
--- a/apps/sim/executor/handlers/agent/memory-harness.integration.ts
+++ b/apps/sim/executor/handlers/agent/memory-harness.integration.ts
@@ -83,7 +83,7 @@ import {
} from '@/lib/uploads/contexts/workspace/file-secret-provenance'
import { deleteFile, uploadFile } from '@/lib/uploads/core/storage-service'
import { insertImmutableFileMetadata } from '@/lib/uploads/server/metadata'
-import { readWorkspaceFileRecordByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key'
+import { readWorkspaceFileRecordByKey } from '@/lib/workspace-files/application/read-workspace-file-record-by-key'
import { AgentBlockHandler } from '@/executor/handlers/agent/handler'
import type { AgentInputs, Message } from '@/executor/handlers/agent/types'
import type { ExecutionContext, StreamingExecution, UserFile } from '@/executor/types'
diff --git a/apps/sim/hooks/queries/browser-import.test.tsx b/apps/sim/hooks/queries/browser-import.test.tsx
new file mode 100644
index 00000000000..ce1c1269c9d
--- /dev/null
+++ b/apps/sim/hooks/queries/browser-import.test.tsx
@@ -0,0 +1,86 @@
+/** @vitest-environment jsdom */
+
+import { act } from 'react'
+import type { BrowserPendingSignIn } from '@sim/desktop-bridge'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { usePendingSignIn, useStartSignInHandoff } from '@/hooks/queries/browser-import'
+
+/** The shell's pending sign-ins, by chat scope id. */
+let pendingByScope: Record
+let finishLaunch: (() => void) | undefined
+let shown: BrowserPendingSignIn | null | undefined
+let start: ((profileId: string) => Promise) | undefined
+
+interface PanelProps {
+ scopeId: string
+}
+
+function Panel({ scopeId }: PanelProps) {
+ shown = usePendingSignIn(scopeId, true).data
+ start = useStartSignInHandoff(scopeId).mutateAsync
+ return null
+}
+
+let root: Root | undefined
+
+describe('pending sign-in after a chat is saved mid-handoff', () => {
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ pendingByScope = {}
+ Object.assign(window, {
+ simDesktop: {
+ browserImport: {
+ getPendingSignIn: async (scopeId: string) => pendingByScope[scopeId] ?? null,
+ continueSignIn: (_profileId: string, _scopeId: string) =>
+ new Promise((resolve) => {
+ finishLaunch = () => {
+ // The provisional chat saved meanwhile; the shell moved its handoff to the saved id.
+ const pending = { site: 'github.com', browserLabel: 'Chrome' }
+ pendingByScope['chat-1'] = pending as BrowserPendingSignIn
+ resolve({ pending })
+ }
+ }),
+ bringSignInBack: vi.fn(),
+ cancelSignIn: vi.fn(),
+ },
+ },
+ })
+ })
+
+ afterEach(() => {
+ act(() => root?.unmount())
+ root = undefined
+ Reflect.deleteProperty(window, 'simDesktop')
+ })
+
+ it("shows the handoff under the saved chat's id once the launch settles", async () => {
+ const container = document.createElement('div')
+ root = createRoot(container)
+ const client = new QueryClient()
+ const render = (scopeId: string) =>
+ act(async () => {
+ root?.render(
+
+ {/* The browser panel is keyed by its scope, so a saved chat id remounts it. */}
+
+
+ )
+ })
+
+ await render('draft-1')
+ let launch: Promise | undefined
+ await act(async () => {
+ launch = start?.('profile-1')
+ })
+ await render('chat-1')
+ await vi.waitFor(() => expect(shown).toBeNull())
+
+ await act(async () => {
+ finishLaunch?.()
+ await launch
+ })
+ await vi.waitFor(() => expect(shown).toMatchObject({ site: 'github.com' }))
+ })
+})
diff --git a/apps/sim/hooks/queries/browser-import.ts b/apps/sim/hooks/queries/browser-import.ts
new file mode 100644
index 00000000000..81ffd9a1339
--- /dev/null
+++ b/apps/sim/hooks/queries/browser-import.ts
@@ -0,0 +1,103 @@
+'use client'
+
+import type { BrowserImportProfile, BrowserPendingSignIn } from '@sim/desktop-bridge'
+import { type QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { getDesktopBridge } from '@/lib/desktop'
+
+/** Local browser data read through the desktop shell, never the server. */
+const browserImportKeys = {
+ all: ['browser-import'] as const,
+ profiles: () => [...browserImportKeys.all, 'profiles'] as const,
+ pendingSignIns: () => [...browserImportKeys.all, 'pending-sign-in'] as const,
+ pendingSignIn: (scopeId: string) => [...browserImportKeys.pendingSignIns(), scopeId] as const,
+}
+
+/** Browsers are rarely installed or removed mid-session. */
+const BROWSER_IMPORT_PROFILES_STALE_TIME = 5 * 60 * 1000
+/** The shell owns a pending sign-in, and every change to it goes through the hooks below. */
+const PENDING_SIGN_IN_STALE_TIME = Number.POSITIVE_INFINITY
+
+function browserImport() {
+ return getDesktopBridge()?.browserImport
+}
+
+/** Whether this shell can continue a sign-in in another browser and bring it back. */
+export function supportsContinueSignIn(): boolean {
+ const api = browserImport()
+ return Boolean(
+ api?.continueSignIn && api.bringSignInBack && api.cancelSignIn && api.getPendingSignIn
+ )
+}
+
+/** Browser profiles the shell can import from; empty when there are none. */
+export function useBrowserImportProfiles(enabled: boolean) {
+ return useQuery({
+ queryKey: browserImportKeys.profiles(),
+ // rq-lint-allow: a preload IPC call cannot be aborted
+ queryFn: (): Promise => {
+ const listProfiles = browserImport()?.listChromeProfiles
+ if (!listProfiles) throw new Error('Browser import is not available on this device.')
+ return listProfiles()
+ },
+ enabled,
+ staleTime: BROWSER_IMPORT_PROFILES_STALE_TIME,
+ })
+}
+
+/** The chat's sign-in waiting in another browser, kept by the shell across panel remounts. */
+export function usePendingSignIn(scopeId: string, enabled: boolean) {
+ return useQuery({
+ queryKey: browserImportKeys.pendingSignIn(scopeId),
+ // rq-lint-allow: a preload IPC call cannot be aborted
+ queryFn: async (): Promise =>
+ (await browserImport()?.getPendingSignIn?.(scopeId)) ?? null,
+ enabled,
+ staleTime: PENDING_SIGN_IN_STALE_TIME,
+ })
+}
+
+/**
+ * Refreshes every chat's pending sign-in, not just the one a mutation started under:
+ * a provisional chat id that saves mid-mutation moves the handoff to the saved id.
+ */
+function invalidatePendingSignIns(queryClient: QueryClient) {
+ return queryClient.invalidateQueries({ queryKey: browserImportKeys.pendingSignIns() })
+}
+
+export function useStartSignInHandoff(scopeId: string) {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async (profileId: string) => {
+ const continueSignIn = browserImport()?.continueSignIn
+ if (!continueSignIn) return { error: 'unknown' as const }
+ return continueSignIn(profileId, scopeId)
+ },
+ onSuccess: ({ pending }) => {
+ if (pending) queryClient.setQueryData(browserImportKeys.pendingSignIn(scopeId), pending)
+ },
+ // A failed or superseded launch can still retire the chat's previous
+ // handoff, so the shell's answer replaces whatever the cache last held.
+ onSettled: () => invalidatePendingSignIns(queryClient),
+ })
+}
+
+export function useBringSignInBack(scopeId: string) {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async () => {
+ const bringSignInBack = browserImport()?.bringSignInBack
+ if (!bringSignInBack) throw new Error('Browser import is not available on this device.')
+ return bringSignInBack(scopeId)
+ },
+ onSuccess: () => invalidatePendingSignIns(queryClient),
+ })
+}
+
+export function useCancelSignInHandoff(scopeId: string) {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async () => browserImport()?.cancelSignIn?.(scopeId),
+ onSuccess: () => queryClient.setQueryData(browserImportKeys.pendingSignIn(scopeId), null),
+ onSettled: () => invalidatePendingSignIns(queryClient),
+ })
+}
diff --git a/apps/sim/hooks/queries/people.ts b/apps/sim/hooks/queries/people.ts
new file mode 100644
index 00000000000..c13bf3dc09b
--- /dev/null
+++ b/apps/sim/hooks/queries/people.ts
@@ -0,0 +1,96 @@
+import {
+ keepPreviousData,
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from '@tanstack/react-query'
+import { isApiClientError } from '@/lib/api/client/errors'
+import { requestJson } from '@/lib/api/client/request'
+import {
+ getPersonContract,
+ importPeopleContract,
+ listDepartmentsContract,
+ listPeopleContract,
+ type PersonBody,
+ updatePersonContract,
+} from '@/lib/api/contracts/people'
+
+const PEOPLE_STALE_TIME = 60 * 1000
+
+const peopleKeys = {
+ all: ['people'] as const,
+ organization: (organizationId: string) => [...peopleKeys.all, organizationId] as const,
+ list: (organizationId: string, search: string, department: string) =>
+ [...peopleKeys.organization(organizationId), 'list', search, department] as const,
+ departments: (organizationId: string) =>
+ [...peopleKeys.organization(organizationId), 'departments'] as const,
+ person: (organizationId: string, memberId: string) =>
+ [...peopleKeys.organization(organizationId), 'person', memberId] as const,
+}
+
+export function usePeople(organizationId: string, search: string, department: string) {
+ return useInfiniteQuery({
+ queryKey: peopleKeys.list(organizationId, search, department),
+ initialPageParam: undefined as string | undefined,
+ queryFn: ({ signal, pageParam }) =>
+ requestJson(listPeopleContract, {
+ params: { id: organizationId },
+ query: {
+ search: search || undefined,
+ department: department || undefined,
+ cursor: pageParam,
+ },
+ signal,
+ }),
+ getNextPageParam: (page) => page.nextCursor ?? undefined,
+ staleTime: PEOPLE_STALE_TIME,
+ placeholderData: keepPreviousData,
+ })
+}
+
+export function useDepartments(organizationId: string) {
+ return useQuery({
+ queryKey: peopleKeys.departments(organizationId),
+ queryFn: ({ signal }) =>
+ requestJson(listDepartmentsContract, { params: { id: organizationId }, signal }),
+ select: (data) => data.departments,
+ staleTime: PEOPLE_STALE_TIME,
+ })
+}
+
+export function usePerson(organizationId: string, memberId: string) {
+ return useQuery({
+ queryKey: peopleKeys.person(organizationId, memberId),
+ queryFn: ({ signal }) =>
+ requestJson(getPersonContract, { params: { id: organizationId, memberId }, signal }),
+ retry: (failures, error) => !(isApiClientError(error) && error.status === 404) && failures < 1,
+ staleTime: PEOPLE_STALE_TIME,
+ })
+}
+
+/** A profile change can move anyone's manager, reports, or department, so it refreshes them all. */
+function useInvalidatePeople(organizationId: string) {
+ const queryClient = useQueryClient()
+ return () => queryClient.invalidateQueries({ queryKey: peopleKeys.organization(organizationId) })
+}
+
+export function useUpdatePerson(organizationId: string) {
+ const invalidate = useInvalidatePeople(organizationId)
+ return useMutation({
+ mutationFn: ({ memberId, body }: { memberId: string; body: PersonBody }) =>
+ requestJson(updatePersonContract, { params: { id: organizationId, memberId }, body }),
+ onSuccess: invalidate,
+ })
+}
+
+export function useImportPeople(organizationId: string) {
+ const invalidate = useInvalidatePeople(organizationId)
+ return useMutation({
+ mutationFn: (body: { headers: string[]; rows: string[][]; apply: boolean }) =>
+ requestJson(importPeopleContract, { params: { id: organizationId }, body }),
+ onSuccess: (result) => {
+ if (result.applied) return invalidate()
+ },
+ })
+}
diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts
index 3fd234a473b..59f8aa70f1f 100644
--- a/apps/sim/hooks/queries/workspace-files.ts
+++ b/apps/sim/hooks/queries/workspace-files.ts
@@ -778,6 +778,14 @@ function fileAddressFilter(workspaceId: string, fileId: string) {
}
}
+/** Refetches every active read that can have handed out `fileId`'s storage key. */
+function reresolveFileRecord(queryClient: QueryClient, workspaceId: string, fileId: string) {
+ return queryClient.refetchQueries(
+ { ...fileAddressFilter(workspaceId, fileId), type: 'active' },
+ { cancelRefetch: true }
+ )
+}
+
/**
* Re-resolve a file's address after a read found its storage key superseded.
*
@@ -809,17 +817,25 @@ function useStaleKeyRecovery(workspaceId: string, fileId: string): (error: unkno
//
// `cancelRefetch` because a re-resolution has to OBSERVE the rotation: a record read already in
// flight was started before it, so it can only hand back the key we already know is dead.
- void Promise.resolve().then(() =>
- queryClient.refetchQueries(
- { ...fileAddressFilter(workspaceId, fileId), type: 'active' },
- { cancelRefetch: true }
- )
- )
+ void Promise.resolve().then(() => reresolveFileRecord(queryClient, workspaceId, fileId))
},
[queryClient, workspaceId, fileId]
)
}
+/**
+ * Re-resolves a file's record for a reader that loads bytes outside react-query — a media element
+ * streaming from the serve URL — the way a content query does on a 404 from a replaced key. Resolves
+ * once the record is current: if it moved, the reader re-renders at the new key's URL.
+ */
+export function useReresolveFileRecord(workspaceId: string, fileId: string): () => Promise {
+ const queryClient = useQueryClient()
+ return useCallback(
+ () => reresolveFileRecord(queryClient, workspaceId, fileId),
+ [queryClient, workspaceId, fileId]
+ )
+}
+
/**
* Fetch file content as text via a content-source URL
*/
diff --git a/apps/sim/hooks/use-before-unload-while.ts b/apps/sim/hooks/use-before-unload-while.ts
new file mode 100644
index 00000000000..dee6c9d8d25
--- /dev/null
+++ b/apps/sim/hooks/use-before-unload-while.ts
@@ -0,0 +1,19 @@
+'use client'
+
+import { useEffect } from 'react'
+
+/**
+ * Asks the browser to confirm before the page unloads while `active` is true, using the
+ * standard `beforeunload` prompt. The desktop app's update restart probes this same
+ * event, so guarded work also holds an update restart for confirmation.
+ */
+export function useBeforeUnloadWhile(active: boolean) {
+ useEffect(() => {
+ if (!active) return
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ event.preventDefault()
+ }
+ window.addEventListener('beforeunload', handleBeforeUnload)
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload)
+ }, [active])
+}
diff --git a/apps/sim/hooks/use-file-content-source.tsx b/apps/sim/hooks/use-file-content-source.tsx
index bcedf42bfd6..9dd838cc9b6 100644
--- a/apps/sim/hooks/use-file-content-source.tsx
+++ b/apps/sim/hooks/use-file-content-source.tsx
@@ -63,6 +63,12 @@ export interface ImageDimensionsSource {
*/
export interface FileContentSource {
buildUrl: (key: string, opts?: FileContentUrlOptions) => string
+ /**
+ * The URLs {@link buildUrl} returns answer byte-range requests, so a media element can play and
+ * seek a file straight from them while it downloads. Absent where the route sends only whole
+ * bodies, from which a player can stream only start to end — and Safari not at all.
+ */
+ servesByteRanges?: boolean
/**
* Map an embedded image `src` to a display URL scoped to the current context: the in-app source
* points at the workspace-scoped inline route, the public source at the token-scoped cascade route.
@@ -125,6 +131,7 @@ export function createWorkspaceFileContentSource(
`/api/workspaces/${workspaceId}/files/inline`,
{ entityType: 'workspace', entityId: workspaceId }
),
+ servesByteRanges: true,
...imageDimensions,
}
}
@@ -181,6 +188,7 @@ export function createProjectFileContentSource(
export const workspaceFileContentSource: FileContentSource = {
buildUrl: buildServeUrl,
resolveImageSrc: (src) => src,
+ servesByteRanges: true,
}
const FileContentSourceContext = createContext(workspaceFileContentSource)
diff --git a/apps/sim/lib/api/contracts/people.ts b/apps/sim/lib/api/contracts/people.ts
new file mode 100644
index 00000000000..4d0d1c88b20
--- /dev/null
+++ b/apps/sim/lib/api/contracts/people.ts
@@ -0,0 +1,130 @@
+import { z } from 'zod'
+import { defineRouteContract } from '@/lib/api/contracts'
+import { organizationIdSchema } from '@/lib/api/contracts/primitives'
+import { memberProfileFieldsSchema } from '@/lib/people/validation'
+
+/** Rows one import may carry; larger organizations import in several files. */
+export const PEOPLE_IMPORT_MAX_ROWS = 10_000
+
+const params = z.object({ id: organizationIdSchema })
+const personParams = params.extend({ memberId: z.string().min(1).max(255) })
+
+const personSummarySchema = z.object({
+ memberId: z.string(),
+ userId: z.string(),
+ name: z.string(),
+ email: z.string(),
+ image: z.string().nullable(),
+ title: z.string().nullable(),
+})
+export type PersonSummary = z.output
+
+const personSchema = personSummarySchema.extend({
+ department: z.string().nullable(),
+ location: z.string().nullable(),
+ /** `YYYY-MM-DD`. */
+ startDate: z.string().nullable(),
+ /** The manager as shown; `null` when there is none or they are suspended. */
+ manager: personSummarySchema.nullable(),
+ /** The stored manager link, kept while that manager is suspended. */
+ managerMemberId: z.string().nullable(),
+ joinedAt: z.iso.datetime(),
+})
+export type Person = z.output
+export type PersonBody = z.input
+
+export const listPeopleContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/organizations/[id]/people',
+ params,
+ query: z.object({
+ search: z.string().max(200).optional(),
+ department: z.string().max(200).optional(),
+ cursor: z.string().min(1).max(1024).optional(),
+ }),
+ response: {
+ mode: 'json',
+ schema: z.object({ people: z.array(personSchema), nextCursor: z.string().nullable() }),
+ },
+})
+
+export const listDepartmentsContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/organizations/[id]/people/departments',
+ params,
+ response: {
+ mode: 'json',
+ schema: z.object({
+ /** Largest first. */
+ departments: z.array(z.object({ name: z.string(), count: z.number().int() })),
+ }),
+ },
+})
+
+export const getPersonContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/organizations/[id]/people/[memberId]',
+ params: personParams,
+ response: {
+ mode: 'json',
+ schema: z.object({
+ person: personSchema,
+ /** Managers above this person, nearest first. */
+ chain: z.array(personSummarySchema),
+ reports: z.array(personSummarySchema),
+ }),
+ },
+})
+
+export const updatePersonContract = defineRouteContract({
+ method: 'PUT',
+ path: '/api/organizations/[id]/people/[memberId]',
+ params: personParams,
+ body: memberProfileFieldsSchema,
+ response: { mode: 'json', schema: z.object({ person: personSchema }) },
+})
+
+const peopleImportResultSchema = z.object({
+ /** Profiles the import changed, or would change on a preview. */
+ updated: z.number().int(),
+ unchanged: z.number().int(),
+ skipped: z.number().int(),
+ warnings: z.number().int(),
+ /** The first issues in file order; `skipped` and `warnings` count every one. */
+ issues: z.array(
+ z.object({
+ row: z.number().int(),
+ email: z.string().nullable(),
+ severity: z.enum(['skipped', 'warning']),
+ message: z.string(),
+ })
+ ),
+ applied: z.boolean(),
+})
+export type PeopleImportResult = z.output
+
+export const importPeopleContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/organizations/[id]/people/import',
+ params,
+ body: z
+ .object({
+ headers: z
+ .array(z.string().max(200))
+ .min(1)
+ .max(100)
+ .refine(
+ (headers) => new Set(headers).size === headers.length,
+ 'Each column must have its own name'
+ ),
+ /** Each row's cells in header order; a short row leaves its trailing cells blank. */
+ rows: z
+ .array(z.array(z.string().max(1000)).max(100))
+ .min(1, 'The file has no rows')
+ .max(PEOPLE_IMPORT_MAX_ROWS, `Import at most ${PEOPLE_IMPORT_MAX_ROWS} rows at a time`),
+ /** `false` previews the import without changing anything. */
+ apply: z.boolean(),
+ })
+ .strict(),
+ response: { mode: 'json', schema: peopleImportResultSchema },
+})
diff --git a/apps/sim/lib/api/contracts/sim-links.ts b/apps/sim/lib/api/contracts/sim-links.ts
index c7d37aef390..98de8a146ac 100644
--- a/apps/sim/lib/api/contracts/sim-links.ts
+++ b/apps/sim/lib/api/contracts/sim-links.ts
@@ -1,3 +1,7 @@
+import {
+ SIM_LINK_SUGGESTIONS_PATH,
+ simLinkSuggestionsSchema,
+} from '@sim/browser-extension-protocol'
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts'
import { organizationIdSchema } from '@/lib/api/contracts/primitives'
@@ -74,3 +78,11 @@ export const deleteSimLinkContract = defineRouteContract({
params: linkParams,
response: { mode: 'json', schema: z.object({ success: z.literal(true) }) },
})
+
+/** The browser extension's address-bar suggestions, from the organization `sim/` opens in. */
+export const suggestSimLinksContract = defineRouteContract({
+ method: 'GET',
+ path: SIM_LINK_SUGGESTIONS_PATH,
+ query: z.object({ q: z.string().max(200).default('') }),
+ response: { mode: 'json', schema: simLinkSuggestionsSchema },
+})
diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts
index 113bad59775..364b6ba9633 100644
--- a/apps/sim/lib/api/contracts/v2/files.ts
+++ b/apps/sim/lib/api/contracts/v2/files.ts
@@ -45,6 +45,7 @@ import {
import { MAX_FOLDER_PATH_BYTES, MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/paths'
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
import { MAX_TEXT_EXTRACTION_BYTES } from '@/lib/uploads/utils/file'
+import { isWorkspaceFileVfsPath } from '@/lib/vfs/path'
import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits'
import {
FILE_SEARCH_DEFAULT_MAX_RESULTS,
@@ -302,6 +303,19 @@ export const v2FileReferenceParamsSchema = z.object({
export type V2FileReferenceParams = z.output
+/** Also takes the `files/…` path `glob` and listings print, so no listing round-trip finds the id. */
+const v2FileIdOrPathParamsSchema = z.object({
+ fileId: requiredFieldSchema('File reference is required')
+ .max(MAX_FOLDER_PATH_BYTES, 'File reference is too long')
+ .refine(
+ (value) => isWorkspaceFileVfsPath(value) || workspaceFileIdSchema.safeParse(value).success,
+ 'Invalid file id or path: pass a file id or its `files//` VFS path'
+ )
+ .describe(
+ 'File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike.'
+ ),
+})
+
export const v2CreateFileBodySchema = z
.object({
workspaceId: workspaceIdSchema.describe('Workspace in which to create the file.'),
@@ -943,7 +957,7 @@ export const v2ReadFileTextContract = defineRouteContract({
export const v2GetFileContract = defineRouteContract({
method: 'GET',
path: '/api/v2/files/[fileId]/metadata',
- params: v2FileParamsSchema,
+ params: v2FileIdOrPathParamsSchema,
query: v2GetFileMetadataQuerySchema,
response: {
mode: 'json',
@@ -955,7 +969,7 @@ export const v2RenameFileContract = defineRouteContract({
method: 'PATCH',
path: '/api/v2/files/[fileId]',
query: noInputSchema,
- params: v2FileParamsSchema,
+ params: v2FileIdOrPathParamsSchema,
body: v2RenameFileBodySchema,
response: {
mode: 'json',
@@ -966,7 +980,7 @@ export const v2RenameFileContract = defineRouteContract({
export const v2DeleteFileContract = defineRouteContract({
method: 'DELETE',
path: '/api/v2/files/[fileId]',
- params: v2FileParamsSchema,
+ params: v2FileIdOrPathParamsSchema,
query: v2FileWorkspaceQuerySchema,
response: {
mode: 'json',
diff --git a/apps/sim/lib/api/server/people-presenters.ts b/apps/sim/lib/api/server/people-presenters.ts
new file mode 100644
index 00000000000..ead4a9f98df
--- /dev/null
+++ b/apps/sim/lib/api/server/people-presenters.ts
@@ -0,0 +1,6 @@
+import type { Person } from '@/lib/api/contracts/people'
+import type { PersonRecord } from '@/lib/people/repository'
+
+export function presentPerson(person: PersonRecord): Person {
+ return { ...person, joinedAt: person.joinedAt.toISOString() }
+}
diff --git a/apps/sim/lib/api/server/routes/definition.test.ts b/apps/sim/lib/api/server/routes/definition.test.ts
index 0d624df5c4a..a0a21328671 100644
--- a/apps/sim/lib/api/server/routes/definition.test.ts
+++ b/apps/sim/lib/api/server/routes/definition.test.ts
@@ -3,8 +3,10 @@ import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts'
import {
methodMatchesContract,
+ requireBinaryRouteDefinition,
requireJsonRouteDefinition,
} from '@/lib/api/server/routes/definition'
+import { defineOperation } from '@/lib/core/application'
const renameOperation = {
id: 'files.rename',
@@ -26,6 +28,26 @@ describe('declarative route definition invariants', () => {
)
).toThrow('does not match')
})
+
+ it('refuses a binary route whose first status cannot carry the body it serves', () => {
+ const contract = (status: number[]) =>
+ defineRouteContract({
+ method: 'GET',
+ path: '/icon',
+ response: { mode: 'binary', status },
+ })
+ const operation = defineOperation({
+ id: 'icons.get',
+ principalKinds: ['session'],
+ capability: 'none',
+ })
+ expect(() => requireBinaryRouteDefinition(contract([204, 200]), operation, operation)).toThrow(
+ 'carries a body first'
+ )
+ expect(
+ requireBinaryRouteDefinition(contract([200, 204]), operation, operation).successStatus
+ ).toBe(200)
+ })
})
describe('methodMatchesContract', () => {
diff --git a/apps/sim/lib/api/server/routes/definition.ts b/apps/sim/lib/api/server/routes/definition.ts
index f870dbd3280..fdcb9522a04 100644
--- a/apps/sim/lib/api/server/routes/definition.ts
+++ b/apps/sim/lib/api/server/routes/definition.ts
@@ -23,6 +23,9 @@ export function requireJsonRouteDefinition(
return declaredSuccessStatuses(contract)
}
+/** Statuses a response body can't accompany, so never a binary route's default. */
+const BODYLESS_STATUSES = new Set([204, 205])
+
export function requireBinaryRouteDefinition(
contract: AnyApiRouteContract,
declaredOperation: ApplicationOperation,
@@ -36,7 +39,13 @@ export function requireBinaryRouteDefinition(
`Route operation ${declaredOperation.id} does not match use case ${useCaseOperation.id}`
)
}
- return declaredSuccessStatuses(contract)
+ const metadata = declaredSuccessStatuses(contract)
+ if (BODYLESS_STATUSES.has(metadata.successStatus)) {
+ throw new Error(
+ `${contract.method} ${contract.path} must list a status that carries a body first; it is the status a body is served with`
+ )
+ }
+ return metadata
}
/** The contract's success statuses, 200 by default; the first is a route's default status. */
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index f3d82d9a24f..3273c3a7d86 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -49,6 +49,7 @@ import {
isSignInProviderAllowed,
} from '@/lib/auth/constants'
import { getAuthDatabase } from '@/lib/auth/database-context'
+import { refuseDisposableAccountEmail } from '@/lib/auth/disposable-email-gate'
import {
type AuthenticationSecurityAuditSnapshot,
captureAuthenticationSecurityAudit,
@@ -356,11 +357,12 @@ export const auth = betterAuth({
databaseHooks: {
user: {
create: {
- before: async (user) => {
+ before: async (user, context) => {
const accessControl = await getAccessControlConfig()
if (isEmailBlockedByAccessControl(user.email, accessControl)) {
throw new Error('Sign-ups from this email are not allowed.')
}
+ if (isHosted) await refuseDisposableAccountEmail(user.email, context?.path)
return { data: user }
},
after: async (user, context) => {
diff --git a/apps/sim/lib/auth/constants.ts b/apps/sim/lib/auth/constants.ts
index bb22d317d01..64695ce9d2d 100644
--- a/apps/sim/lib/auth/constants.ts
+++ b/apps/sim/lib/auth/constants.ts
@@ -105,3 +105,12 @@ export const SSO_REQUIRED_ERROR_CODE = 'SSO_REQUIRED'
export const SSO_REQUIRED_MESSAGE =
'Your organization requires single sign-on. Sign in through your identity provider.'
+
+/**
+ * How an account creation refused for a disposable email domain identifies itself, so the OAuth
+ * error page can name the way forward instead of telling the person to try again.
+ */
+export const DISPOSABLE_EMAIL_ERROR_CODE = 'DISPOSABLE_EMAIL'
+
+export const DISPOSABLE_EMAIL_MESSAGE =
+ 'Sign-ups from temporary email addresses aren’t supported. Use an account with a permanent email address.'
diff --git a/apps/sim/lib/auth/disposable-email-gate.test.ts b/apps/sim/lib/auth/disposable-email-gate.test.ts
new file mode 100644
index 00000000000..8f44faaca34
--- /dev/null
+++ b/apps/sim/lib/auth/disposable-email-gate.test.ts
@@ -0,0 +1,83 @@
+import { betterAuth } from 'better-auth'
+import { memoryAdapter } from 'better-auth/adapters/memory'
+import { admin } from 'better-auth/plugins'
+import { describe, expect, it } from 'vitest'
+import { DISPOSABLE_EMAIL_ERROR_CODE } from '@/lib/auth/constants'
+import { refuseDisposableAccountEmail } from '@/lib/auth/disposable-email-gate'
+import { configuredSsoPlugin } from '@/lib/auth/sso/plugin'
+
+/**
+ * A real Better Auth instance wired the way `auth.ts` wires the gate, so the endpoint path each
+ * creation path reports to `databaseHooks.user.create.before` is Better Auth's, not an assumption.
+ */
+function createAuth() {
+ return betterAuth({
+ baseURL: 'http://localhost:3000',
+ secret: 'disposable-email-gate-test-secret-0123456789',
+ database: memoryAdapter({ user: [], session: [], account: [], verification: [] }),
+ emailAndPassword: { enabled: true },
+ plugins: [admin()],
+ databaseHooks: {
+ user: {
+ create: {
+ before: async (user, context) => {
+ await refuseDisposableAccountEmail(user.email, context?.path)
+ return { data: user }
+ },
+ },
+ },
+ },
+ })
+}
+
+const password = 'correct-horse-battery-staple'
+
+describe('refuseDisposableAccountEmail', () => {
+ it('refuses a self-service email signup from a disposable domain', async () => {
+ const auth = createAuth()
+ await expect(
+ auth.api.signUpEmail({ body: { email: 'jane@mailinator.com', password, name: 'Jane' } })
+ ).rejects.toMatchObject({ body: { code: DISPOSABLE_EMAIL_ERROR_CODE } })
+ })
+
+ it('admits a self-service email signup from a company under a public suffix', async () => {
+ const auth = createAuth()
+ const result = await auth.api.signUpEmail({
+ body: { email: 'jane@company.msk.ru', password, name: 'Jane' },
+ })
+ expect(result.user.email).toBe('jane@company.msk.ru')
+ })
+
+ /** SCIM provisioning creates accounts through the admin plugin's server-side `createUser`. */
+ it('admits a directory-provisioned account whatever its domain', async () => {
+ const auth = createAuth()
+ const result = await auth.api.createUser({
+ body: { email: 'jane@mailinator.com', name: 'Jane' },
+ })
+ expect(result.user.email).toBe('jane@mailinator.com')
+ })
+
+ /**
+ * Better Auth dispatches with an endpoint's declared path, so the SSO plugin's own callback
+ * endpoints name the paths a just-in-time creation reports. No IdP is driven here.
+ */
+ const { callbackSSO, callbackSSOShared, callbackSSOSAML, acsEndpoint } =
+ configuredSsoPlugin.endpoints
+ it.each([callbackSSO.path, callbackSSOShared.path, callbackSSOSAML.path, acsEndpoint.path])(
+ 'admits any domain at the SSO plugin callback path %s',
+ async (path) => {
+ await expect(
+ refuseDisposableAccountEmail('jane@mailinator.com', path)
+ ).resolves.toBeUndefined()
+ }
+ )
+
+ it.each([undefined, '/sign-up/email', '/callback/:id', '/oauth2/callback/:providerId'])(
+ 'refuses a disposable domain created at %s',
+ async (path) => {
+ await expect(refuseDisposableAccountEmail('jane@mailinator.com', path)).rejects.toMatchObject(
+ { body: { code: DISPOSABLE_EMAIL_ERROR_CODE } }
+ )
+ }
+ )
+})
diff --git a/apps/sim/lib/auth/disposable-email-gate.ts b/apps/sim/lib/auth/disposable-email-gate.ts
new file mode 100644
index 00000000000..fa6e7fc19af
--- /dev/null
+++ b/apps/sim/lib/auth/disposable-email-gate.ts
@@ -0,0 +1,48 @@
+import { createLogger } from '@sim/logger'
+import { APIError } from 'better-auth/api'
+import { DISPOSABLE_EMAIL_ERROR_CODE, DISPOSABLE_EMAIL_MESSAGE } from '@/lib/auth/constants'
+import { isSsoCallbackPath } from '@/lib/auth/sso/callback-provider'
+import { emailDomain, isDisposableEmail } from '@/lib/messaging/email/disposable'
+
+const logger = createLogger('DisposableEmailGate')
+
+/**
+ * The admin plugin's endpoint, which SCIM provisioning calls server-side. Over HTTP it requires a
+ * platform admin's session, so either way an account created here was not self-service.
+ */
+const ADMIN_CREATE_USER_PATH = '/admin/create-user'
+
+/**
+ * Whether the account is being created on behalf of an organization: SCIM provisioning, or
+ * just-in-time creation at an SSO callback (Sim admits SSO only through a provider whose domain
+ * the organization verified). The organization vouches for the identity, so a throwaway inbox is
+ * not the risk, while a false positive breaks provisioning (a refused SCIM create is retried by
+ * the IdP indefinitely).
+ */
+function isOrganizationProvisioned(path: string | undefined): boolean {
+ return path !== undefined && (path === ADMIN_CREATE_USER_PATH || isSsoCallbackPath(path))
+}
+
+/**
+ * Refuses self-service account creation from a disposable email domain, throwing the
+ * `DISPOSABLE_EMAIL` error the signup form and OAuth error page recognize.
+ *
+ * @param path - the Better Auth endpoint the creation runs under (`context?.path` in
+ * `databaseHooks.user.create.before`), which tells organization provisioning apart from signup.
+ */
+export async function refuseDisposableAccountEmail(
+ email: string,
+ path: string | undefined
+): Promise {
+ if (isOrganizationProvisioned(path)) return
+ if (!(await isDisposableEmail(email))) return
+
+ logger.warn('Refusing account creation for a disposable email domain', {
+ domain: emailDomain(email),
+ path,
+ })
+ throw new APIError('FORBIDDEN', {
+ code: DISPOSABLE_EMAIL_ERROR_CODE,
+ message: DISPOSABLE_EMAIL_MESSAGE,
+ })
+}
diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts
index 127a9b26e0c..a76ec3243e2 100644
--- a/apps/sim/lib/billing/organizations/membership.ts
+++ b/apps/sim/lib/billing/organizations/membership.ts
@@ -61,6 +61,7 @@ import { isRetryableTransactionError } from '@/lib/db/transaction'
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
import { acquireInvitationMutationLocks } from '@/lib/invitations/locks'
import { type StoppedRun, stopLiveRunsTx } from '@/lib/mothership/async-runs/repository'
+import { enqueueMemoryCleanup } from '@/lib/mothership/memories/cleanup-outbox'
import { requireMemberManagementAuthority } from '@/lib/organizations/members/authority'
import {
revokePersonalApiKeysTx,
@@ -1192,6 +1193,13 @@ export async function transferUserBetweenOrganizations(
if (deleted.length === 0) {
throw new Error('Member could not be transferred because their role changed')
}
+ await enqueueMemoryCleanup(tx, [
+ {
+ kind: 'organization_member',
+ organizationId: params.sourceOrganizationId,
+ userId: params.userId,
+ },
+ ])
const cancelledInvitations = invitationIds.length
? await tx
@@ -1407,6 +1415,7 @@ export async function removeUserFromOrganization(
'The membership changed before removal. Refresh and try again.'
)
}
+ await enqueueMemoryCleanup(tx, [{ kind: 'organization_member', organizationId, userId }])
if (!skipBillingLogic) {
await enqueueOutboxEvent(tx, MEMBER_BILLING_RECONCILIATION_EVENT_TYPE, {
@@ -1679,6 +1688,7 @@ export async function removeExternalUserFromOrganizationWorkspaces(params: {
)
await setOrgMemberUsageLimit(organizationId, userId, null, undefined, tx)
+ await enqueueMemoryCleanup(tx, [{ kind: 'organization_member', organizationId, userId }])
const cancelledInvitations = invitationIds.length
? await tx
diff --git a/apps/sim/lib/concurrency/leader-lock.test.ts b/apps/sim/lib/concurrency/leader-lock.test.ts
index 222e523b7b1..4e278f70e5b 100644
--- a/apps/sim/lib/concurrency/leader-lock.test.ts
+++ b/apps/sim/lib/concurrency/leader-lock.test.ts
@@ -119,6 +119,53 @@ describe('withLeaderLock', () => {
}
})
+ describe('when the follower wait ends with nothing', () => {
+ const waitedOut = {
+ key: 'k',
+ pollIntervalMs: 5,
+ maxWaitMs: 20,
+ onFollower: async () => null,
+ }
+
+ it('leads once a leader that vanished leaves its lease to lapse', async () => {
+ redisConfigMockFns.mockAcquireLock.mockResolvedValueOnce(false)
+ const onLeader = vi.fn(async () => 'took-over')
+ await expect(withLeaderLock({ ...waitedOut, onLeader })).resolves.toBe('took-over')
+ expect(onLeader).toHaveBeenCalledOnce()
+ expect(redisConfigMockFns.mockAcquireLock).toHaveBeenCalledTimes(2)
+ expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledOnce()
+ })
+
+ it('waits out a dead leader’s lease by default before taking over', async () => {
+ /** A Redis `SET NX EX` lease on fake time: acquiring succeeds only once the holder's lapses. */
+ let leaseExpiresAt = 0
+ redisConfigMockFns.mockAcquireLock.mockImplementation(async (_key, _owner, ttlSec) => {
+ if (Date.now() < leaseExpiresAt) return false
+ leaseExpiresAt = Date.now() + ttlSec * 1000
+ return true
+ })
+ vi.useFakeTimers()
+ try {
+ leaseExpiresAt = Date.now() + 10_000
+ const onLeader = vi.fn(async () => 'took-over')
+ const result = withLeaderLock({ key: 'k', onLeader, onFollower: async () => null })
+ await vi.advanceTimersByTimeAsync(10_200)
+ await expect(result).resolves.toBe('took-over')
+ expect(onLeader).toHaveBeenCalledOnce()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('returns the follower result when a live holder still has the lease', async () => {
+ redisConfigMockFns.mockAcquireLock.mockResolvedValue(false)
+ const onLeader = vi.fn(async () => 'should-not-run')
+ await expect(withLeaderLock({ ...waitedOut, onLeader })).resolves.toBeNull()
+ expect(onLeader).not.toHaveBeenCalled()
+ expect(redisConfigMockFns.mockAcquireLock).toHaveBeenCalledTimes(2)
+ })
+ })
+
it('only one of N concurrent callers acquires the lock', async () => {
// Track which calls won the lock: first one returns true, rest return false.
let acquired = false
diff --git a/apps/sim/lib/concurrency/leader-lock.ts b/apps/sim/lib/concurrency/leader-lock.ts
index 567abd1c8a1..53a29203bdb 100644
--- a/apps/sim/lib/concurrency/leader-lock.ts
+++ b/apps/sim/lib/concurrency/leader-lock.ts
@@ -8,23 +8,33 @@ const logger = createLogger('LeaderLock')
const DEFAULT_TTL_SEC = 10
const DEFAULT_POLL_INTERVAL_MS = 100
-const DEFAULT_MAX_WAIT_MS = 3_000
export interface LeaderLockOptions {
key: string
ttlSec?: number
pollIntervalMs?: number
+ /**
+ * How long a follower polls before its takeover try. Defaults to the lease TTL so that try comes
+ * after a dead leader's lease has lapsed; a shorter wait can only take over a released lease.
+ */
maxWaitMs?: number
onLeader: () => Promise
onFollower: () => Promise
}
+/**
+ * Runs `onLeader` in the one caller that takes the lock for `key`; every other caller polls
+ * `onFollower` until it reports the leader's result or `maxWaitMs` passes. A follower whose wait
+ * ends with nothing tries the lock once more: a leader whose process died without releasing it
+ * leaves the lease to lapse, and that follower then leads instead of failing. Losing that try
+ * means a live holder has the lease, and the follower returns `null`.
+ */
export async function withLeaderLock(opts: LeaderLockOptions): Promise {
const {
key,
ttlSec = DEFAULT_TTL_SEC,
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
- maxWaitMs = DEFAULT_MAX_WAIT_MS,
+ maxWaitMs = ttlSec * 1000,
onLeader,
onFollower,
} = opts
@@ -42,7 +52,7 @@ export async function withLeaderLock(opts: LeaderLockOptions): Promise {
try {
return await onLeader()
} finally {
@@ -57,6 +67,8 @@ export async function withLeaderLock(opts: LeaderLockOptions): Promise(opts: LeaderLockOptions): Promise {
})
/**
- * The workspace column keeps its fail-fast: it is a property of the workspace
- * rather than of any group, so it is not the organization-configuration
- * oracle the group key would be, and three call-site suites pin the ordering.
+ * The workspace column follows membership too: the context is loaded before
+ * authorization, so refusing the column first would tell a non-member that the
+ * workspace, and the resource it named in it, exist.
*/
- it('keeps refusing the workspace personal-key column before the role lookup', async () => {
+ it('conceals the workspace before refusing the workspace personal-key column', async () => {
mocks.resolvePermission.mockResolvedValue(null)
await expect(
@@ -500,8 +500,7 @@ describe('authorizeWorkspaceOperation personal API key policy', () => {
...context,
allowPersonalApiKeys: false,
})
- ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError)
- expect(mocks.resolvePermission).not.toHaveBeenCalled()
+ ).rejects.toBeInstanceOf(NoWorkspaceAccessError)
})
})
diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts
index 67de9320502..baad11a7c49 100644
--- a/apps/sim/lib/core/application/workspace-authorization.ts
+++ b/apps/sim/lib/core/application/workspace-authorization.ts
@@ -380,33 +380,32 @@ export async function authorizeWorkspaceOperation redirects. Supports organization rollout; disabled by default.',
fallback: 'SIM_LINKS',
},
+ people: {
+ description:
+ 'Enable the organization People page: member profiles, reporting lines, and people-data import. Supports organization rollout; disabled by default.',
+ fallback: 'PEOPLE',
+ },
'mothership-model-selector': {
description:
'Show the Mothership model selector, model-specific effort levels, and Fast for supported ' +
diff --git a/apps/sim/lib/core/utils/concurrency.test.ts b/apps/sim/lib/core/utils/concurrency.test.ts
new file mode 100644
index 00000000000..ea81fdda215
--- /dev/null
+++ b/apps/sim/lib/core/utils/concurrency.test.ts
@@ -0,0 +1,50 @@
+import { flushMacrotask } from '@sim/testing/helpers/async'
+import { createDeferred } from '@sim/testing/helpers/deferred'
+import { describe, expect, it } from 'vitest'
+import { createConcurrencyLimit } from '@/lib/core/utils/concurrency'
+
+describe('createConcurrencyLimit', () => {
+ it('runs at most the limit at once and starts waiting tasks in call order', async () => {
+ const limit = createConcurrencyLimit(2)
+ const gates = Array.from({ length: 5 }, () => createDeferred())
+ const started: number[] = []
+ let active = 0
+ let peak = 0
+ const runs = gates.map((gate, index) =>
+ limit(async () => {
+ started.push(index)
+ peak = Math.max(peak, ++active)
+ await gate.promise
+ active--
+ return index
+ })
+ )
+ await flushMacrotask()
+ expect(started).toEqual([0, 1])
+ for (const index of [1, 0, 4, 3, 2]) {
+ gates[index].resolve()
+ await flushMacrotask()
+ }
+ await expect(Promise.all(runs)).resolves.toEqual([0, 1, 2, 3, 4])
+ expect(started).toEqual([0, 1, 2, 3, 4])
+ expect(peak).toBe(2)
+ })
+
+ it('releases the slot of a task that throws synchronously or rejects', async () => {
+ const limit = createConcurrencyLimit(1)
+ const error = new Error('task failed')
+ const throwsSynchronously = (): Promise => {
+ throw error
+ }
+ const thrown = limit(throwsSynchronously)
+ const rejected = limit(async () => Promise.reject(error))
+ let next: string | undefined
+ void limit(async () => 'next').then((value) => {
+ next = value
+ })
+ await expect(thrown).rejects.toBe(error)
+ await expect(rejected).rejects.toBe(error)
+ await flushMacrotask()
+ expect(next).toBe('next')
+ })
+})
diff --git a/apps/sim/lib/core/utils/concurrency.ts b/apps/sim/lib/core/utils/concurrency.ts
index 70b9e701522..9e69fd4e5ff 100644
--- a/apps/sim/lib/core/utils/concurrency.ts
+++ b/apps/sim/lib/core/utils/concurrency.ts
@@ -56,6 +56,27 @@ export function mapSettledWithConcurrency(
})
}
+/**
+ * A gate that runs at most `limit` of the tasks passed to it at once, starting the rest in call
+ * order as slots free. Bounds one phase of otherwise concurrent work, such as the part that holds
+ * database connections, without bounding the rest of it.
+ */
+export function createConcurrencyLimit(limit: number) {
+ let active = 0
+ const waiting: (() => void)[] = []
+ return async (task: () => Promise): Promise => {
+ if (active >= limit) await new Promise((resolve) => waiting.push(resolve))
+ else active++
+ try {
+ return await task()
+ } finally {
+ const next = waiting.shift()
+ if (next) next()
+ else active--
+ }
+ }
+}
+
/** Default bound for per-row object-storage materialization fan-out. */
export const MATERIALIZE_CONCURRENCY = 20
diff --git a/apps/sim/lib/execution/remote-sandbox/code-failure.test.ts b/apps/sim/lib/execution/remote-sandbox/code-failure.test.ts
index 64b59629886..97fcce0344c 100644
--- a/apps/sim/lib/execution/remote-sandbox/code-failure.test.ts
+++ b/apps/sim/lib/execution/remote-sandbox/code-failure.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
-import { processCodeFailure } from '@/lib/execution/remote-sandbox/code-failure'
+import {
+ describeProcessExit,
+ processCodeFailure,
+} from '@/lib/execution/remote-sandbox/code-failure'
/** Node 22's crash report when `fetch` loses its socket before any response bytes. */
const FETCH_SOCKET_CLOSED = `node:internal/deps/undici/undici:13510
@@ -196,6 +199,34 @@ describe('processCodeFailure', () => {
expect(error).toEqual({ name, value, traceback: stderr })
})
+ it('keeps the printed headline and adds the signal exit code beside it', () => {
+ const warning = 'Warning: You are sending unauthenticated requests to the HF Hub.'
+ const killed = processCodeFailure({ stdout: '', stderr: warning, exitCode: 137 })
+ const exited = processCodeFailure({ stdout: '', stderr: warning, exitCode: 1 })
+
+ expect(killed.error).toEqual({
+ name: 'Warning',
+ value:
+ 'You are sending unauthenticated requests to the HF Hub. [Process exited with code 137 (the exit code of SIGKILL, usually out of memory)]',
+ traceback: warning,
+ })
+ expect(exited.error).toEqual({
+ name: 'Warning',
+ value: 'You are sending unauthenticated requests to the HF Hub.',
+ traceback: warning,
+ })
+ })
+
+ it.each([
+ [1, 'Process exited with code 1'],
+ [128, 'Process exited with code 128'],
+ [129, 'Process exited with code 129 (the exit code of signal 1)'],
+ [143, 'Process exited with code 143 (the exit code of SIGTERM)'],
+ [193, 'Process exited with code 193'],
+ ])('describes exit code %i', (exitCode, description) => {
+ expect(describeProcessExit(exitCode)).toBe(description)
+ })
+
it('reads the traceback from stdout when stderr is empty', () => {
const { error } = processCodeFailure({ stdout: PYTHON_TRACEBACK, stderr: '', exitCode: 1 })
diff --git a/apps/sim/lib/execution/remote-sandbox/code-failure.ts b/apps/sim/lib/execution/remote-sandbox/code-failure.ts
index 990a4120f16..9159fb7336e 100644
--- a/apps/sim/lib/execution/remote-sandbox/code-failure.ts
+++ b/apps/sim/lib/execution/remote-sandbox/code-failure.ts
@@ -14,9 +14,37 @@ import type { SandboxCodeResult, SandboxCommandResult } from '@/lib/execution/re
const ERROR_HEADLINE =
/^(?:\[(?:[A-Za-z_$][\w.$]*)?(?:Error|Exception)|\[?[A-Za-z_$][\w.$]* \[[\w.$-]+\]|[A-Za-z_$][\w.$]*):(?:\s|$)/
+const SIGNAL_NAMES: Readonly> = {
+ 6: 'SIGABRT',
+ 9: 'SIGKILL',
+ 11: 'SIGSEGV',
+ 15: 'SIGTERM',
+}
+
+/**
+ * `Process exited with code N`, naming the signal a code above 128 stands for. A process a signal
+ * killed often printed only a warning first, so the code must be visible beside whatever it
+ * printed. The code alone cannot prove a kill (a program may exit 137 itself), so it is described
+ * as the signal's code; SIGKILL's (137) usually means the out-of-memory killer.
+ */
+export function describeProcessExit(exitCode: number): string {
+ const note = signalCodeNote(exitCode)
+ return `Process exited with code ${exitCode}${note ? ` (${note})` : ''}`
+}
+
+/** What a shell's `128 + n` exit code stands for, if the code is one. */
+function signalCodeNote(exitCode: number): string | undefined {
+ if (exitCode <= 128 || exitCode > 128 + 64) return undefined
+ const signal = exitCode - 128
+ const name = SIGNAL_NAMES[signal] ?? `signal ${signal}`
+ return `the exit code of ${name}${signal === 9 ? ', usually out of memory' : ''}`
+}
+
/**
* Converts a failed code command into a code result, naming the error from the
- * last error headline of the traceback (stderr, else stdout).
+ * last error headline of the traceback (stderr, else stdout). A signal's exit code
+ * is appended to the value, since a killed process's last printed line (often a
+ * warning) is not necessarily why it stopped.
*/
export function processCodeFailure(result: SandboxCommandResult): SandboxCodeResult {
const traceback = result.stderr || result.stdout
@@ -31,10 +59,13 @@ export function processCodeFailure(result: SandboxCommandResult): SandboxCodeRes
const separator = errorLine?.indexOf(':') ?? -1
const parsedErrorLine = errorLine ?? ''
const name = separator > 0 ? parsedErrorLine.slice(0, separator) : 'Error'
- const value =
+ const headlineValue =
separator > 0
? parsedErrorLine.slice(separator + 1).trim()
: parsedErrorLine || 'Execution failed'
+ const value = signalCodeNote(result.exitCode)
+ ? `${headlineValue} [${describeProcessExit(result.exitCode)}]`
+ : headlineValue
return {
text: '',
stdout: result.stdout,
diff --git a/apps/sim/lib/execution/remote-sandbox/conformance.test.ts b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts
index d62d6db26c5..a4f8e5bacd4 100644
--- a/apps/sim/lib/execution/remote-sandbox/conformance.test.ts
+++ b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts
@@ -1261,6 +1261,18 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => {
})
})
+ it("leads a failed command's error with its exit code and the signal it stands for", async () => {
+ const warning = 'Warning: You are sending unauthenticated requests to the HF Hub.'
+ stubShellCommand(provider, provider === 'daytona' ? warning : '', warning, 137)
+
+ const res = await executeShellInSandbox({ code: 'python train.py', envs: {}, timeoutMs: 1000 })
+
+ expect(res.error).toBe(
+ `Process exited with code 137 (the exit code of SIGKILL, usually out of memory): ${warning}`
+ )
+ expect(res.stdout).toContain(warning)
+ })
+
it('terminates shell execution when streamed process output exceeds the byte budget', async () => {
const oversized = 'x'.repeat(MAX_SANDBOX_PROCESS_OUTPUT_BYTES + 1)
if (provider === 'e2b') {
diff --git a/apps/sim/lib/execution/remote-sandbox/index.ts b/apps/sim/lib/execution/remote-sandbox/index.ts
index abf685496c1..a16a464c294 100644
--- a/apps/sim/lib/execution/remote-sandbox/index.ts
+++ b/apps/sim/lib/execution/remote-sandbox/index.ts
@@ -11,6 +11,7 @@ import {
import { recordSandboxTeardownFailure } from '@/lib/core/execution-limits/metrics'
import { buildJavaScriptRuntimeBindingsSource } from '@/lib/execution/code-placeholders/javascript-runtime'
import { SANDBOX_SYSTEM_PATH } from '@/lib/execution/remote-sandbox/cli-tools.server'
+import { describeProcessExit } from '@/lib/execution/remote-sandbox/code-failure'
import {
type CreatedSandbox,
createSandbox,
@@ -1152,8 +1153,10 @@ async function executeShellInSandboxWithinBudget(
if (result.exitCode !== 0) {
// Daytona merges both streams into stdout (stderr is always empty), so fall
// back to stdout for the real command output before the generic message.
- const errorMessage =
- result.stderr || result.stdout || `Process exited with code ${result.exitCode}`
+ const output = result.stderr || result.stdout
+ const errorMessage = output
+ ? `${describeProcessExit(result.exitCode)}: ${output}`
+ : describeProcessExit(result.exitCode)
logger.error('Sandbox shell execution error', {
sandboxId,
exitCode: result.exitCode,
diff --git a/apps/sim/lib/favicon/application.ts b/apps/sim/lib/favicon/application.ts
index c1934af7424..533a07c0a42 100644
--- a/apps/sim/lib/favicon/application.ts
+++ b/apps/sim/lib/favicon/application.ts
@@ -1,6 +1,20 @@
+import type { SessionPrincipal } from '@sim/auth/principal'
import { defineOperation } from '@/lib/core/application'
+import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter'
import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case'
-import { resolveFavicon } from '@/lib/favicon/resolve.server'
+import { cachedFavicon, type FaviconResolution, resolveFavicon } from '@/lib/favicon/resolve.server'
+
+const rateLimiter = new RateLimiter()
+
+/**
+ * Lookups that may reach the icon providers, per user; an icon already cached is served without
+ * charge. Each lookup can ask the providers about several hosts, and caches what it finds for days.
+ */
+const PROVIDER_LOOKUP_LIMIT: TokenBucketConfig = {
+ maxTokens: 120,
+ refillRate: 60,
+ refillIntervalMs: 60_000,
+}
/** A site's icon for the signed-in user, or why there is none to serve. */
export const getFavicon = defineAuthorizedCredentialUserUseCase({
@@ -10,5 +24,19 @@ export const getFavicon = defineAuthorizedCredentialUserUseCase({
principalKinds: ['session'],
capability: 'none',
}),
- execute: ({ input }: { input: { host: string } }) => resolveFavicon(input.host),
+ async execute({
+ principal,
+ input,
+ }: {
+ principal: SessionPrincipal
+ input: { host: string }
+ }): Promise {
+ const cached = await cachedFavicon(input.host)
+ if (cached) return cached
+ const { allowed } = await rateLimiter.checkRateLimitDirect(
+ `route:favicon-lookup:user:${principal.userId}`,
+ PROVIDER_LOOKUP_LIMIT
+ )
+ return allowed ? resolveFavicon(input.host) : { kind: 'unavailable' }
+ },
})
diff --git a/apps/sim/lib/favicon/hosts.test.ts b/apps/sim/lib/favicon/hosts.test.ts
index c47ced23dd2..634ca278efd 100644
--- a/apps/sim/lib/favicon/hosts.test.ts
+++ b/apps/sim/lib/favicon/hosts.test.ts
@@ -113,12 +113,18 @@ describe('faviconHostCandidates', () => {
expect(faviconHostCandidates('gitlab.gitlab.com')).toEqual(['gitlab.gitlab.com', 'gitlab.com'])
})
- it('tries at most three parents', () => {
+ it('tries at most three parents, always ending at the registrable domain', () => {
expect(faviconHostCandidates('a.b.c.d.e.example.com')).toEqual([
'a.b.c.d.e.example.com',
'b.c.d.e.example.com',
'c.d.e.example.com',
- 'd.e.example.com',
+ 'example.com',
+ ])
+ expect(faviconHostCandidates('a.b.c.d.example.co.uk')).toEqual([
+ 'a.b.c.d.example.co.uk',
+ 'b.c.d.example.co.uk',
+ 'c.d.example.co.uk',
+ 'example.co.uk',
])
})
diff --git a/apps/sim/lib/favicon/hosts.ts b/apps/sim/lib/favicon/hosts.ts
index c7b85840f29..5d9101b0a77 100644
--- a/apps/sim/lib/favicon/hosts.ts
+++ b/apps/sim/lib/favicon/hosts.ts
@@ -1,34 +1,36 @@
import { parse } from 'tldts'
import { platformSite } from '@/lib/favicon/platforms'
-const MAX_PARENT_HOSTS = 3
+/** Parents nearest the host tried before the registrable domain, which is always tried last. */
+const NEAREST_PARENT_HOSTS = 2
const HOST_SHAPE = /^[^\s/?#@\\]+$/
/**
* Hosts to ask a favicon provider about, most specific first. A private tenant such as
* `acme.atlassian.net` is invisible to providers, but its platform is not, so each parent
- * domain follows — at most three, stopping before the ICANN public suffix (`gitlab.io` is a valid
- * last step; `io` and `co.uk` are not). A host on a platform no provider can see even at the
- * parent (`x.awsapps.com`, a self-hosted `gitlab.`) ends at the platform's own site, after
- * its own host and parents so a tenant's custom icon still wins. Internal hosts — IP literals, single labels, and names outside
- * the public DNS — yield nothing, so they never leave the server, and neither does a name with
- * characters DNS can't hold, which could otherwise reshape a provider's URL.
+ * domain follows — the two nearest, then the registrable domain, which is most likely to have an
+ * icon, never the ICANN public suffix (`gitlab.io` is a valid last step; `io` and `co.uk` are
+ * not). A host on a platform no provider can see even at the parent (`x.awsapps.com`, a
+ * self-hosted `gitlab.`) ends at the platform's own site, after its own host and parents
+ * so a tenant's custom icon still wins. Internal hosts — IP literals, single labels, and names
+ * outside the public DNS — yield nothing, so they never leave the server, and neither does a name
+ * with characters DNS can't hold, which could otherwise reshape a provider's URL.
*/
export function faviconHostCandidates(input: string): string[] {
const url = HOST_SHAPE.test(input) ? URL.parse(`https://${input}`) : null
if (!url) return []
const hostname = url.hostname.replace(/\.$/, '')
if (hostname.split('.').some((label) => !label)) return []
- const { isIp, isIcann, publicSuffix } = parse(hostname, {
+ const { isIp, isIcann, publicSuffix, domain } = parse(hostname, {
allowPrivateDomains: false,
validateHostname: true,
})
- if (isIp || !isIcann || !publicSuffix || /(^|\.)arpa$/.test(publicSuffix)) return []
+ if (isIp || !isIcann || !publicSuffix || !domain || /(^|\.)arpa$/.test(publicSuffix)) return []
const labels = hostname.split('.')
- const candidateCount = labels.length - publicSuffix.split('.').length
- const candidates = labels
- .slice(0, Math.min(candidateCount, MAX_PARENT_HOSTS + 1))
+ const nearest = labels
+ .slice(0, Math.min(labels.length - publicSuffix.split('.').length, NEAREST_PARENT_HOSTS + 1))
.map((_, index) => labels.slice(index).join('.'))
+ const candidates = nearest.includes(domain) ? nearest : [...nearest, domain]
const platform = platformSite(hostname)
return platform && !candidates.includes(platform) ? [...candidates, platform] : candidates
}
diff --git a/apps/sim/lib/favicon/probe.ts b/apps/sim/lib/favicon/probe.ts
index 7df7c3c1ed6..2f2b2d94055 100644
--- a/apps/sim/lib/favicon/probe.ts
+++ b/apps/sim/lib/favicon/probe.ts
@@ -1,7 +1,10 @@
import { sniffImageContentType } from '@/lib/uploads/utils/validation'
-/** Favicons are a few kilobytes; anything far larger is not one. */
-export const MAX_ICON_BYTES = 256 * 1024
+/**
+ * Favicons are a few kilobytes; anything far larger is not one, and every icon is cached, so the
+ * cap also bounds what one lookup can store.
+ */
+export const MAX_ICON_BYTES = 64 * 1024
const ICO_MAGIC_BYTES = [0x00, 0x00, 0x01, 0x00]
diff --git a/apps/sim/lib/favicon/resolve.server.test.ts b/apps/sim/lib/favicon/resolve.server.test.ts
index cc89109539d..96e55710302 100644
--- a/apps/sim/lib/favicon/resolve.server.test.ts
+++ b/apps/sim/lib/favicon/resolve.server.test.ts
@@ -5,7 +5,8 @@ import {
import { createMockRedis } from '@sim/testing/mocks/redis.mock'
import { redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { resolveFavicon } from '@/lib/favicon/resolve.server'
+import { MAX_ICON_BYTES } from '@/lib/favicon/probe'
+import { cachedFavicon, resolveFavicon } from '@/lib/favicon/resolve.server'
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
@@ -71,4 +72,30 @@ describe('resolveFavicon', () => {
expect((await resolveFavicon('acme.example.com')).kind).toBe('hit')
expect(ttlOf('acme.example.com')).toBe(30 * DAY)
})
+
+ it('looks up an icon cached under an earlier, larger cap again instead of serving it', async () => {
+ answers = { 'acme.example.com': 'icon' }
+ const oversized = Buffer.concat([Buffer.from('h'), PNG, Buffer.alloc(MAX_ICON_BYTES)])
+ stored.set('favicon:v3:acme.example.com', { value: oversized, ttl: 30 * DAY })
+ const result = await resolveFavicon('acme.example.com')
+ expect(result.kind === 'hit' && result.bytes.length).toBe(PNG.length)
+ expect(inputValidationMockFns.mockSecureFetchWithValidation).toHaveBeenCalled()
+ })
+
+ it('reports nothing cached until a lookup has stored an answer', async () => {
+ answers = { 'acme.example.com': 'icon' }
+ expect(await cachedFavicon('acme.example.com')).toBeNull()
+ await resolveFavicon('acme.example.com')
+ expect((await cachedFavicon('acme.example.com'))?.kind).toBe('hit')
+ })
+
+ it('shares one lookup between concurrent callers of a host', async () => {
+ answers = { 'acme.example.com': 'icon' }
+ const results = await Promise.all([
+ resolveFavicon('acme.example.com'),
+ resolveFavicon('acme.example.com'),
+ ])
+ expect(results.map((result) => result.kind)).toEqual(['hit', 'hit'])
+ expect(inputValidationMockFns.mockSecureFetchWithValidation).toHaveBeenCalledTimes(1)
+ })
})
diff --git a/apps/sim/lib/favicon/resolve.server.ts b/apps/sim/lib/favicon/resolve.server.ts
index 53caf74f274..0fdc920d2b7 100644
--- a/apps/sim/lib/favicon/resolve.server.ts
+++ b/apps/sim/lib/favicon/resolve.server.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { coalesceLocally } from '@/lib/concurrency/singleflight'
import { getRedisClient } from '@/lib/core/config/redis'
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
@@ -34,13 +35,11 @@ const PROVIDERS: readonly ((host: string) => string)[] = [
(host) => `https://icons.duckduckgo.com/ip3/${host}.ico`,
]
-type FaviconResolution =
+export type FaviconResolution =
| { kind: 'hit'; contentType: string; bytes: Buffer; provisional?: true }
| { kind: 'miss' }
| { kind: 'unavailable' }
-const inFlight = new Map>()
-
const cacheKey = (host: string) => `favicon:v3:${host}`
/** A host's key holds the result of the walk that starts at that host. */
@@ -53,6 +52,8 @@ async function readCache(host: string): Promise {
if (cached.toString() === UNAVAILABLE_MARKER) return { kind: 'unavailable' }
const prefix = cached.subarray(0, 1).toString()
const bytes = cached.subarray(1)
+ // Cached under an earlier, larger cap: look it up again rather than serve it or call it a miss.
+ if (bytes.length > MAX_ICON_BYTES) return null
const result = classifyProviderResponse(200, bytes)
if (result.kind !== 'hit') return { kind: 'miss' }
return prefix === PROVISIONAL_HIT_PREFIX
@@ -150,6 +151,12 @@ async function walk(candidates: string[], signal: AbortSignal): Promise {
+ const requested = faviconHostCandidates(host)[0]
+ return requested ? readCache(requested) : { kind: 'miss' }
+}
+
/**
* The icon for a host, or its nearest parent domain's, from public icon providers — Sim never
* contacts the destination itself (a provider may, from its own servers), so a host behind a
@@ -161,10 +168,7 @@ export async function resolveFavicon(host: string): Promise {
const candidates = faviconHostCandidates(host)
const requested = candidates[0]
if (!requested) return { kind: 'miss' }
- const pending = inFlight.get(requested)
- if (pending) return pending
-
- const resolution = (async (): Promise => {
+ return coalesceLocally(`favicon:${requested}`, async (): Promise => {
const cached = await readCache(requested)
if (cached) return cached
const resolved = await runWithOutboundOrganization(null, () =>
@@ -172,7 +176,5 @@ export async function resolveFavicon(host: string): Promise {
).catch((): FaviconResolution => ({ kind: 'unavailable' }))
await writeCache(requested, resolved)
return resolved
- })().finally(() => inFlight.delete(requested))
- inFlight.set(requested, resolution)
- return resolution
+ }).catch((): FaviconResolution => ({ kind: 'unavailable' }))
}
diff --git a/apps/sim/lib/knowledge/__integration__/workspace-kb-document-access.integration.ts b/apps/sim/lib/knowledge/__integration__/workspace-kb-document-access.integration.ts
index 6b42a80d071..4033a2f9219 100644
--- a/apps/sim/lib/knowledge/__integration__/workspace-kb-document-access.integration.ts
+++ b/apps/sim/lib/knowledge/__integration__/workspace-kb-document-access.integration.ts
@@ -6,7 +6,7 @@
* nothing about what a workspace search returns. A signed-in reader's own grants widen what they
* read, and a source whose reader must be proven live admits a candidate only once the proof
* holds, with the readable rows below it filling the page when it does not. A Search-marked knowledge base
- * named explicitly by id is searched the same way.
+ * named explicitly by id is searched the same way, except that it keeps no vector candidates.
*/
import { createHash } from 'node:crypto'
import type { Principal } from '@sim/auth/principal'
@@ -48,10 +48,14 @@ afterAll(async () => {
await db.$client.end()
})
+const ALL_MODES: readonly KnowledgeSearchMode[] = ['vector', 'hybrid']
+/** A Search knowledge base's chunk writes delete its candidate rows, so only keyword ranking finds it. */
+const KEYWORD_RANKED_MODES: readonly KnowledgeSearchMode[] = ['hybrid']
+
describe.each([
- ['a workspace knowledge base', false],
- ['a dormant search index', true],
-] as const)('search of %s decides access on the document', (_label, isSearchIndex) => {
+ ['a workspace knowledge base', false, ALL_MODES],
+ ['a dormant search index', true, KEYWORD_RANKED_MODES],
+] as const)('search of %s decides access on the document', (_label, isSearchIndex, modes) => {
const ids = createKnowledgeAclFixtureIds()
const baseId = generateId()
const readable = [generateId(), generateId(), generateId()]
@@ -161,41 +165,43 @@ describe.each([
return result.rows.map((row) => row.documentId).sort()
}
- it.each(['vector', 'hybrid'] as const)(
- 'returns a signed-in reader the rows an actorless run gets (%s)',
- async (mode) => {
- const previousDebug = db.$client.options.debug
- const statements: string[] = []
- db.$client.options.debug = (_connection, query) => {
- statements.push(query)
- }
- try {
- const signedIn = await search(reader, mode)
- const workspaceKey = await search(actorless, mode)
- expect(signedIn).toEqual([...readable].sort())
- expect(workspaceKey).toEqual(signedIn)
- /** Nothing read the per-row machinery the search-index path keeps for itself. */
- for (const fragment of [
- 'embedding_keyword_search',
- 'knowledge_projection_dirty',
- '"embedding_search"."acl"',
- '"embedding_search"."connector_id"',
- ]) {
- expect(statements.filter((statement) => statement.includes(fragment))).toEqual([])
- }
- } finally {
- db.$client.options.debug = previousDebug
+ it.each(modes)('returns a signed-in reader the rows an actorless run gets (%s)', async (mode) => {
+ const previousDebug = db.$client.options.debug
+ const statements: string[] = []
+ db.$client.options.debug = (_connection, query) => {
+ statements.push(query)
+ }
+ try {
+ const signedIn = await search(reader, mode)
+ const workspaceKey = await search(actorless, mode)
+ expect(signedIn).toEqual([...readable].sort())
+ expect(workspaceKey).toEqual(signedIn)
+ /** Nothing read the per-row machinery the search-index path keeps for itself. */
+ for (const fragment of [
+ 'embedding_keyword_search',
+ 'knowledge_projection_dirty',
+ '"embedding_search"."acl"',
+ '"embedding_search"."connector_id"',
+ ]) {
+ expect(statements.filter((statement) => statement.includes(fragment))).toEqual([])
}
+ } finally {
+ db.$client.options.debug = previousDebug
}
- )
+ })
- it.each(['vector', 'hybrid'] as const)(
+ it.each(modes)(
'returns a signed-in reader the document shared with them alone as well (%s)',
async (mode) => {
const owner: Principal = { kind: 'session', userId: ids.aliceId, sessionId: 'fixture-owner' }
expect(await search(owner, mode)).toEqual([...readable, personal].sort())
}
)
+
+ it.runIf(isSearchIndex)('finds nothing by vector, since no candidate row is kept', async () => {
+ const owner: Principal = { kind: 'session', userId: ids.aliceId, sessionId: 'fixture-owner' }
+ expect(await search(owner, 'vector')).toEqual([])
+ })
})
describe('a workspace source whose reader must be proven live', () => {
diff --git a/apps/sim/lib/knowledge/search/diagnostics.ts b/apps/sim/lib/knowledge/search/diagnostics.ts
index 6b603bd3abe..64ca7f4808a 100644
--- a/apps/sim/lib/knowledge/search/diagnostics.ts
+++ b/apps/sim/lib/knowledge/search/diagnostics.ts
@@ -148,6 +148,8 @@ const FAILURE_REASONS = [
'mcp_payload_too_large',
'mcp_payload_unreadable',
'mcp_account_required',
+ /** The account never got to search: no slot freed with enough of the search deadline left. */
+ 'not_started',
] as const
export type SearchFailureReason = (typeof FAILURE_REASONS)[number]
diff --git a/apps/sim/lib/messaging/email/disposable-email-domains.json b/apps/sim/lib/messaging/email/disposable-email-domains.json
new file mode 100644
index 00000000000..d4f39b73757
--- /dev/null
+++ b/apps/sim/lib/messaging/email/disposable-email-domains.json
@@ -0,0 +1,10081 @@
+[
+ "0-mail.com",
+ "0-mailer.dynv6.net",
+ "000-webmail.myhome-server.de",
+ "000000000000000.theworkpc.com",
+ "000000000000000000.dynv6.net",
+ "000webmail.ddnss.de",
+ "000webmail.ddnss.org",
+ "000webmail.dyn-ip24.de",
+ "000webmail.dyn.ddnss.de",
+ "000webmail.dyn.home-webserver.de",
+ "000webmail.dyndns.ddnss.de",
+ "000webmail.dyndns1.de",
+ "000webmail.home-webserver.de",
+ "00jac.com",
+ "01022.hk",
+ "010530.xyz",
+ "01130.hk",
+ "020307.xyz",
+ "027168.com",
+ "045692.xyz",
+ "062e.com",
+ "0815.ru",
+ "0815.su",
+ "0845.ru",
+ "092088.xyz",
+ "0box.eu",
+ "0cd.cn",
+ "0clickemail.com",
+ "0cpub2.tech",
+ "0n0ff.net",
+ "0nelce.com",
+ "0rg.fr",
+ "0v.ro",
+ "0w.ro",
+ "0wnd.net",
+ "0wnd.org",
+ "0x207.info",
+ "1-8.biz",
+ "1-mintue-mail.ddnsgeek.com",
+ "1-second-mail.site",
+ "1-tm.com",
+ "10-minute-mail.com",
+ "1000acres.co.uk",
+ "1000rebates.stream",
+ "100likers.com",
+ "102.io.vn",
+ "103.ink",
+ "105kg.ru",
+ "10dk.email",
+ "10inbox.online",
+ "10mail.com",
+ "10mail.org",
+ "10mail.tk",
+ "10mail.xyz",
+ "10min.email",
+ "10minemail.com",
+ "10minmail.de",
+ "10minmail.org",
+ "10minut.com.pl",
+ "10minut.xyz",
+ "10minute.email",
+ "10minutemail.be",
+ "10minutemail.cf",
+ "10minutemail.co.uk",
+ "10minutemail.co.za",
+ "10minutemail.com",
+ "10minutemail.de",
+ "10minutemail.ga",
+ "10minutemail.gq",
+ "10minutemail.ml",
+ "10minutemail.net",
+ "10minutemail.nl",
+ "10minutemail.pro",
+ "10minutemail.us",
+ "10minutemailbox.com",
+ "10minutemails.in",
+ "10minutenemail.de",
+ "10minutenmail.xyz",
+ "10minutesmail.com",
+ "10minutesmail.fr",
+ "10minutmail.pl",
+ "10x9.com",
+ "1111111111111.dynv6.net",
+ "11163.com",
+ "11166666.xyz",
+ "114514279.xyz",
+ "115yu.cc.cd",
+ "123-m.com",
+ "12312312312.cfd",
+ "123312.cc.cd",
+ "1234211.xyz",
+ "123456789.life",
+ "123clone.com",
+ "123mails.org",
+ "12bclass.us",
+ "12friday.space",
+ "12hosting.net",
+ "12houremail.com",
+ "12minutemail.com",
+ "12minutemail.net",
+ "12storage.com",
+ "130723.xyz",
+ "13282298.xyz",
+ "1337.care",
+ "13512863687.store",
+ "140unichars.com",
+ "142code.me",
+ "147.cl",
+ "14club.org.uk",
+ "14n.co.uk",
+ "151668.xyz",
+ "15553.eu.cc",
+ "15555.eu.cc",
+ "15557.eu.cc",
+ "15963.fr.nf",
+ "15qm.com",
+ "164109038.xyz",
+ "16816889.xyz",
+ "179297.xyz",
+ "17tb.io.vn",
+ "1820mail.vip",
+ "189.email",
+ "18lover.com",
+ "18t5b.shop",
+ "191117636.cfd",
+ "191mariobet.com",
+ "199411.store",
+ "1blackmoon.com",
+ "1ce.us",
+ "1chuan.com",
+ "1clck2.com",
+ "1fsdfdsfsdf.tk",
+ "1mail.ml",
+ "1nom.org",
+ "1pad.de",
+ "1pice.io.vn",
+ "1s.fr",
+ "1sec.site",
+ "1secmail.com",
+ "1secmail.net",
+ "1secmail.org",
+ "1secmail.space",
+ "1secmail.website",
+ "1st-forms.com",
+ "1sworld.com",
+ "1t-mail.com",
+ "1to1mail.org",
+ "1trick.net",
+ "1usemail.com",
+ "1webmail.info",
+ "1win.life",
+ "1xp.fr",
+ "1zhuan.com",
+ "2-b.icu",
+ "200192.indevs.in",
+ "20030310.com",
+ "2003066.xyz",
+ "2012-2016.ru",
+ "20253025.xyz",
+ "20260501.ccwu.cc",
+ "20260503.ccwu.cc",
+ "202608221.xyz",
+ "2048unblocked.com",
+ "20email.eu",
+ "20email.it",
+ "20mail.eu",
+ "20mail.in",
+ "20mail.it",
+ "20minutemail.com",
+ "20minutemail.it",
+ "20minutesmail.com",
+ "20mm.eu",
+ "2120001.net",
+ "216222.xyz",
+ "21mail.top",
+ "2200freefonts.com",
+ "22345377.xyz",
+ "2333.live",
+ "247airconrepair.xyz",
+ "247chats.com",
+ "247coolcare.xyz",
+ "247fixit.xyz",
+ "247hvacservice.xyz",
+ "247web.net",
+ "24faw.com",
+ "24hinbox.com",
+ "24hourmail.com",
+ "24hourmail.net",
+ "24mail.top",
+ "24x7fixit.xyz",
+ "24x7hvac.xyz",
+ "25kcreditveo3.io.vn",
+ "25kg2gveo3.io.vn",
+ "25kmans.io.vn",
+ "25kmembers.io.vn",
+ "25kseller.io.vn",
+ "25kselll.io.vn",
+ "25kveo.io.vn",
+ "25kveo3.io.vn",
+ "25kvideo.io.vn",
+ "25u.com",
+ "264341.xyz",
+ "269966.xyz",
+ "2932919455.dynv6.net",
+ "2anom.com",
+ "2chmail.net",
+ "2ether.net",
+ "2fdgdfgdfgdf.tk",
+ "2likemobile.site",
+ "2likemobile.tech",
+ "2likemobile.top",
+ "2likemobile.xyz",
+ "2mail.eu.cc",
+ "2odem.com",
+ "2prong.com",
+ "2wc.info",
+ "300book.info",
+ "30mail.ir",
+ "30minutemail.com",
+ "30wave.com",
+ "3202.com",
+ "32132.ccwu.cc",
+ "326645.indevs.in",
+ "326646.indevs.in",
+ "326646302.indevs.in",
+ "3333366.xyz",
+ "3435637.xyz",
+ "365boxmail.com",
+ "36duongtau.live",
+ "36ru.com",
+ "37xiao.com",
+ "38yz.help",
+ "39393.eu.cc",
+ "39393.me",
+ "3a88.dev",
+ "3bbb.cc.cd",
+ "3d-painting.com",
+ "3dkai.com",
+ "3fdn.com",
+ "3jf.cn",
+ "3l6.com",
+ "3littlemiracles.com",
+ "3mail.ga",
+ "3trtretgfrfe.tk",
+ "3xu.studio",
+ "4-n.us",
+ "401010.xyz",
+ "4057.com",
+ "415dacjder.qzz.io",
+ "418.dk",
+ "42o.org",
+ "42web.io",
+ "45kg2gveo3.io.vn",
+ "45kselller.io.vn",
+ "45kveo.io.vn",
+ "45kveo3.io.vn",
+ "48dz.com",
+ "48hr.email",
+ "497000.xyz",
+ "499713376.xyz",
+ "4bbb.cc.cd",
+ "4bus.io.vn",
+ "4gfdsgfdgfd.tk",
+ "4k5.net",
+ "4mail.cf",
+ "4mail.ga",
+ "4nextmail.com",
+ "4nmv.ru",
+ "4pu.com",
+ "4save.net",
+ "4tb.host",
+ "4warding.com",
+ "4warding.net",
+ "4warding.org",
+ "50set.ru",
+ "51baidu.tech",
+ "51mail.shop",
+ "51www.us",
+ "521883.xyz",
+ "522885.xyz",
+ "52mails.com",
+ "55hosting.net",
+ "5ghgfhfghfgh.tk",
+ "5gramos.com",
+ "5ixmonth.xyz",
+ "5july.org",
+ "5mail.cf",
+ "5mail.ga",
+ "5minutemail.net",
+ "5oz.ru",
+ "5semail.com",
+ "5tb.in",
+ "5x25.com",
+ "5xu.vn",
+ "5y.loseyourip.com",
+ "5yaochu.top",
+ "5ymail.com",
+ "60minutemail.com",
+ "628868.xyz",
+ "636364.xyz",
+ "66-77.cc",
+ "662602718.xyz",
+ "668fmail.com",
+ "66b1it.eu.org",
+ "672643.net",
+ "675hosting.com",
+ "675hosting.net",
+ "675hosting.org",
+ "6789.io.vn",
+ "67899vip.com",
+ "681mail.com",
+ "689345.xyz",
+ "6974.click",
+ "69flix.site",
+ "6gdfg.zone.id",
+ "6hjgjhgkilkj.tk",
+ "6ip.us",
+ "6mail.cf",
+ "6mail.ga",
+ "6mail.ml",
+ "6n9.net",
+ "6paq.com",
+ "6somok.ru",
+ "6th.pp.ua",
+ "6url.com",
+ "6ys.tech",
+ "709183.freeddns.org",
+ "719182.accesscam.org",
+ "729406.xyz",
+ "7362354.xyz",
+ "75hosting.com",
+ "75hosting.net",
+ "75hosting.org",
+ "778891.icu",
+ "78.edu.kg",
+ "78.school",
+ "78511939.xyz",
+ "792792.xyz",
+ "794613.accesscam.org",
+ "7days-printing.com",
+ "7mail.ga",
+ "7mail.ml",
+ "7novels.com",
+ "7tags.com",
+ "7xusq8vbim5d47.icu",
+ "80665.com",
+ "80fight.cn",
+ "8127ep.com",
+ "816qs.com",
+ "83.io.vn",
+ "862808.xyz",
+ "875888.xyz",
+ "88cloud.cc",
+ "8d7kh.com",
+ "8mail.cf",
+ "8mail.ga",
+ "8mail.ml",
+ "911666.xyz",
+ "912684.abrdns.com",
+ "91tanhua.top",
+ "920325.xyz",
+ "931123.xyz",
+ "94587293.xyz",
+ "94an.com",
+ "950410.xyz",
+ "957jp.cc",
+ "971007.xyz",
+ "981027.xyz",
+ "98104.cn",
+ "99.com",
+ "992551.xyz",
+ "99cows.com",
+ "99experts.com",
+ "99mail.us",
+ "9mail.cf",
+ "9me.site",
+ "9mot.ru",
+ "9ox.net",
+ "9q.ro",
+ "a--i.top",
+ "a-bc.net",
+ "a-germandu.de",
+ "a45.in",
+ "a7996.com",
+ "aa5zy64.com",
+ "aaago.cyou",
+ "aabkmail.com",
+ "aachendate.de",
+ "aaconservation.org",
+ "aahzz.com",
+ "aakingcompany.shop",
+ "aakk.link",
+ "aakkmail.com",
+ "aaoo.me",
+ "aaqwe.ru",
+ "aaqwe.store",
+ "aard.org.uk",
+ "aasdarq.eu.cc",
+ "aasdasd.eu.cc",
+ "abacuswe.us",
+ "abaib.bond",
+ "abajb.bond",
+ "abakiss.com",
+ "abalb.bond",
+ "abamb.bond",
+ "abanb.bond",
+ "abapb.bond",
+ "abasb.bond",
+ "abatido.com",
+ "abaub.bond",
+ "abaxb.bond",
+ "abayb.bond",
+ "abazb.bond",
+ "abc179312.shop",
+ "abcmail.email",
+ "aberllefenni.co.uk",
+ "abevw.com",
+ "abilityaccesslegal.org",
+ "abilitywe.us",
+ "abo-free.fr.nf",
+ "aboodbab.com",
+ "abouse.space",
+ "abovewe.us",
+ "abowned.com",
+ "absolutewe.us",
+ "abundantwe.us",
+ "abusemail.de",
+ "abuser.eu",
+ "abusultann.com",
+ "abusultansmarty.co.uk",
+ "abusultanvip.com",
+ "abusultanvipo2.co.uk",
+ "abyis.com",
+ "abyssmail.com",
+ "ac-malin.fr.nf",
+ "ac20mail.in",
+ "academiccommunity.com",
+ "academywe.us",
+ "acaic.bond",
+ "acalbarsha.xyz",
+ "acanok.com",
+ "acc1s.com",
+ "acc1s.net",
+ "accare.xyz",
+ "accclone.com",
+ "acceleratewe.us",
+ "accentwe.us",
+ "acceptwe.us",
+ "accheckup.xyz",
+ "accis.net",
+ "acclaimwe.us",
+ "accora.io.vn",
+ "accordwe.us",
+ "accountscenter.support",
+ "accreditedwe.us",
+ "acdag.indevs.in",
+ "acdesk.xyz",
+ "acdoctors.xyz",
+ "acedby.com",
+ "acexpertsdxb.xyz",
+ "acg-news.com",
+ "acgdb.eu.org",
+ "acgn.click",
+ "acgnnews.com",
+ "acherouae.xyz",
+ "achievementwe.us",
+ "achievewe.us",
+ "acidservices.xyz",
+ "acmudon.xyz",
+ "acornwe.us",
+ "acprosdxb.xyz",
+ "acqq.dev",
+ "acreadingroom.co.uk",
+ "acrepairuae.xyz",
+ "acrossgracealley.com",
+ "acrylicwe.us",
+ "acservicesdxb.xyz",
+ "acservicesuae.xyz",
+ "acserviceuae.xyz",
+ "actarus.infos.st",
+ "activatewe.us",
+ "activitywe.us",
+ "actuneupuae.xyz",
+ "acucre.com",
+ "acuitywe.us",
+ "acumenwe.us",
+ "aczabeel.xyz",
+ "aczonedubai.xyz",
+ "aczonedxb.xyz",
+ "aczoneuae.xyz",
+ "ad-managment.run.place",
+ "adaid.bond",
+ "adaptivewe.us",
+ "adaptwe.us",
+ "adarsa.me",
+ "adawd.bond",
+ "add3000.pp.ua",
+ "addadsasd.icu",
+ "addictingtrailers.com",
+ "addmails.com",
+ "addrin.uk",
+ "adeptwe.us",
+ "adfskj.com",
+ "adios.email",
+ "adiq.eu",
+ "aditus.info",
+ "admiralwe.us",
+ "adn3t.com",
+ "ado888.biz",
+ "adobeccepdm.com",
+ "adoniswe.us",
+ "ados.cc.cd",
+ "adpugh.org",
+ "adresse.infos.st",
+ "adroh.com",
+ "ads24h.top",
+ "adsd.org",
+ "adsensekorea.com",
+ "adsjii.xyz",
+ "adubiz.info",
+ "adult-work.info",
+ "adv-dep-eng.jo3.org",
+ "advantagewe.us",
+ "advantageyoungpeople.com",
+ "advantimo.com",
+ "adventurewe.us",
+ "adventwe.us",
+ "advisorwe.us",
+ "advitise.com",
+ "advocatewe.us",
+ "adwaterandstir.com",
+ "aeace.bond",
+ "aeale.bond",
+ "aeame.bond",
+ "aeape.bond",
+ "aeare.bond",
+ "aeaue.bond",
+ "aeaxe.bond",
+ "aebz.makeup",
+ "aecmedya.com",
+ "aeerso.space",
+ "aegde.com",
+ "aegia.net",
+ "aegiscorp.net",
+ "aegiswe.us",
+ "aelo.es",
+ "aeonpsi.com",
+ "aerionx25k.io.vn",
+ "aeronyx.cfd",
+ "aesel.me",
+ "aetheron.cfd",
+ "afalf.bond",
+ "afarek.com",
+ "afarf.bond",
+ "afeeyah.store",
+ "affecting.org",
+ "affiliate-nebenjob.info",
+ "affiliatedwe.us",
+ "affilikingz.de",
+ "affinitywe.us",
+ "affluentwe.us",
+ "affordablewe.us",
+ "afgpuk.co.uk",
+ "afia.pro",
+ "afrobacon.com",
+ "afse-gh.top",
+ "aftercorporation.com",
+ "afterhourswe.us",
+ "afternea.sbs",
+ "afw.fr.nf",
+ "ag77brc.ddnsfree.com",
+ "agabg.bond",
+ "agaexpress.com",
+ "agallagher.id",
+ "agedmail.com",
+ "agencysq.store",
+ "agendawe.us",
+ "agenpremium.store",
+ "agentloop.sbs",
+ "agger.ro",
+ "agilewe.us",
+ "agiuse.com",
+ "agmbowie.com",
+ "agorawe.us",
+ "agri4life.io.vn",
+ "agtx.net",
+ "agylzuv.top",
+ "ahavaexperience.com",
+ "aheadwe.us",
+ "ahem.email",
+ "ahk.jp",
+ "ahmadfamily.net",
+ "ahmadizhar.net",
+ "ahmedkhlef.com",
+ "ahvin.com",
+ "ai100999.com",
+ "aiaccount.click",
+ "aiapes.publicvm.com",
+ "aibase.bond",
+ "aiclit.asia",
+ "aidesign.work.gd",
+ "aidesigner.2bd.net",
+ "aifmusic.top",
+ "aifreeuk.com",
+ "aifreeuk.info",
+ "aifreeuk.net",
+ "aifreeuk.store",
+ "aigcdog.com",
+ "aihubtools.space",
+ "aihuohua.online",
+ "aikunkun.com",
+ "ailicke.com",
+ "aineapi.com",
+ "aiphotoeditor.io",
+ "aiphotoenhancer.me",
+ "air2token.com",
+ "airconakoya.xyz",
+ "airconalquoz.xyz",
+ "airconcare.xyz",
+ "airconcitywalk.xyz",
+ "airconcleaningdubai.xyz",
+ "airconcompanyuae.xyz",
+ "airconcontractingdxb.xyz",
+ "airconcontractinguae.xyz",
+ "airconcontractors.xyz",
+ "airconcrew.xyz",
+ "airconcrewdubai.xyz",
+ "aircondcare.xyz",
+ "aircondcleaning.xyz",
+ "aircondeskdubai.xyz",
+ "aircondeskuae.xyz",
+ "aircondmedic.xyz",
+ "aircondtuneup.xyz",
+ "aircondworks.xyz",
+ "airconemaar.xyz",
+ "airconexpert.xyz",
+ "airconfestivalcity.xyz",
+ "airconfixdxb.xyz",
+ "airconinstallersuae.xyz",
+ "airconlabuae.xyz",
+ "airconmaintenancedubai.xyz",
+ "airconmaintenanceuae.xyz",
+ "airconmiddleeast.xyz",
+ "airconmudon.xyz",
+ "airconnadalsheba.xyz",
+ "airconprodubai.xyz",
+ "airconpros.xyz",
+ "airconprosuae.xyz",
+ "airconservicedxb.xyz",
+ "airconservicesuae.xyz",
+ "airconserviceuae.xyz",
+ "aircontechdxb.xyz",
+ "aircontechuae.xyz",
+ "airconzone.xyz",
+ "airconzonedxb.xyz",
+ "aircoolmedic.xyz",
+ "aircourriel.com",
+ "airhemp.com",
+ "airmailbox.website",
+ "airsworld.net",
+ "airychen.com",
+ "aisub.store",
+ "aisubpro.click",
+ "aisubstore.click",
+ "aisupply.click",
+ "aiuu.tech",
+ "aiworldx.com",
+ "aixnv.com",
+ "aizhongzhuan.cc",
+ "ajaxapp.net",
+ "ajinimoto.me",
+ "ak02fca.loseyourip.com",
+ "ak6lay.shop",
+ "akapost.com",
+ "akbip.com",
+ "akerd.com",
+ "akfoin.us.ci",
+ "akgq701.com",
+ "akhl.autos",
+ "akirapowered.com",
+ "akjaey.space",
+ "akmail.in",
+ "akolay.tech",
+ "akugu.com",
+ "akunlama.com",
+ "akunlemah.biz.id",
+ "al-qaeda.us",
+ "al1ya.app",
+ "alarafoundation.com",
+ "albarshamaintenance.com",
+ "albill.com",
+ "albionwe.us",
+ "alchemywe.us",
+ "aleh.de",
+ "alfaceti.com",
+ "alfurjanmaintenance.com",
+ "alhsalumni.org",
+ "aliaswe.us",
+ "aliban.org",
+ "alienware13.com",
+ "aligamel.com",
+ "alightmotion.cloud",
+ "alightmotion.id",
+ "alightmotion.top",
+ "alightmotion.website",
+ "aligroup.uk",
+ "alilot.com",
+ "alin.cc.cd",
+ "alin.eu.cc",
+ "alina-schiesser.ch",
+ "alisaol.com",
+ "alisongamel.com",
+ "alisree.com",
+ "alivance.com",
+ "alivewe.us",
+ "alkhawaneejmaintenance.com",
+ "all-cats.ru",
+ "allaccesswe.us",
+ "allamericanwe.us",
+ "allaroundwe.us",
+ "alldayaccare.xyz",
+ "alldirectbuy.com",
+ "allegiancewe.us",
+ "allegrowe.us",
+ "allemojikeyboard.com",
+ "allez.io.vn",
+ "allfreemail.net",
+ "allgoodwe.us",
+ "alliancewe.us",
+ "allinaircond.xyz",
+ "allinamcmaintenance.xyz",
+ "allinhvactech.xyz",
+ "allinonewe.us",
+ "allofthem.net",
+ "alloutwe.us",
+ "allowed.org",
+ "alloywe.us",
+ "allprowe.us",
+ "allreview4u.com",
+ "allseasonswe.us",
+ "allstarwe.us",
+ "allthegoodnamesaretaken.org",
+ "allumhall.co.uk",
+ "allurewe.us",
+ "allwebemails.com",
+ "almatips.com",
+ "almiswelfare.org",
+ "almondwe.us",
+ "alosp.com",
+ "alph.wtf",
+ "alpha-web.net",
+ "alphaairconfix.xyz",
+ "alphaamcmaintenance.xyz",
+ "alphacooltech.xyz",
+ "alphafancoil.xyz",
+ "alphaomegawe.us",
+ "alphatronic25k.io.vn",
+ "alphax.fr.nf",
+ "alpinewe.us",
+ "alreval.com",
+ "altairwe.us",
+ "altitudewe.us",
+ "altmails.com",
+ "altrans.fr.nf",
+ "altuswe.us",
+ "aluimport.com",
+ "alvaxio.com",
+ "alves.fr.nf",
+ "alwayss.uno",
+ "ama-trade.de",
+ "ama-trans.de",
+ "amadeuswe.us",
+ "amail.club",
+ "amail.com",
+ "amail1.com",
+ "amail4.me",
+ "amazinggift.life",
+ "amazingly.online",
+ "amazon-aws.org",
+ "amberheadline.buzz",
+ "amberwe.us",
+ "ambiancewe.us",
+ "ambitiouswe.us",
+ "amccare.xyz",
+ "amccompanydubai.xyz",
+ "amccompanyuae.xyz",
+ "amccontractingdxb.xyz",
+ "amccrewdxb.xyz",
+ "amcexpertsuae.xyz",
+ "amcfixitdubai.xyz",
+ "amcfixuae.xyz",
+ "amcguysdxb.xyz",
+ "amchandymanuae.xyz",
+ "amchubdubai.xyz",
+ "amchubdxb.xyz",
+ "amcjlt.xyz",
+ "amclabuae.xyz",
+ "amcmaintdubai.xyz",
+ "amcmaintenancedxb.xyz",
+ "amcmaintenancemarina.xyz",
+ "amcmandxb.xyz",
+ "amcpointtower.xyz",
+ "amcservice.xyz",
+ "amcshielddubai.xyz",
+ "amcteam.xyz",
+ "amctilal.xyz",
+ "amcvictory.xyz",
+ "amcworkstower.xyz",
+ "amcworksvilla.xyz",
+ "ameady.com",
+ "ameeppoossaa.dedyn.io",
+ "amelabs.com",
+ "americanawe.us",
+ "americancivichub.com",
+ "americasbestwe.us",
+ "americaswe.us",
+ "amex409.monster",
+ "amicuswe.us",
+ "amilegit.com",
+ "aminating.com",
+ "aminavin.com",
+ "amiri.net",
+ "amiriindustries.com",
+ "amiyah.cloud",
+ "amiyiy.xyz",
+ "amplewe.us",
+ "amplifiedwe.us",
+ "amplifywe.us",
+ "ampsylike.com",
+ "anajakstore.site",
+ "analogwe.us",
+ "analysiswe.us",
+ "analyticalwe.us",
+ "analyticswe.us",
+ "analyticwe.us",
+ "anappfor.com",
+ "anappthat.com",
+ "anarac.com",
+ "anbinh.io.vn",
+ "andalanglobal.app",
+ "andreihusanu.ro",
+ "andrewclaude.eu",
+ "andrewclaude.fr",
+ "andrewclaude.org",
+ "andrewclaude.store",
+ "andrewcluh.top",
+ "androl.cfd",
+ "andthen.us",
+ "angiiidayyy.click",
+ "anhiqojkaq1.abrdns.com",
+ "anhvip9999.com",
+ "anie.io.vn",
+ "animesos.com",
+ "aniross.com",
+ "anit.ro",
+ "ankiclub.space",
+ "ann.qzz.io",
+ "annd.us",
+ "annnekkk.com",
+ "ano-mail.net",
+ "ano2.co.uk",
+ "anogz.com",
+ "anon-mail.de",
+ "anonbox.net",
+ "anonmail.top",
+ "anonmails.de",
+ "anonymail.dk",
+ "anonymbox.com",
+ "anonymized.org",
+ "anonymousness.com",
+ "anotherdomaincyka.tk",
+ "anquandx.com",
+ "ansibleemail.com",
+ "anthony-junkmail.com",
+ "anti.io.vn",
+ "antireg.com",
+ "antireg.ru",
+ "antispam.de",
+ "antispam.fr.nf",
+ "antispam24.de",
+ "antispammail.de",
+ "antttool.us",
+ "anversa.com.co",
+ "anvjobvnu.top",
+ "any.pink",
+ "anyalias.com",
+ "anypng.com",
+ "anytimecoolrepair.xyz",
+ "anytimerepairs.xyz",
+ "anywebmail.com",
+ "aobl.hair",
+ "aoeuhtns.com",
+ "aohl.io.vn",
+ "aokkad.com",
+ "aokkap.my.id",
+ "aokkas.com",
+ "aoyh.hair",
+ "ap535fu.ddnsfree.com",
+ "ape-tech.run.place",
+ "apedesigns.jo3.org",
+ "apedesigns.linkpc.net",
+ "apes-ai.linkpc.net",
+ "apex.io.vn",
+ "apexnovaser.com",
+ "apexone.top",
+ "apexrepair24x7.xyz",
+ "apexrepairs.xyz",
+ "apexthermo.xyz",
+ "apfelkorps.de",
+ "aphlog.com",
+ "apimi.icu",
+ "apkmd.com",
+ "apocaw.com",
+ "appc.se",
+ "appinventor.nl",
+ "appixie.com",
+ "apple.edu.pl",
+ "appmail.uk",
+ "appremiums.pro",
+ "apps.dj",
+ "appsdjheru.site",
+ "appzily.com",
+ "aprinastore.web.id",
+ "aprte.com",
+ "aqilakomputer.com",
+ "aqim.skin",
+ "aquaguide.ru",
+ "ar26710.cc.cd",
+ "arabianranchesmaintenance.com",
+ "arapgege.app",
+ "arapps.me",
+ "arcadein.com",
+ "arcadium.lat",
+ "arcentra.cfd",
+ "arcticairconrepair.xyz",
+ "arcticcoolair.xyz",
+ "arcticcoolcare.xyz",
+ "arcticfreon.xyz",
+ "ardsp.shop",
+ "arduino.hk",
+ "arenahiveai.com",
+ "ares.edu.pl",
+ "ares.io.vn",
+ "ariaz.jetzt",
+ "arkoo.site",
+ "armyspy.com",
+ "aron.us",
+ "arrabyte.com",
+ "arrb.autos",
+ "arroisijewellery.com",
+ "art-en-ligne.pro",
+ "art2mart.com",
+ "arthiq.life",
+ "artleoncommunications.com",
+ "artman-conception.com",
+ "artsdigi.store",
+ "artsdigital.store",
+ "arur01.tk",
+ "arurgitu.gq",
+ "arvato-community.de",
+ "arxxwalls.com",
+ "asajv2ray.site",
+ "asaption.com",
+ "aschenbrandt.net",
+ "asciibinder.net",
+ "asd903102.online",
+ "asdascas.dpdns.org",
+ "asdasd.nl",
+ "asdasd.ru",
+ "asdasdasd.cfd",
+ "asdcxijkj.eu.cc",
+ "asedeeqaweds.dedyn.io",
+ "asgzs.top",
+ "ashgroveventures.net",
+ "ashleyandrew.com",
+ "ashnkarsh.life",
+ "asia-hq.jo3.org",
+ "asiafootball.org.uk",
+ "asimarif.com",
+ "asistx.net",
+ "ask-mail.com",
+ "asleepity.com",
+ "asorent.com",
+ "aspireviastudios.org",
+ "ass.pp.ua",
+ "assetactech.xyz",
+ "assetaircon.xyz",
+ "assetairconcare.xyz",
+ "assetaircond.xyz",
+ "assetaircool.xyz",
+ "assethvacrepair.xyz",
+ "assetmaintenance.xyz",
+ "assetscaredubai.xyz",
+ "assetsdesk.xyz",
+ "assurmail.net",
+ "astonut.tk",
+ "astroempires.info",
+ "asu.mx",
+ "asu.su",
+ "asurad.com",
+ "at.hm",
+ "at0mik.org",
+ "atelierdelune.shop",
+ "atkshop.shop",
+ "atkshopp.space",
+ "atlas4381.me",
+ "atlasimple.com",
+ "atlasstitch.cfd",
+ "atmemail.top",
+ "atnet.top",
+ "atnetcloud.space",
+ "atnextmail.com",
+ "atomauto.vn",
+ "atombomb.online",
+ "attnetwork.com",
+ "atxc.space",
+ "au1688x.us",
+ "aubady.com",
+ "augmentationtechnology.com",
+ "aumails.us",
+ "auralith.cfd",
+ "aureliandata.space",
+ "ausgefallen.info",
+ "auth2fa.com",
+ "auti.st",
+ "autofixmax.com",
+ "automisly.org",
+ "automizelymail.info",
+ "automizly.net",
+ "autommo.net",
+ "autorobotica.com",
+ "autosouvenir39.ru",
+ "autotwollow.com",
+ "autowb.com",
+ "autoxugiare.com",
+ "autre.fr.nf",
+ "autu11n.lol",
+ "available-home.com",
+ "avalentra.cfd",
+ "averdov.com",
+ "avia-tonic.fr",
+ "avifcove.me",
+ "avls.pt",
+ "avq69cb.loseyourip.com",
+ "avtolev.com",
+ "awa.pics",
+ "awatum.de",
+ "awdrt.org",
+ "awesome47.com",
+ "awesomesaucemail.org",
+ "awgarstone.com",
+ "awiki.org",
+ "awoiiwowma11001.indevs.in",
+ "aws-mail-2.dynv6.net",
+ "aws-mail-free-1017.dynv6.net",
+ "aws-mail-free-102.dynv6.net",
+ "aws-mail-free-1056.dynv6.net",
+ "aws-mail-free-1304.dynv6.net",
+ "aws-mail-free-1705.dynv6.net",
+ "aws-mail-free-1842.dynv6.net",
+ "aws-mail-free-1884.dynv6.net",
+ "aws-mail-free-2156.dynv6.net",
+ "aws-mail-free-2236.dynv6.net",
+ "aws-mail-free-2290.dynv6.net",
+ "aws-mail-free-2383.dynv6.net",
+ "aws-mail-free-2409.dynv6.net",
+ "aws-mail-free-2454.dynv6.net",
+ "aws-mail-free-2524.dynv6.net",
+ "aws-mail-free-2704.dynv6.net",
+ "aws-mail-free-2737.dynv6.net",
+ "aws-mail-free-2775.dynv6.net",
+ "aws-mail-free-2795.dynv6.net",
+ "aws-mail-free-2845.dynv6.net",
+ "aws-mail-free-2892.dynv6.net",
+ "aws-mail-free-2906.dynv6.net",
+ "aws-mail-free-2923.dynv6.net",
+ "aws-mail-free-3038.dynv6.net",
+ "aws-mail-free-3335.dynv6.net",
+ "aws-mail-free-3381.dynv6.net",
+ "aws-mail-free-3620.dynv6.net",
+ "aws-mail-free-3711.dynv6.net",
+ "aws-mail-free-3796.dynv6.net",
+ "aws-mail-free-385.dynv6.net",
+ "aws-mail-free-4014.dynv6.net",
+ "aws-mail-free-4017.dynv6.net",
+ "aws-mail-free-4131.dynv6.net",
+ "aws-mail-free-4206.dynv6.net",
+ "aws-mail-free-4257.dynv6.net",
+ "aws-mail-free-4289.dynv6.net",
+ "aws-mail-free-4531.dynv6.net",
+ "aws-mail-free-4550.dynv6.net",
+ "aws-mail-free-4573.dynv6.net",
+ "aws-mail-free-4711.dynv6.net",
+ "aws-mail-free-4726.dynv6.net",
+ "aws-mail-free-4761.dynv6.net",
+ "aws-mail-free-4816.dynv6.net",
+ "aws-mail-free-4885.dynv6.net",
+ "aws-mail-free-4920.dynv6.net",
+ "aws-mail-free-493.dynv6.net",
+ "aws-mail-free-4937.dynv6.net",
+ "aws-mail-free-5119.dynv6.net",
+ "aws-mail-free-518.dynv6.net",
+ "aws-mail-free-5241.dynv6.net",
+ "aws-mail-free-5380.dynv6.net",
+ "aws-mail-free-5392.dynv6.net",
+ "aws-mail-free-5537.dynv6.net",
+ "aws-mail-free-5670.dynv6.net",
+ "aws-mail-free-5781.dynv6.net",
+ "aws-mail-free-5786.dynv6.net",
+ "aws-mail-free-5969.dynv6.net",
+ "aws-mail-free-6032.dynv6.net",
+ "aws-mail-free-6033.dynv6.net",
+ "aws-mail-free-6080.dynv6.net",
+ "aws-mail-free-6120.dynv6.net",
+ "aws-mail-free-6253.dynv6.net",
+ "aws-mail-free-6255.dynv6.net",
+ "aws-mail-free-6306.dynv6.net",
+ "aws-mail-free-6398.dynv6.net",
+ "aws-mail-free-6552.dynv6.net",
+ "aws-mail-free-659.dynv6.net",
+ "aws-mail-free-6604.dynv6.net",
+ "aws-mail-free-6690.dynv6.net",
+ "aws-mail-free-6691.dynv6.net",
+ "aws-mail-free-6762.dynv6.net",
+ "aws-mail-free-6826.dynv6.net",
+ "aws-mail-free-7145.dynv6.net",
+ "aws-mail-free-7149.dynv6.net",
+ "aws-mail-free-7153.dynv6.net",
+ "aws-mail-free-7194.dynv6.net",
+ "aws-mail-free-7358.dynv6.net",
+ "aws-mail-free-7477.dynv6.net",
+ "aws-mail-free-764.dynv6.net",
+ "aws-mail-free-7654.dynv6.net",
+ "aws-mail-free-7838.dynv6.net",
+ "aws-mail-free-7849.dynv6.net",
+ "aws-mail-free-788.dynv6.net",
+ "aws-mail-free-7897.dynv6.net",
+ "aws-mail-free-8085.dynv6.net",
+ "aws-mail-free-8091.dynv6.net",
+ "aws-mail-free-8205.dynv6.net",
+ "aws-mail-free-8225.dynv6.net",
+ "aws-mail-free-8467.dynv6.net",
+ "aws-mail-free-8475.dynv6.net",
+ "aws-mail-free-8741.dynv6.net",
+ "aws-mail-free-9015.dynv6.net",
+ "aws-mail-free-916.dynv6.net",
+ "aws-mail-free-9161.dynv6.net",
+ "aws-mail-free-9283.dynv6.net",
+ "aws-mail-free-9409.dynv6.net",
+ "aws-mail-free-9533.dynv6.net",
+ "aws-mail-free-9596.dynv6.net",
+ "aws-mail-free-9697.dynv6.net",
+ "aws-mail-free-9703.dynv6.net",
+ "aws-mail-free-9789.dynv6.net",
+ "aws-mail.accesscam.org",
+ "aws-mailer.dynv6.net",
+ "awsoo.com",
+ "ax05.cn",
+ "axax.asia",
+ "axgrad.io",
+ "axin05.icu",
+ "axionintellex.shop",
+ "axiz.org",
+ "axon7zte.com",
+ "axrx.hair",
+ "axsup.net",
+ "ayag.cloud",
+ "ayakamail.cf",
+ "ayumas.store",
+ "az18nep.camdvr.org",
+ "azame.pw",
+ "azazazatashkent.tk",
+ "azcomputerworks.com",
+ "azeqsd.fr.nf",
+ "azly.autos",
+ "azmeil.tk",
+ "azulejoslowcost.es",
+ "azuretechtalk.net",
+ "azurien.cfd",
+ "azzancoffee.com",
+ "b0h748o.kozow.com",
+ "b1of96u.com",
+ "b1tvjn3.loseyourip.com",
+ "b20nn6.cn",
+ "b2bx.net",
+ "b2cmail.de",
+ "b3j72kr.theworkpc.com",
+ "b7s.ru",
+ "babyeat.food",
+ "babyfun.fun",
+ "back67283.eu.cc",
+ "back7654.eu.cc",
+ "backilnge.com",
+ "backyr.com",
+ "bacteroidmail.com",
+ "badgerland.eu",
+ "badoop.com",
+ "badopsec.lol",
+ "badpotato.tk",
+ "baechecker.com",
+ "bahlill.cfd",
+ "baicai1145.online",
+ "baicai1145.shop",
+ "baidoxe.com",
+ "baileybridge.org",
+ "baipiaodashen.cloud-ip.cc",
+ "baiyucraft.sbs",
+ "baizoto.shop",
+ "bakalos.dpdns.org",
+ "bakultelo.com",
+ "balaket.com",
+ "balawo.com",
+ "banana.linkpc.net",
+ "bananacomet.lol",
+ "bananaspace.shop",
+ "banancaocap.com",
+ "banclone.pro",
+ "bandtoo.com",
+ "bangban.uk",
+ "bangdieukhien.net",
+ "banhang14.com",
+ "banit.club",
+ "banit.me",
+ "bank-opros1.ru",
+ "bankinnepal.com",
+ "banri.xyz",
+ "baqx.autos",
+ "bar-ton.store",
+ "bareed.ws",
+ "barnrestaurant.pro",
+ "barooko.com",
+ "barry.dpdns.org",
+ "barryogorman.com",
+ "bartdevos.be",
+ "baselogicroute.com",
+ "basepathgrid.com",
+ "baserda.net",
+ "bashyrcn.yachts",
+ "basketrise.com",
+ "basscode.org",
+ "batdongsanhatinh.org",
+ "bathdf.co.uk",
+ "batiinsaat.net",
+ "bauwerke-online.com",
+ "baybabes.com",
+ "baybumutan.shop",
+ "bazaaboom.com",
+ "bb28.dev",
+ "bbbbyyzz.info",
+ "bbfz.makeup",
+ "bbhost.us",
+ "bbitf.com",
+ "bbitj.com",
+ "bbitq.com",
+ "bbmail.store",
+ "bbmailku.com",
+ "bboys.fr.nf",
+ "bbroot.com",
+ "bbwg.one",
+ "bbxb.fashion",
+ "bbyb.fashion",
+ "bcaoo.com",
+ "bcast.ws",
+ "bcb.ro",
+ "bccto.me",
+ "bcm.edu.pl",
+ "bcmail.pro",
+ "bcnet.store",
+ "bcnetlite.xyz",
+ "bcooq.com",
+ "bda.io.vn",
+ "bdgstr.com",
+ "bdm.ovh",
+ "bdmuzic.pw",
+ "bdo.io.vn",
+ "bdq8.pw",
+ "bdshar.com",
+ "beaconmessenger.com",
+ "bearsarefuzzy.com",
+ "beastmail.space",
+ "beatbok.online",
+ "beautifulblessingsbeyondborders.org",
+ "bebed.cyou",
+ "bebel.cyou",
+ "beben.cyou",
+ "bebez.cyou",
+ "beddly.com",
+ "beefmilk.com",
+ "beelsil.com",
+ "beganment.com",
+ "begemail.com",
+ "begfax1.loseyourip.com",
+ "behatifoundation.org",
+ "bekasi.me",
+ "belamail.org",
+ "belfieldcorp.net",
+ "belgianairways.com",
+ "belicapcut.app",
+ "belljonestax.com",
+ "beluckygame.com",
+ "benipaula.org",
+ "benphim.com",
+ "benphim.net",
+ "benshelf.com",
+ "bepureme.com",
+ "beribase.ru",
+ "beribaza.ru",
+ "berirabotay.ru",
+ "berkahfb.com",
+ "berkahjayashope.com",
+ "berwie.com",
+ "bespokehaircare.co.uk",
+ "best-hosting.biz",
+ "best-john-boats.com",
+ "best-temp-mail.com",
+ "best-vpn.xyz",
+ "bestacmaintenance.xyz",
+ "bestchoiceusedcar.com",
+ "besthvacpro.xyz",
+ "bestlistbase.com",
+ "bestoption25.club",
+ "bestparadize.com",
+ "bestsoundeffects.com",
+ "besttempmail.com",
+ "bestwashae.xyz",
+ "beta.edu.pl",
+ "betacinder.com",
+ "betatensor.com",
+ "betr.co",
+ "betshubs.com",
+ "betteryoutime.com",
+ "beyondreasonabledoubt.co.uk",
+ "bflcafe.com",
+ "bgtmail.com",
+ "bgx.ro",
+ "bgyiacczy.top",
+ "bhana.xyz",
+ "bhap.me",
+ "bheps.com",
+ "bhuxp.org",
+ "bidourlnks.com",
+ "big1.us",
+ "bigmail.lol",
+ "bigprofessor.so",
+ "bigstring.com",
+ "bigwhoop.co.za",
+ "bij.pl",
+ "bilingva-ca.info",
+ "bilingva-ca.store",
+ "bin-ich.com",
+ "binafex.com",
+ "binancepools.cloud",
+ "binbx.net",
+ "binech.com",
+ "binich.com",
+ "binka.me",
+ "binkmail.com",
+ "binnary.com",
+ "binsh.kro.kr",
+ "bio-muesli.info",
+ "bio-muesli.net",
+ "biojuris.com",
+ "bione.co",
+ "bipochub.com",
+ "birh.hair",
+ "birminghamlife.org.uk",
+ "bishwanathuk.cc",
+ "bitmah.com",
+ "bitmens.com",
+ "bitproy.com",
+ "bitrealm.in",
+ "bittnex.com",
+ "bitwhites.top",
+ "bitymails.us",
+ "biubiuwholeworld.cn",
+ "biweia615.icu",
+ "biyug.com",
+ "biz.st",
+ "bizimalem-support.de",
+ "bizisstance.com",
+ "bj41.help",
+ "bjorwi.rest",
+ "bjvc.top",
+ "blackgoldagency.ru",
+ "blackmarket.to",
+ "bladesmail.net",
+ "blakeagency.buzz",
+ "blenched.com",
+ "blendlog.com",
+ "blenro.com",
+ "bletilla.cc",
+ "bletsoetownclosecharity.bio",
+ "blinklaunch.click",
+ "blip.ch",
+ "blip.ovh",
+ "blnkt.net",
+ "blobapps.com",
+ "block-this-for-gambling.cloud-ip.cc",
+ "block521.com",
+ "blockare.site",
+ "blog3.cc.cd",
+ "blogger.nyc.mn",
+ "blogmyway.org",
+ "blogos.net",
+ "blogspam.ro",
+ "blogtricks.de",
+ "blondemorkin.com",
+ "blondmail.com",
+ "blpz.hair",
+ "bltiwd.com",
+ "blueacmaintenance.xyz",
+ "blueaircond.xyz",
+ "bluecooling.xyz",
+ "bluedumpling.info",
+ "bluegrassluck.com",
+ "bluehourclub.cyou",
+ "bluewaters1.com",
+ "bluewaters2.com",
+ "bluewerks.com",
+ "blushthicket.beauty",
+ "bmoar.com",
+ "bncinema.com",
+ "bnote.com",
+ "bnrlner.shop",
+ "bnyhr.us",
+ "boatmail.us",
+ "bobgf.ru",
+ "bobgf.store",
+ "bobmail.info",
+ "bobmurchison.com",
+ "bocas.tech",
+ "boerakemail.com",
+ "bofthew.com",
+ "bokgumail.kr",
+ "bokkor1.cyou",
+ "bomnet.net",
+ "bondmeadow.bond",
+ "bonobo.email",
+ "boofx.com",
+ "bookgame.org",
+ "bookthemmore.com",
+ "boom.publicvm.com",
+ "boom114.ccwu.cc",
+ "boommail.online",
+ "boostme.es",
+ "bootssl.com",
+ "bootybay.de",
+ "boranora.com",
+ "bored.lol",
+ "borged.com",
+ "borged.net",
+ "borged.org",
+ "bosakun.com",
+ "bosakun.online",
+ "boscharena.dev",
+ "boshi-gdh.club",
+ "bot.cd",
+ "bot.nu",
+ "botasky.eu.cc",
+ "botgetlink.com",
+ "botgetlink.net",
+ "botnet.my.id",
+ "boukshilf.com",
+ "boulkfout.lol",
+ "boun.cr",
+ "bouncr.com",
+ "bowlfuel.com",
+ "box-mail.ru",
+ "box-mail.store",
+ "boxem.ru",
+ "boxem.store",
+ "boxfi.uk",
+ "boxformail.in",
+ "boximail.com",
+ "boxlet.ru",
+ "boxlet.store",
+ "boxmach.com",
+ "boxmail.lol",
+ "boxmail1.com",
+ "boxomail.live",
+ "boxtemp.com.br",
+ "boyaga.com",
+ "bpotogo.com",
+ "bptfp.net",
+ "bradingtowntrust.co.uk",
+ "brand-app.biz",
+ "brandallday.net",
+ "brashbeat.online",
+ "brasx.org",
+ "brazbuzii.online",
+ "breaite.com",
+ "breakthru.com",
+ "breezeamcfix.xyz",
+ "breezehvacmaintenance.xyz",
+ "breezybundle.shop",
+ "brefmail.com",
+ "brennendesreich.de",
+ "brevionix.cfd",
+ "brevitron.cfd",
+ "bridgetonmainstreet.org",
+ "briefalpha.org",
+ "briggsmarcus.com",
+ "brightaircontech.xyz",
+ "brightaircool.xyz",
+ "brightamctech.xyz",
+ "brightbasketry.shop",
+ "brightcoupon.bond",
+ "brightfuturescharity.org.uk",
+ "brightpeaklet.top",
+ "bristolstatehousefoundation.org",
+ "brivian.link",
+ "broadbandninja.com",
+ "brodipek.xyz",
+ "brokenion.com",
+ "brothershaircut.com",
+ "brownjasen.ccwu.cc",
+ "browzensew.ca",
+ "browzensew.com",
+ "browzensew.icu",
+ "browzensew.info",
+ "browzensw.com",
+ "browzensw.info",
+ "browzensw.net",
+ "browzensw.store",
+ "bsfh.beer",
+ "bskyx.fun",
+ "bsnow.net",
+ "bspamfree.org",
+ "bspooky.com",
+ "bst-72.com",
+ "btb-notes.com",
+ "btc.email",
+ "btcmail.pw",
+ "btcmod.com",
+ "btizet.pl",
+ "btsblock.com",
+ "btthyto.org",
+ "buccalmassage.ru",
+ "budaya-tionghoa.com",
+ "budayationghoa.com",
+ "buddyfly.top",
+ "budgetac.xyz",
+ "budgetcoolcare.xyz",
+ "budgetfreon.xyz",
+ "buffemail.com",
+ "bugfoo.com",
+ "bugmenever.com",
+ "bugmenot.com",
+ "buhf95r.ddnsfree.com",
+ "buildingamcdubai.xyz",
+ "buildingmaintdubai.xyz",
+ "buildingrepair.xyz",
+ "buildingsmaint.xyz",
+ "buildingstechdubai.xyz",
+ "bukanimers.com",
+ "bukatv8.com",
+ "bukhariansiddur.com",
+ "bulapolo.site",
+ "bullbaby.com",
+ "bulrushpress.com",
+ "bultoc.com",
+ "bum.net",
+ "bumail.site",
+ "bumpymail.com",
+ "bunchofidiots.com",
+ "bund.us",
+ "bundes-li.ga",
+ "bunmail.one",
+ "bunnyhub.space",
+ "bunsenhoneydew.com",
+ "burangir.com",
+ "burnermail.bond",
+ "burnermail.cyou",
+ "burnthespam.info",
+ "burry.eu.org",
+ "burstmail.info",
+ "busd-mmjd3.wiki",
+ "business-degree.live",
+ "businessbackend.com",
+ "businesssuccessislifesuccess.com",
+ "buspad.org",
+ "bussinesa.app",
+ "bussitussi.com",
+ "busyaz.tech",
+ "butteiumien.io.vn",
+ "buy-blog.com",
+ "buyapp.foo",
+ "buymoreplays.com",
+ "buyordie.info",
+ "buyreview.us",
+ "buyusdomain.com",
+ "buyusedlibrarybooks.org",
+ "buzzcluby.com",
+ "buzzcut.ws",
+ "bwm.de5.net",
+ "bwmyga.com",
+ "bx52ff4a.shop",
+ "byagu.com",
+ "byconf.com",
+ "byebyemail.com",
+ "byespm.com",
+ "byiepsxqu.top",
+ "byjkprh.top",
+ "byom.de",
+ "bytedigi.com",
+ "bytonf.com",
+ "bznrehk.giize.com",
+ "bzpc.makeup",
+ "bzvrxf8.ooguy.com",
+ "c-eric.fr.nf",
+ "c-newstv.ru",
+ "c-pkk.icu",
+ "c-tta.top",
+ "c14.mobi",
+ "c1ph3r.xyz",
+ "c28.mobi",
+ "c4.fr",
+ "c4pro.uk",
+ "c51vsgq.com",
+ "caainpt.com",
+ "cabiste.fr.nf",
+ "cachedot.net",
+ "cade.org.uk",
+ "cafesui.com",
+ "caftee.com",
+ "caigq.qzz.io",
+ "calderstone.net",
+ "caldrexa.cfd",
+ "calendro.fr.nf",
+ "california.edu.pl",
+ "californiafitnessdeals.com",
+ "calima.asso.st",
+ "calirona.com",
+ "calmdoorway.homes",
+ "calmriver.info",
+ "calvarystreetisa.org",
+ "cam4you.cc",
+ "cameltok.com",
+ "camera47.net",
+ "cameraity.com",
+ "camping-grill.info",
+ "cancer-treatment.xyz",
+ "candassociates.com",
+ "candymail.de",
+ "cane.pw",
+ "canfga.org",
+ "cangcud.com",
+ "cangcutku.pro",
+ "cantozil.com",
+ "canvaspace.shop",
+ "canvect.com",
+ "caowo.online",
+ "capct.id",
+ "capcud.shop",
+ "capcut.digital",
+ "capcut.sbs",
+ "capcut.space",
+ "capcut02.shop",
+ "capcutku.io",
+ "capcutmeflo.shop",
+ "capcutmm.shop",
+ "capcutpro.click",
+ "capcutpro.dev",
+ "capcvt.es",
+ "capitalistdilemma.com",
+ "caprasolver.xyz",
+ "caps7.com",
+ "captainacmaintenance.xyz",
+ "captus.cyou",
+ "capy.gay",
+ "caqcut.top",
+ "car101.pro",
+ "carbtc.net",
+ "care-breath.com",
+ "caregradeguard.com",
+ "careporto.xyz",
+ "caresportshub.xyz",
+ "carpin.org",
+ "cars2.club",
+ "carsencyclopedia.com",
+ "cartelera.org",
+ "caseedu.tk",
+ "cashflow35.co",
+ "cashflow35.com",
+ "casterview.xyz",
+ "casualdx.com",
+ "catfie.app",
+ "catgroup.uk",
+ "catsredeem.com",
+ "catterickgarrisonfootballcentre.uk",
+ "cauterize.top",
+ "cavi.mx",
+ "caye.org.uk",
+ "cazlg.com",
+ "cbair.com",
+ "cbasosbr.top",
+ "cbdol.mx",
+ "cbes.net",
+ "cboc.hair",
+ "cbsbada.com",
+ "cbty.ru",
+ "cbty.store",
+ "cbvl.co.uk",
+ "cc17.name.ng",
+ "cc18.name.ng",
+ "cc3.name.ng",
+ "cc6.name.ng",
+ "cc8c1bd6.abrdns.com",
+ "ccategoryk.com",
+ "ccimran.buzz",
+ "cckuy.com",
+ "ccm8klz.camdvr.org",
+ "ccmail.uk",
+ "ccqu.top",
+ "ccsuncare.com",
+ "cctc.fashion",
+ "cctoolz.com",
+ "ccwc.fashion",
+ "ccxiao.cn",
+ "cczc.fashion",
+ "cdfaq.com",
+ "cdlpvt3.mywire.org",
+ "cdnmia.com",
+ "cdnripple.com",
+ "cdpa.cc",
+ "cdxugmy.top",
+ "cecizie.web.id",
+ "cedarledger.bond",
+ "cedarwhisper.homes",
+ "ceed.se",
+ "cek.pm",
+ "cellurl.com",
+ "cengrop.com",
+ "centermail.com",
+ "centermail.net",
+ "certexx.fr.nf",
+ "certificenter.com",
+ "certve.com",
+ "cetpass.com",
+ "cfo2go.ro",
+ "cg0gcab.mywire.org",
+ "cggup.com",
+ "ch.ma",
+ "chacuo.net",
+ "chahcyrans.com",
+ "chaichuang.com",
+ "chaineor.com",
+ "chalupaurybnicku.cz",
+ "chamconnho.com",
+ "chammy.info",
+ "chanceforfuture.org",
+ "chansd.com",
+ "chantellegribbon.com",
+ "chaocosen.com",
+ "chapsmail.com",
+ "charityrcao.org.uk",
+ "charl.us",
+ "chasefreedomactivate.com",
+ "chatclaude.top",
+ "chatgptmail.shop",
+ "chatgptuk.pp.ua",
+ "chatgtporg.eu.cc",
+ "chatich.com",
+ "chatworkstation.com",
+ "chaublog.com",
+ "chauhuy.click",
+ "chauhuy.io.vn",
+ "chauhuy.online",
+ "chauhuy.site",
+ "chautuyen.click",
+ "chautuyen.io.vn",
+ "chautuyen.online",
+ "cheaphub.net",
+ "cheapmori.store",
+ "cheatmail.de",
+ "chefalicious.com",
+ "chelsworth.net",
+ "cheminsdevie.ink",
+ "chenbot.email",
+ "chenbufan.top",
+ "chessgameland.com",
+ "chessgamingworld.com",
+ "chestcloudx.com",
+ "chewydonut.com",
+ "chibakenma.ml",
+ "chickenkiller.com",
+ "chiefacfix.xyz",
+ "chiefactech.xyz",
+ "chiefaircon.xyz",
+ "chiefaircontech.xyz",
+ "chiefamc.xyz",
+ "chiefcoolair.xyz",
+ "chieffacility.xyz",
+ "chiefhvacfix.xyz",
+ "chielo.com",
+ "childrenth.com",
+ "childsavetrust.org",
+ "chilkat.com",
+ "chillart.org",
+ "chillerdesk.xyz",
+ "chillerhelp.xyz",
+ "chilloliandfii.space",
+ "chinamkm.com",
+ "chingchongme.site",
+ "chithinh.com",
+ "chitthi.in",
+ "choco.la",
+ "chogmail.com",
+ "choicemail1.com",
+ "choichay.com",
+ "choigi.com",
+ "chomolokko.shop",
+ "choner.cn",
+ "choner.shop",
+ "chong-mail.com",
+ "chong-mail.net",
+ "chong-mail.org",
+ "chongqilai.cc",
+ "chophim.com",
+ "chosenx.com",
+ "chotgo.com",
+ "chromentis.cfd",
+ "chsi.christmas",
+ "chuan.info",
+ "chucklequartz.lol",
+ "chumpstakingdumps.com",
+ "chundage.help",
+ "chupanhcuoidep.com",
+ "churchestate.org.uk",
+ "churchhousemanaton.co.uk",
+ "churchinsouthampton.org.uk",
+ "cigar-auctions.com",
+ "cihai.eu.cc",
+ "cihaifhqo.eu.cc",
+ "cihuy.net",
+ "cimario.com",
+ "cipcup.site",
+ "citmo.net",
+ "citruscarton.shop",
+ "cityacmaintenance.xyz",
+ "citychiller.xyz",
+ "cityfixit.xyz",
+ "cityfreon.xyz",
+ "cityhumdaily.buzz",
+ "cityonfoot.me",
+ "civicseedling.sbs",
+ "civikli.com",
+ "civx.org",
+ "cj4fj2a.camdvr.org",
+ "cjdeyuiw.eu.cc",
+ "cjgq.skin",
+ "ckaazaza.tk",
+ "cketrust.org",
+ "ckiso.com",
+ "ckvn.edu.vn",
+ "cl-cl.org",
+ "cl0ne.net",
+ "claimab.com",
+ "clandest.in",
+ "classesmail.com",
+ "classio.click",
+ "claudd.com",
+ "claverfield.org",
+ "clayhallventures.net",
+ "cleanacrepair.xyz",
+ "cleanersdubaiuae.xyz",
+ "cleanersemirates.xyz",
+ "cleanerspalmjumeirah.xyz",
+ "cleaninbox.dynv6.net",
+ "cleaningdubaicreek.xyz",
+ "cleaningfestivalcity.xyz",
+ "cleaningpalm.xyz",
+ "clearaircontech.xyz",
+ "clearbeam.pro",
+ "clearchiller.xyz",
+ "clearclimate.xyz",
+ "clearcoolchiller.xyz",
+ "clearcoolhvactech.xyz",
+ "clearhabitpath.com",
+ "clearwatermail.info",
+ "cleveracservice.xyz",
+ "cleverhvacservice.xyz",
+ "cleverrepairs.xyz",
+ "clfapqg.theworkpc.com",
+ "click-email.com",
+ "clickcore99.click",
+ "clickdeal.co",
+ "clickpetal.click",
+ "climateexpert.xyz",
+ "clipmail.eu",
+ "clixser.com",
+ "clockemail.com",
+ "clone21.io.vn",
+ "cloneemail.com",
+ "clonehv.com",
+ "clonekhoe.com",
+ "clonemailsieure.com",
+ "clonemoi.tk",
+ "clonenpa.com",
+ "clonere.net",
+ "clonetrust.com",
+ "cloud-mail.top",
+ "cloud-temp.com",
+ "cloudflare.gay",
+ "cloudgen.world",
+ "cloudgue.site",
+ "cloudsign.in",
+ "cloudtempmail.net",
+ "cloudwhitespace.cc.cd",
+ "cloudwhitezone.cc.cd",
+ "cloudyhandshake.cyou",
+ "cloudyourfast.net",
+ "clousy.site",
+ "clout.wiki",
+ "cloverradiance.beauty",
+ "clowmail.com",
+ "clrmail.com",
+ "club106.org.uk",
+ "clx0vq5.kozow.com",
+ "clyvantis.cfd",
+ "cmail.blog",
+ "cmail.club",
+ "cmail.com",
+ "cmail.net",
+ "cmail.org",
+ "cmbl.top",
+ "cmcc.indevs.in",
+ "cmdkl.com",
+ "cmhvzylmfc.com",
+ "cnamed.com",
+ "cndps.com",
+ "cnebw.eu.cc",
+ "cneemail.com",
+ "cnew.ir",
+ "cnieux.com",
+ "cnmsg.net",
+ "cnoilpress.com",
+ "cnsds.de",
+ "co.cc",
+ "coasah.com",
+ "cobal.infos.st",
+ "cobarekyo1.ml",
+ "cocoro.uk",
+ "cocovpn.com",
+ "code-gmail.com",
+ "codeandscotch.com",
+ "codeguard.net",
+ "codei.site",
+ "coderdir.com",
+ "codestar.site",
+ "codesuc.top",
+ "codexrip.vip",
+ "coding.ccwu.cc",
+ "codinggo.cn",
+ "codivide.com",
+ "codywon.top",
+ "codzy.net",
+ "coffeeazzan.com",
+ "coffeejadore.com",
+ "coffeetimer24.com",
+ "cogsapb.ddnsfree.com",
+ "coieo.com",
+ "coin-host.net",
+ "coinlink.club",
+ "cok.org.uk",
+ "colabeta.com",
+ "colaname.com",
+ "coldemail.info",
+ "collegewh.edu.pl",
+ "colorcastmail.com",
+ "colurmish.com",
+ "cometchatter.buzz",
+ "cometclear.com",
+ "cometlumen.com",
+ "comfythings.com",
+ "commoncove.sbs",
+ "compareshippingrates.org",
+ "completegolfswing.com",
+ "compressjpg.io",
+ "comwest.de",
+ "comyze.org",
+ "comzx.com",
+ "concu.net",
+ "conf.work",
+ "confmin.com",
+ "congacone312.shop",
+ "congnguyen.io.vn",
+ "connected.linkpc.net",
+ "connectifyhub.sbs",
+ "connho.com",
+ "connho.net",
+ "consumerriot.com",
+ "contaco.org",
+ "contact.infos.st",
+ "contactbox.work",
+ "contactpage.online",
+ "contbay.com",
+ "contractingcherrywood.xyz",
+ "contractingdifc.xyz",
+ "contractingzabeel.xyz",
+ "contractorsmajan.xyz",
+ "convergenceindia.co.in",
+ "coocuuu.food",
+ "cood.food",
+ "cooh-2.site",
+ "cookie007.fr.nf",
+ "coolairguys.xyz",
+ "coolairmedic.xyz",
+ "coolairservice.xyz",
+ "coolairsquad.xyz",
+ "coolandwacky.us",
+ "coolcareuae.xyz",
+ "coolclinicuae.xyz",
+ "coolcontractsameday.xyz",
+ "coolcrewuae.xyz",
+ "coolexpertsdxb.xyz",
+ "coolexpertuae.xyz",
+ "coolimpool.org",
+ "coolingamcuae.xyz",
+ "coolingdesk.xyz",
+ "coolingtechs.xyz",
+ "coolingworks.xyz",
+ "coolinstalldubai.xyz",
+ "cooljump.org",
+ "coolmaintenancedxb.xyz",
+ "coolprodubai.xyz",
+ "coolprosdubai.xyz",
+ "coolrepairdxb.xyz",
+ "coolrepairuae.xyz",
+ "cooltechdxb.xyz",
+ "cooltechuae.xyz",
+ "cooltilal.xyz",
+ "cooltownsquare.xyz",
+ "coolzabeel.xyz",
+ "copyhome.win",
+ "copytosh.com",
+ "coralhost.my",
+ "corbyrise.com",
+ "coreclip.com",
+ "corhash.net",
+ "cornwallaerospaceeducationtrust.org",
+ "corridor.bond",
+ "cosdas.com",
+ "cosmorph.com",
+ "cosoinan.com",
+ "cosycritters.co.uk",
+ "cotasen.com",
+ "cotigz.com",
+ "couponbeacon.bond",
+ "courriel.fr.nf",
+ "courrieltemporaire.com",
+ "cousinment.com",
+ "covisuki.icu",
+ "coza.ro",
+ "cozilla.pw",
+ "cpc.cx",
+ "cqwbn.com",
+ "crankhole.com",
+ "crapmail.org",
+ "crastination.de",
+ "craventry.cfd",
+ "crazespaces.pw",
+ "crazymailing.com",
+ "crczrsxls.top",
+ "cream.pink",
+ "creative.publicvm.com",
+ "credit-loans.xyz",
+ "crepeau12.com",
+ "crestlantern.top",
+ "cresvyn.cfd",
+ "creteanu.com",
+ "crgjw3a.giize.com",
+ "cringemonster.com",
+ "crogra.org",
+ "cross-law.ga",
+ "cross-law.gq",
+ "cross.edu.pl",
+ "crossmailjet.com",
+ "crossroadsmail.com",
+ "croxleypartners.net",
+ "crunchcompass.com",
+ "cruncoau.asia",
+ "crusthost.com",
+ "crybio.com",
+ "cryptoavalonsolhub.cloud",
+ "cryptogmail.com",
+ "crystalcoolservice.xyz",
+ "cs.email",
+ "csapi.dpdns.org",
+ "csh.ro",
+ "cslua.com",
+ "cspaus.com",
+ "cssorbit.me",
+ "csvbeacon.me",
+ "csvlark.me",
+ "cszbl.com",
+ "ctmailing.us",
+ "ctos.ch",
+ "cttnoot.us",
+ "cu.cc",
+ "cuahangvppn.shop",
+ "cuaks.fun",
+ "cubene.com",
+ "cubiclink.com",
+ "cucadas.com",
+ "cuendita.com",
+ "cuirushi.org",
+ "cumnnm.us.ci",
+ "cunan.store",
+ "cuoly.com",
+ "cuongaquarium.com",
+ "cupbest.com",
+ "curlhph.tk",
+ "currentmail.com",
+ "curryworld.de",
+ "cursor13402.xyz",
+ "cursorcove.click",
+ "cuscuscuspen.life",
+ "cust.in",
+ "cutefrogs.xyz",
+ "cutevi.us",
+ "cutout.club",
+ "cutradition.com",
+ "cutsup.com",
+ "cutuvye.org",
+ "cuvox.de",
+ "cuvrs.info",
+ "cwetg.co.uk",
+ "cwsgear.com",
+ "cxbl.top",
+ "cxioms.help",
+ "cxwet.com",
+ "cyber-innovation.club",
+ "cyber-phone.eu",
+ "cyberionexus.shop",
+ "cybertemp.cfd",
+ "cybertemp.xyz",
+ "cycinst.com",
+ "cyclelove.cc",
+ "cyjd.top",
+ "cykr.beer",
+ "cylab.org",
+ "cyln.beer",
+ "cypi.fr",
+ "czx.one",
+ "d1yun.com",
+ "d3p.dk",
+ "d4ngerssquy.info",
+ "d8tiya6.camdvr.org",
+ "daabox.com",
+ "dab.ro",
+ "dacoolest.com",
+ "dadaolibai.eu.cc",
+ "daemonixgames.com",
+ "daemsteam.com",
+ "daerdy.com",
+ "dahewushi.eu.cc",
+ "daibb.us",
+ "daibond.info",
+ "daily-email.com",
+ "dailycoolcare.xyz",
+ "dailynove.com",
+ "dailynutria.com",
+ "daimuedu.com",
+ "daintly.com",
+ "dakaka.org",
+ "dalanshu.org",
+ "damachillsmaintenance.com",
+ "damaclagoonsmaintenance.com",
+ "damai.webcam",
+ "dammexe.net",
+ "damnthespam.com",
+ "damowang.eu.cc",
+ "damphen.org",
+ "dandikmail.com",
+ "dann.mywire.org",
+ "dantriviet.shop",
+ "dao.pp.ua",
+ "daouse.com",
+ "dapdigital.site",
+ "dapurx.me",
+ "dardr.com",
+ "darkemail.school",
+ "darkharvestfilms.com",
+ "darkserver.ru",
+ "daryxfox.net",
+ "dasdasdascyka.tk",
+ "dasf.bid",
+ "dash-pads.com",
+ "dataarca.com",
+ "datadudi.com",
+ "datapinacle.com",
+ "datarca.com",
+ "datazo.ca",
+ "datchuban.top",
+ "datdonshopee.site",
+ "datenschutz.ru",
+ "datgemini.io.vn",
+ "datprovn.io.vn",
+ "datprovnn.io.vn",
+ "datum2.com",
+ "daugr.com",
+ "dautubds.io.vn",
+ "davidkoh.net",
+ "davidlcreative.com",
+ "davidtrix.biz",
+ "dawin.com",
+ "dawnwick.org",
+ "dawsoncountygunclub.org",
+ "dawushihe.eu.cc",
+ "dawushihehe.eu.cc",
+ "dayizhan.top",
+ "daymail.life",
+ "daymailonline.com",
+ "dayrep.com",
+ "dbea.autos",
+ "dbmail.one",
+ "dboso.com",
+ "dboxmail.com",
+ "dbunker.com",
+ "dcctb.com",
+ "dcemail.com",
+ "dcpa.net",
+ "dcyo.skin",
+ "dd2car.com",
+ "ddat0511.click",
+ "ddcrew.com",
+ "ddfd.fashion",
+ "ddsdg34.abrdns.com",
+ "de-a.org",
+ "dea-21olympic.com",
+ "deadaddress.com",
+ "deadchildren.org",
+ "deadfake.cf",
+ "deadfake.ga",
+ "deadfake.ml",
+ "deadfake.tk",
+ "deadspam.com",
+ "deagot.com",
+ "deajeng.store",
+ "dealja.com",
+ "deallabs.org",
+ "dealrek.com",
+ "deapad.com",
+ "debitur.cam",
+ "decep.com",
+ "decodewp.com",
+ "decornido.com",
+ "dede.infos.st",
+ "deekayen.us",
+ "deenur.com",
+ "deepconverts.com",
+ "deepmails.org",
+ "deepmaster.fun",
+ "deepneko.xyz",
+ "deepyinc.com",
+ "deertees.com",
+ "defomail.com",
+ "degap.fr.nf",
+ "degar.xyz",
+ "degradedfun.net",
+ "dehaopay.com",
+ "deinbox.com",
+ "deisgn-ai.work.gd",
+ "delaeb.com",
+ "delayload.com",
+ "delayload.net",
+ "delikkt.de",
+ "delivrmail.com",
+ "delorex.com",
+ "deltajohnsons.com",
+ "deluxmail.com",
+ "delvionis.cfd",
+ "demen.ml",
+ "dengekibunko.ga",
+ "dengekibunko.gq",
+ "dengekibunko.ml",
+ "denipl.com",
+ "denipl.net",
+ "dentaguardmouthguard.com",
+ "deornaumail.com",
+ "depdocla.net",
+ "der-kombi.de",
+ "derkombi.de",
+ "derluxuswagen.de",
+ "desertsundesigns.com",
+ "desfrenes.fr.nf",
+ "deshnetarchadacalculator.one",
+ "designhta.work.gd",
+ "desmond.eu.cc",
+ "desoz.com",
+ "despam.it",
+ "despammed.com",
+ "dev-null.cf",
+ "dev-null.ga",
+ "dev-null.gq",
+ "dev-null.ml",
+ "dev17.tech",
+ "devbike.com",
+ "developermail.com",
+ "developtool.app",
+ "devh24.online",
+ "devnullmail.com",
+ "devsonline.studio",
+ "devthien.io.vn",
+ "dewlilac.beauty",
+ "deyom.com",
+ "dfghjertyh.dynv6.net",
+ "dfyuih.eu.cc",
+ "dgdf.cc",
+ "dghdfjfd.eu.cc",
+ "dh372.top",
+ "dharmatel.net",
+ "dheiwuy.eu.cc",
+ "dhkt.skin",
+ "dhm.ro",
+ "dhnow.com",
+ "dhoahn.eu.cc",
+ "dhy.cc",
+ "dialogus.com",
+ "diamondacmaintenance.xyz",
+ "diamondmaint.xyz",
+ "diapaulpainting.com",
+ "dichvuxe24h.com",
+ "dicopto.com",
+ "diddyricky.com",
+ "diddyricky.fr",
+ "diddyricky.info",
+ "diddyricky.store",
+ "diemhenvn.com",
+ "digdig.org",
+ "digi-value.fr",
+ "digial.jo3.org",
+ "digibeast.my",
+ "digibeast.store",
+ "digimaster.bond",
+ "digimaster.eu.cc",
+ "diginey.com",
+ "digital-message.com",
+ "digitaldesign.run.place",
+ "digitalesbusiness.info",
+ "digitalmail.info",
+ "digitalmariachis.com",
+ "digitalsanctuary.com",
+ "digitecy.live",
+ "dildoe.cfd",
+ "dildosfromspace.com",
+ "dim-coin.com",
+ "dimalk.com",
+ "dingbone.com",
+ "dinlaan.site",
+ "dino.icu",
+ "diolang.com",
+ "diplom-voronesh.ru",
+ "directcoolfix.xyz",
+ "directcoolservice.xyz",
+ "directhvacfix.xyz",
+ "directmail24.net",
+ "dis.hopto.org",
+ "disaq.com",
+ "disbox.net",
+ "disbox.org",
+ "discard.cf",
+ "discard.email",
+ "discard.ga",
+ "discard.gq",
+ "discard.ml",
+ "discard.tk",
+ "discardmail.com",
+ "discardmail.de",
+ "discos4.com",
+ "dishcatfish.com",
+ "disign-concept.eu",
+ "disign-revelation.com",
+ "dispo.in",
+ "dispomail.eu",
+ "disposable-e.ml",
+ "disposable-email.ml",
+ "disposable.cf",
+ "disposable.ga",
+ "disposable.ml",
+ "disposable.site",
+ "disposableaddress.com",
+ "disposableemailaddresses.com",
+ "disposableinbox.com",
+ "disposablemails.com",
+ "dispose.it",
+ "disposeamail.com",
+ "disposely.xyz",
+ "disposemail.com",
+ "disposemymail.com",
+ "disposly.com",
+ "dispostable.com",
+ "districtonemaintenance.com",
+ "divad.ga",
+ "divermail.com",
+ "divismail.ru",
+ "diwaq.com",
+ "dixz.net",
+ "diyongjin.net",
+ "dizigg.com",
+ "djiv.xyz",
+ "djwekih.eu.cc",
+ "dkz.opik.net",
+ "dldweb.info",
+ "dlemail.ru",
+ "dlwv.makeup",
+ "dmail.one",
+ "dmarc.ro",
+ "dmcelements.org",
+ "dmts.fr.nf",
+ "dmxs8.com",
+ "dnbp.hair",
+ "dndent.com",
+ "dnkah.eu.cc",
+ "dns.army",
+ "dns.navy",
+ "dnsclick.com",
+ "dnses.ro",
+ "dnslog.tech",
+ "dnsprism.me",
+ "dnsraven.me",
+ "doanart.com",
+ "doantotnghiep.com",
+ "dob.jp",
+ "docash.app",
+ "docbao7.com",
+ "docse.site",
+ "dodgeit.com",
+ "dodgemail.de",
+ "dodgit.com",
+ "dodgit.org",
+ "dodsi.com",
+ "dogai.qzz.io",
+ "dogclothing.org",
+ "dogking.dpdns.org",
+ "dogmrp.com",
+ "dohoanganh.site",
+ "doiea.com",
+ "doj.one",
+ "dolofan.com",
+ "dolphinnet.net",
+ "domforfb1.tk",
+ "domforfb18.tk",
+ "domforfb19.tk",
+ "domforfb2.tk",
+ "domforfb23.tk",
+ "domforfb27.tk",
+ "domforfb29.tk",
+ "domforfb3.tk",
+ "domforfb4.tk",
+ "domforfb5.tk",
+ "domforfb6.tk",
+ "domforfb7.tk",
+ "domforfb8.tk",
+ "domforfb9.tk",
+ "domozmail.com",
+ "don.edu.pl",
+ "dona.one",
+ "dona.pw",
+ "dona.rip",
+ "donebyngle.com",
+ "donemail.my.id",
+ "donemail.ru",
+ "dongqing365.com",
+ "donkicode.top",
+ "dontreg.com",
+ "dontsendmespam.de",
+ "dontsleep404.com",
+ "donusumekatil.com",
+ "doobb.com",
+ "doojazz.com",
+ "doquier.tk",
+ "doreact.com",
+ "dorothyfoundation.org",
+ "doszo.qzz.io",
+ "dotapodemail.com",
+ "dotman.de",
+ "dotmsg.com",
+ "dotool8.top",
+ "dotos.dev",
+ "dotslashrage.com",
+ "dotvu.net",
+ "dotzi.net",
+ "doublemail.de",
+ "douchelounge.com",
+ "doudizhu.top",
+ "doulas.org",
+ "downloadigpost.com",
+ "downloadvideoig.app",
+ "dozvon-spb.ru",
+ "dp76.com",
+ "dpfrufiwo.top",
+ "dpmstore.id.vn",
+ "dpmurt.my",
+ "dpptd.com",
+ "dprasu.sbs",
+ "dr69.site",
+ "dramamixio.icu",
+ "draughtier.com",
+ "draycottholdings.net",
+ "drdrb.com",
+ "drdrb.net",
+ "dreamadventure.org",
+ "dreamclarify.org",
+ "dreameg.com",
+ "dreamgreen.fr.nf",
+ "dred.ru",
+ "drevo.si",
+ "drewzen.com",
+ "driftz.net",
+ "drimex.cfd",
+ "dripzgaming.com",
+ "drivetagdev.com",
+ "drivz.net",
+ "drmail.in",
+ "droggo.engineer",
+ "droolingfanboy.de",
+ "dropcake.de",
+ "dropinboxes.com",
+ "dropjar.com",
+ "droplar.com",
+ "dropmail.me",
+ "dropons.com",
+ "dropsin.net",
+ "drovantis.cfd",
+ "drovenix.cfd",
+ "drowblock.com",
+ "drugsellers.com",
+ "druzik.pp.ua",
+ "dsbe.makeup",
+ "dsfsfdsfds.shop",
+ "dsgvo.party",
+ "dsgvo.ru",
+ "dshfjdafd.cloud",
+ "dshznonline.cc",
+ "dsiay.com",
+ "dsitip.com",
+ "dspwebservices.com",
+ "dswe-56f.xyz",
+ "dtvpn.dpdns.org",
+ "duam.net",
+ "dubaiacplumbing.com",
+ "dubaiapartmentcare.com",
+ "dubaiapartmentfix.com",
+ "dubaicarecrew.com",
+ "dubaifixcrew.com",
+ "dubaifixitnow.com",
+ "dubaifixlab.com",
+ "dubaifixmasters.com",
+ "dubaifixnow.com",
+ "dubaifixpoint.com",
+ "dubaifixservices.com",
+ "dubaifixteam.com",
+ "dubaiflatfix.com",
+ "dubaiflatservices.com",
+ "dubaihandyhelp.com",
+ "dubaihandyman247.com",
+ "dubaihandymanco.com",
+ "dubaihandymanhub.com",
+ "dubaihandymanpro.com",
+ "dubaihillsmaintenance.com",
+ "dubaihomeassist.com",
+ "dubaihomecareservices.com",
+ "dubaihomemasters.com",
+ "dubaihomerepairco.com",
+ "dubaihomerepairexperts.com",
+ "dubaihomerescue.com",
+ "dubaihomeserviceco.com",
+ "dubaihomesupport.com",
+ "dubaihometechnicians.com",
+ "dubaihomeworks.com",
+ "dubaihousecare.com",
+ "dubaihousedoctors.com",
+ "dubaihousefix.com",
+ "dubailandmaintenance.com",
+ "dubaimaintenance247.com",
+ "dubaimaintenanceassist.com",
+ "dubaimaintenanceco.com",
+ "dubaimaintenancecrew.com",
+ "dubaimaintenancemasters.com",
+ "dubaimaintenancepro.com",
+ "dubaimaintenancepros.com",
+ "dubaipropertyfix.com",
+ "dubairepair247.com",
+ "dubairepairassist.com",
+ "dubairepairco.com",
+ "dubairepaircrew.com",
+ "dubairepairguys.com",
+ "dubairepairmasters.com",
+ "dubairepairworks.com",
+ "dubaiservicecrew.com",
+ "dubaiserviceworks.com",
+ "dubaisiliconoasismaintenance.com",
+ "dubaisouthmaintenance.com",
+ "dubaivillacare.com",
+ "dubaivillamaintenance.com",
+ "dubokutv.com",
+ "duck2.club",
+ "duckmail.sbs",
+ "duckyoursick.com",
+ "ducpham.id.vn",
+ "ductfixit.xyz",
+ "ductingco.xyz",
+ "ductingrepair.xyz",
+ "dudmail.com",
+ "dugmail.com",
+ "duidir.com",
+ "duk33.com",
+ "dukedish.com",
+ "dukunmodern.id",
+ "dulich360.com",
+ "dulich84.com",
+ "dulichdatlanh.com",
+ "dulichdatlanh.vn",
+ "dulieu.io.vn",
+ "dumalu.com",
+ "dumbass.nl",
+ "dump-email.info",
+ "dumpandjunk.com",
+ "dumpmail.de",
+ "dumpyemail.com",
+ "dunamispfm.org",
+ "dunefreon.xyz",
+ "dunesplit.xyz",
+ "dunkos.xyz",
+ "dunsfoldgroup.net",
+ "durandinterstellar.com",
+ "duskmail.com",
+ "dusrui.com",
+ "dustinry.com",
+ "dustmail.net",
+ "duvips.com",
+ "duya25446.top",
+ "duyquyen.site",
+ "dv2.host",
+ "dvaren.online",
+ "dvdpit.com",
+ "dwarfmining.com",
+ "dwiworka.pro",
+ "dwse.edu.pl",
+ "dwseal.com",
+ "dxbcontractquick.xyz",
+ "dxbmaintenanceuae.xyz",
+ "dxbrepairquick.xyz",
+ "dyceroprojects.com",
+ "dyclsr.xyz",
+ "dyebkj.eu.cc",
+ "dyera.studio",
+ "dyfgiavcrw.eu.cc",
+ "dygovil.com",
+ "dymarent.cfd",
+ "dynarix.cfd",
+ "dz17.net",
+ "dz62.help",
+ "dzntn.io.vn",
+ "dztyhd.cn",
+ "e-mail.com",
+ "e-mail.org",
+ "e-marketstore.ru",
+ "e-pool.co.uk",
+ "e-pool.uk",
+ "e-postkasten.de",
+ "e-record.com",
+ "e-tomarigi.com",
+ "e3z.de",
+ "e4ward.com",
+ "e6hoohs.camdvr.org",
+ "eacademia.uk",
+ "ealea.fr.nf",
+ "eanok.com",
+ "eartin.net",
+ "ease.es",
+ "eastcombe.org",
+ "easy-trash-mail.com",
+ "easyamcservice.xyz",
+ "easycooltech.xyz",
+ "easygbd.com",
+ "easyhomefit.com",
+ "easyhvaccare.xyz",
+ "easyhvacfix.xyz",
+ "easymailer.live",
+ "easymails.cc",
+ "easynetwork.info",
+ "easytrashmail.com",
+ "eatmea2z.club",
+ "eay.jp",
+ "ebar.site",
+ "ebbob.com",
+ "ebeschlussbuch.de",
+ "eby9h2m.giize.com",
+ "ecallheandi.com",
+ "eckpcin.giize.com",
+ "ecolo-online.fr",
+ "ecoverseworld.com",
+ "ecstor.com",
+ "edankeunwe.site",
+ "edfc.skin",
+ "edgepodlab.com",
+ "edgetopgrid.com",
+ "edgex.ru",
+ "edinburgh-airporthotels.com",
+ "edmondventures.com",
+ "edny.net",
+ "edshol.net",
+ "edu-qin.ccwu.cc",
+ "education.nyc.mn",
+ "edudingy.cfd",
+ "eduemail.me",
+ "eduip.eu.org",
+ "edumail.edu.pl",
+ "edumail.edu.rs",
+ "edumaili.com",
+ "edumaili.edu.pl",
+ "edumaill.edu.pl",
+ "edupolska.edu.pl",
+ "edv.to",
+ "ee1.pl",
+ "ee2.pl",
+ "eedl.skin",
+ "eeedv.de",
+ "eelmail.com",
+ "eelraodo.com",
+ "eemail.eu.cc",
+ "een4dnt.ooguy.com",
+ "eewmaop.com",
+ "efdsdsdw.cloud-ip.cc",
+ "effobe.com",
+ "efxs.ca",
+ "egg5233.com",
+ "egg5233.one",
+ "eggbanjos.org",
+ "eggso.click",
+ "eggsuwz.ooguy.com",
+ "eggur.com",
+ "egirl.help",
+ "egni.beer",
+ "egvoo.com",
+ "egzones.com",
+ "einmalmail.de",
+ "einrot.com",
+ "einrot.de",
+ "eintagsmail.de",
+ "einzieg.site",
+ "eiveg.com",
+ "ekot.xyz",
+ "elafans.com",
+ "eldobhato-level.hu",
+ "elearningjournal.org",
+ "electro.mn",
+ "electrochillplanet.com",
+ "electrolabx.com",
+ "elerso.com",
+ "eligou.store",
+ "elitemaint.xyz",
+ "elitepixels.store",
+ "elitevipatlantamodels.com",
+ "elixora.cfd",
+ "eljawir.com",
+ "elji.autos",
+ "elki-mkzn.ru",
+ "ellertongroup.net",
+ "elobits.com",
+ "elondonteam.work.gd",
+ "elpisproject.org",
+ "elumetra.cfd",
+ "elygifts.com",
+ "emaii.io.vn",
+ "email-68.com",
+ "email-fake.cf",
+ "email-fake.com",
+ "email-fake.ga",
+ "email-fake.gq",
+ "email-fake.ml",
+ "email-fake.tk",
+ "email-free.online",
+ "email-jetable.fr",
+ "email-lab.com",
+ "email-temp.com",
+ "email.edu.pl",
+ "email.net",
+ "email1.pro",
+ "email10p.org",
+ "email60.com",
+ "email84.com",
+ "emailabox.pro",
+ "emailage.cf",
+ "emailage.ga",
+ "emailage.gq",
+ "emailage.ml",
+ "emailage.tk",
+ "emailaoa.pro",
+ "emailasia1.com",
+ "emailate.com",
+ "emailawb.pro",
+ "emailax.pro",
+ "emailbbox.pro",
+ "emailbin.net",
+ "emailboxer.one",
+ "emailcbox.pro",
+ "emailcoffeehouse.com",
+ "emailcu.icu",
+ "emaildark.fr.nf",
+ "emaildbox.pro",
+ "emaildienst.de",
+ "emaildrop.io",
+ "emailfake.com",
+ "emailfake.ml",
+ "emailforyou.info",
+ "emailfoxi.pro",
+ "emailfreedom.ml",
+ "emailgen.uk",
+ "emailgenerator.de",
+ "emailgo.de",
+ "emailgratisan.fun",
+ "emailhook.site",
+ "emailias.com",
+ "emailigo.de",
+ "emailinbo.live",
+ "emailinfive.com",
+ "emailisvalid.com",
+ "emailjps.com",
+ "emailkp.com",
+ "emaillime.com",
+ "emailluu.asia",
+ "emailmiser.com",
+ "emailmuaqat.shop",
+ "emailna.co",
+ "emailnator.com",
+ "emailnax.com",
+ "emailnow.net",
+ "emailnow.one",
+ "emailo.pro",
+ "emailondeck.com",
+ "emailplay.org",
+ "emailportal.info",
+ "emailproxsy.com",
+ "emailracc.com",
+ "emailresort.com",
+ "emailrod.com",
+ "emails.ga",
+ "emailsecurer.com",
+ "emailsensei.com",
+ "emailsingularity.net",
+ "emailspam.cf",
+ "emailspam.ga",
+ "emailspam.gq",
+ "emailspam.ml",
+ "emailspam.tk",
+ "emailsy.info",
+ "emailtech.info",
+ "emailtemporanea.com",
+ "emailtemporanea.net",
+ "emailtemporar.ro",
+ "emailtemporario.com.br",
+ "emailthe.net",
+ "emailtik.com",
+ "emailtmp.com",
+ "emailto.de",
+ "emailuae.com",
+ "emailure.net",
+ "emailvb.pro",
+ "emailviet.com",
+ "emailwarden.com",
+ "emailwww.pro",
+ "emailxfer.com",
+ "emaily.pro",
+ "emailz.cf",
+ "emailz.ga",
+ "emailz.gq",
+ "emailz.ml",
+ "emalupe.com",
+ "emaxasp.com",
+ "embege.xyz",
+ "embekhoe.com",
+ "emeil.in",
+ "emeil.ir",
+ "emeraldwebmail.com",
+ "emergencymail.site",
+ "emergentvillage.org",
+ "emiratesannualuae.xyz",
+ "emiratiacpro.xyz",
+ "emiratirepairs.xyz",
+ "emkei.cf",
+ "emkei.ga",
+ "emkei.gq",
+ "emkei.ml",
+ "emkei.tk",
+ "eml.pp.ua",
+ "emlhot.com",
+ "emlhub.com",
+ "emlpro.com",
+ "emltmp.com",
+ "emocan.name.tr",
+ "empireanime.ga",
+ "empva.io.vn",
+ "empva1.com",
+ "empva1.io.vn",
+ "empva11.com",
+ "empva11.io.vn",
+ "emstjzh.com",
+ "emz.net",
+ "emziegz.com",
+ "enayu.com",
+ "end.tw",
+ "endingimpunity.com",
+ "endob.com",
+ "enfq.autos",
+ "engineersdubaimarina.xyz",
+ "engineerspalmjumeirah.xyz",
+ "englishcriketclub.org.uk",
+ "enitempmail.xyz",
+ "enotj.com",
+ "enowgntg.site",
+ "enspinner.com",
+ "ensproxy.com",
+ "enterto.com",
+ "envy17.com",
+ "eoffice.top",
+ "eoopy.com",
+ "eosada.com",
+ "eoslux.com",
+ "eoutrbl.com",
+ "ep77.com",
+ "epaco9t.loseyourip.com",
+ "epb.ro",
+ "epbox.ru",
+ "epbox.store",
+ "epetsoft.com",
+ "ephemail.net",
+ "ephemeral.email",
+ "eposta.buzz",
+ "eposta.work",
+ "epostal.ru",
+ "epostal.store",
+ "epsteinisland.click",
+ "epurcf.org",
+ "eqgq.hair",
+ "eqiluxspam.ga",
+ "equivara.cfd",
+ "erahods.com",
+ "ereplyzy.com",
+ "ericjohnson.ml",
+ "eripo.net",
+ "ernestturcotte.us",
+ "ero-tube.org",
+ "erth.nl",
+ "esadverse.com",
+ "esbano-ru.ru",
+ "esc.la",
+ "escapehatchapp.com",
+ "esemay.com",
+ "esgeneri.com",
+ "eshimod.com",
+ "esiix.com",
+ "esprity.com",
+ "estate-invest.fr",
+ "estatecoolrepair.xyz",
+ "estatecoolservice.xyz",
+ "estatemaintenancedubai.xyz",
+ "esterace.com",
+ "estrowebservices.com",
+ "etempmail.net",
+ "etenx.com",
+ "eth2btc.info",
+ "ether123.net",
+ "ethereum1.top",
+ "ethersports.org",
+ "ethersportz.info",
+ "etickethelpdesk.net",
+ "etics.us",
+ "etotvibor.ru",
+ "etquakemultimedia.com",
+ "etramay.com",
+ "etranquil.com",
+ "etranquil.net",
+ "etranquil.org",
+ "etubemail.com",
+ "etzg.de5.net",
+ "euaqa.com",
+ "euleina.com",
+ "eulercraft.com",
+ "euroallsports.org.uk",
+ "eurokool.com",
+ "euucn.com",
+ "evairo.fun",
+ "evairo.web.id",
+ "evanfox.info",
+ "eve.dpdns.org",
+ "eveav.com",
+ "evilcomputer.com",
+ "evilgodshop.uk",
+ "evjy.skin",
+ "evnft.com",
+ "evomiserver.com",
+ "evontra.cfd",
+ "evopo.com",
+ "evusd.com",
+ "evvgo.com",
+ "evyush.com",
+ "ewebrus.com",
+ "ewma.hair",
+ "ewma.skin",
+ "exactaccare.xyz",
+ "exactacrepair.xyz",
+ "exactchiller.xyz",
+ "exacthvacrepair.xyz",
+ "exahut.com",
+ "examcentral.co.uk",
+ "examstudy.xyz",
+ "exbts.com",
+ "exclussi.com",
+ "exdonuts.com",
+ "exelica.com",
+ "exespay.com",
+ "exhaycle.com",
+ "exile.my.id",
+ "existiert.net",
+ "exitbit.com",
+ "exitstageleft.net",
+ "exkucn.ccwu.cc",
+ "exmab.com",
+ "expertacmaintenance.xyz",
+ "expertairconservice.xyz",
+ "expertamcmaintenance.xyz",
+ "expertcitywalk.xyz",
+ "expertcoolservice.xyz",
+ "expertinstallationsdxb.xyz",
+ "expertqusais.xyz",
+ "expertsarabianranches.xyz",
+ "expertskarama.xyz",
+ "experttechgulf.xyz",
+ "expertwarsan.xyz",
+ "expiredtoaster.org",
+ "explodemail.com",
+ "expltain.com",
+ "express.net.ua",
+ "extracurricularsociety.com",
+ "extraku.com",
+ "extraku.net",
+ "extraku.shop",
+ "extremail.ru",
+ "extrset.com",
+ "exweme.com",
+ "exzork.sbs",
+ "ey-hungry.top",
+ "eycfhb.com",
+ "eyee.eu.cc",
+ "eyepaste.com",
+ "eynlong.com",
+ "ez.lv",
+ "ezai.chat",
+ "ezehe.com",
+ "ezfill.com",
+ "ezimb.com",
+ "ezonemail.com",
+ "ezsj.hair",
+ "ezstest.com",
+ "ezua.com",
+ "ezztt.com",
+ "f1b.email",
+ "f1b.vn",
+ "f2b.click",
+ "f2b.email",
+ "f2b.info",
+ "f2b.site",
+ "f2b.tech",
+ "f2b.tokyo",
+ "f2b.vn",
+ "f2c.email",
+ "f2c.host",
+ "f2c.me",
+ "f2c.mobi",
+ "f2c.press",
+ "f2d.click",
+ "f2d.info",
+ "f2d.life",
+ "f2d.pro",
+ "f2e.click",
+ "f2e.email",
+ "f2e.pro",
+ "f2e.pw",
+ "f2e.world",
+ "f2f.email",
+ "f2f.pw",
+ "f3p.io.vn",
+ "f4k.es",
+ "f4wxmpu.kozow.com",
+ "f5p0y90.ooguy.com",
+ "f5v7u9a.ddnsfree.com",
+ "f6z6ica.camdvr.org",
+ "fabaos.com",
+ "fabricmarsh.cfd",
+ "facebook-email.cf",
+ "facebook-email.ga",
+ "facebook-email.ml",
+ "facebookmail.gq",
+ "facebookmail.ml",
+ "facemac.website",
+ "facilitiesacrepair.xyz",
+ "facilitiesrepair.xyz",
+ "fackme.gq",
+ "factoryuk.org.uk",
+ "fadingemail.com",
+ "fadsfdsfaa.indevs.in",
+ "faecesmail.me",
+ "fafa.beer",
+ "fag.wf",
+ "failbone.com",
+ "fairairconcare.xyz",
+ "fairairconfix.xyz",
+ "fairamc.xyz",
+ "fairhavenventures.net",
+ "faithkills.com",
+ "faizasoft.com",
+ "fake-box.com",
+ "fake-email.pp.ua",
+ "fake-mail.cf",
+ "fake-mail.ga",
+ "fake-mail.ml",
+ "fakedemail.com",
+ "fakeinbox.cf",
+ "fakeinbox.com",
+ "fakeinbox.ga",
+ "fakeinbox.info",
+ "fakeinbox.ml",
+ "fakeinbox.tk",
+ "fakeinformation.com",
+ "fakemail.fr",
+ "fakemail.io",
+ "fakemail.net",
+ "fakemailgenerator.com",
+ "fakemailz.com",
+ "falconcitymaintenance.com",
+ "falconcoolmaintenance.xyz",
+ "falconryadventurecentreofeducation.com",
+ "fallinhay.com",
+ "famamail.com",
+ "fammix.com",
+ "fanclub.pm",
+ "fancoilsquad.xyz",
+ "fandoe.com",
+ "fanfanhenkuai.xyz",
+ "fangoh.com",
+ "fangxiaobai.us.ci",
+ "fangxiaobai1.us.ci",
+ "fanicle.com",
+ "fansworldwide.de",
+ "fantastu.com",
+ "fantasy139.icu",
+ "fantasymail.de",
+ "fantatua.com",
+ "fanymail.com",
+ "fanzher.com",
+ "farah.rip",
+ "farouter.tech",
+ "farrowgate.net",
+ "farrse.co.uk",
+ "fasl4kas3.site",
+ "fasssd.ru",
+ "fasssd.store",
+ "fast-email.info",
+ "fast-mail.fr",
+ "fastaccare.xyz",
+ "fastacura.com",
+ "fastchevy.com",
+ "fastchrysler.com",
+ "fasternet.biz",
+ "fastestflex.com",
+ "fastfacility.xyz",
+ "fastinbox.one",
+ "fastkawasaki.com",
+ "fastmail.edu.pl",
+ "fastmazda.com",
+ "fastmitsubishi.com",
+ "fastnissan.com",
+ "fastnodo.click",
+ "fastsearcher.com",
+ "fastsubaru.com",
+ "fastsuzuki.com",
+ "fasttoyota.com",
+ "fastyamaha.com",
+ "fatebd.com",
+ "fatflap.com",
+ "faviconpouch.me",
+ "faxico.com",
+ "faxzu.com",
+ "faybetsy.com",
+ "fazka.ovh",
+ "fazs.skin",
+ "fbb.cool",
+ "fbb.id.vn",
+ "fbb.io.vn",
+ "fbb.kim",
+ "fbb.linkpc.net",
+ "fbb.lu",
+ "fbb.vn",
+ "fbfbs.lol",
+ "fbhotro.com",
+ "fbi.one",
+ "fbins001mail.com",
+ "fbins607.com",
+ "fbkjlnry.top",
+ "fblgsl.cam",
+ "fbma.tk",
+ "fboxmail.com",
+ "fbviamail.com",
+ "fcetpkqwm.top",
+ "fddns.ml",
+ "fdfdsfds.com",
+ "fdfggh-df5.cc",
+ "fdger88.cc",
+ "fdhsui.eu.cc",
+ "fdmail.top",
+ "fdpbnzx.cn",
+ "fdsgsd.org",
+ "fdsregre.shop",
+ "fdvctte.info",
+ "featcore.com",
+ "fedayroi.online",
+ "feelmyheartwithsong.com",
+ "feiyujingxuan.top",
+ "femailtor.com",
+ "fengzheng.qzz.io",
+ "fenzhuai.top",
+ "fer-gabon.org",
+ "feralrex.com",
+ "ferd.live",
+ "ferdomnermail.com",
+ "feriwor.com",
+ "fermaxxi.ru",
+ "ferngrove.org",
+ "feroxid.com",
+ "fettometern.com",
+ "fewzc.makeup",
+ "fexbox.org",
+ "fexbox.ru",
+ "fexpost.com",
+ "fextemp.com",
+ "ffpp.store",
+ "ffps.fun",
+ "fft.edu.do",
+ "ffyf.fashion",
+ "fgmail.cyou",
+ "fgtygcop.store",
+ "fhoau.eu.cc",
+ "fhpfhp.fr.nf",
+ "fhwui.eu.cc",
+ "fhwyuoia.eu.cc",
+ "fiallaspares.com",
+ "fibmail.com",
+ "ficken.de",
+ "fictionsite.com",
+ "fidhost.com",
+ "fifafotball.org.uk",
+ "fightallspam.com",
+ "fighthunger.co.uk",
+ "fightingzebras.org",
+ "figjs.com",
+ "figshot.com",
+ "figurescoin.com",
+ "fiifke.de",
+ "filbert4u.com",
+ "filberts4u.com",
+ "file2drive.com",
+ "filetodrive.com",
+ "film-blog.biz",
+ "filmla.org",
+ "filzmail.com",
+ "finacenter.com",
+ "findemail.info",
+ "findids.net",
+ "findize.com",
+ "findmeghana.org",
+ "findu.pl",
+ "finews.biz",
+ "fingso.com",
+ "finsburyholdings.net",
+ "finsmine.info",
+ "fir.hk",
+ "fira.my",
+ "firemail.com.br",
+ "firemailbox.club",
+ "firerocket.site",
+ "firstaidtrainingschool.co.uk",
+ "firstamcmaintenance.xyz",
+ "firstamcpro.xyz",
+ "firstducting.xyz",
+ "firstlawyer.org",
+ "fisdom.vip",
+ "fitbloomlab.com",
+ "fitnesrezink.ru",
+ "fivemail.de",
+ "fivermail.com",
+ "fixarabianranches.xyz",
+ "fixbarsha.xyz",
+ "fixclean24x7.xyz",
+ "fixcontractexpress.xyz",
+ "fixitdip.xyz",
+ "fixitsprings.xyz",
+ "fixmail.tk",
+ "fixmirdif.xyz",
+ "fixmydubaihome.com",
+ "fixtech24x7.xyz",
+ "fizmail.com",
+ "fj7v67f.camdvr.org",
+ "fjic.hair",
+ "fjic.skin",
+ "fkainc.com",
+ "fknm8.mobi",
+ "flaimenet.ir",
+ "flakeian.com",
+ "flameoflovedegree.com",
+ "flashemail.site",
+ "flatimostore.cloud",
+ "flavourity.com",
+ "flayeraaron.eu.cc",
+ "flayerhu.eu.cc",
+ "fleckens.hu",
+ "flemail.ru",
+ "flews.app",
+ "flexvio.com",
+ "flickerbutton.click",
+ "fliegender.fish",
+ "flobo.fr.nf",
+ "flock84.uk",
+ "florevia.cfd",
+ "flosek.com",
+ "flowconnect.beer",
+ "flowu.com",
+ "flu.cc",
+ "fluidsoft.us",
+ "flurred.com",
+ "fly-ts.de",
+ "flyhubs.top",
+ "flyinggeek.net",
+ "flymail.tk",
+ "flyovertrees.com",
+ "flyspam.com",
+ "flywaverun.com",
+ "flyzy.net",
+ "fmuss.com",
+ "fncp.ru",
+ "fncp.store",
+ "fngd.hair",
+ "fog.one",
+ "fommie.com",
+ "fommie.online",
+ "fommie.store",
+ "foobarbot.net",
+ "footard.com",
+ "footballgamesbd.site",
+ "foothilltoy.site",
+ "for4u.net",
+ "forapps.space",
+ "forbmmend.com",
+ "foreastate.com",
+ "forecastertests.com",
+ "foreskin.cf",
+ "foreskin.ga",
+ "foreskin.gq",
+ "foreskin.ml",
+ "foreskin.tk",
+ "forexnews.bg",
+ "forexru.com",
+ "forexzig.com",
+ "forge45k.io.vn",
+ "forgetmail.com",
+ "forgetmenotstudio.org.uk",
+ "forliion.com",
+ "formatbento.me",
+ "formodapk.com",
+ "fornow.eu",
+ "forregmail.asia",
+ "forregmail.xyz",
+ "forspam.net",
+ "forum.nyc.mn",
+ "forward.cat",
+ "fosil.pro",
+ "foxglovegoods.shop",
+ "foxja.com",
+ "foxnew.info",
+ "foxtrotter.info",
+ "fpgrabber.com",
+ "fpt-student-support.store",
+ "fqys.hair",
+ "fr.cr",
+ "fr33mail.info",
+ "fragolina2.tk",
+ "frankhealeyfoundation.org",
+ "frapmail.com",
+ "frappina.tk",
+ "frederictonlawyer.com",
+ "free-email.cf",
+ "free-email.ga",
+ "free-temp-mail.eu.org",
+ "free-temp.net",
+ "freebabysittercam.com",
+ "freeblackbootytube.com",
+ "freecat.net",
+ "freecustom.email",
+ "freedom4you.info",
+ "freedomcentremereside.org",
+ "freedompop.us",
+ "freedrops.org",
+ "freefattymovies.com",
+ "freegrok.cc.cd",
+ "freehotmail.net",
+ "freeimagehosts.org",
+ "freeimghost.org",
+ "freeinbox.email",
+ "freelance-france.eu",
+ "freeletter.me",
+ "freemail.ms",
+ "freemails.cf",
+ "freemails.ga",
+ "freemails.ml",
+ "freemails.pp.ua",
+ "freemeil.ga",
+ "freemeil.gq",
+ "freemeil.ml",
+ "freeml.net",
+ "freeplumpervideos.com",
+ "freerubli.ru",
+ "freeschoolgirlvids.com",
+ "freesistercam.com",
+ "freesourcecodes.com",
+ "freeteenbums.com",
+ "freetimer.online",
+ "freonexpert.xyz",
+ "freshairconpro.xyz",
+ "freshcooling.xyz",
+ "freshfable.buzz",
+ "freundin.ru",
+ "friday12.store",
+ "friendlyhollow.sbs",
+ "friendlymail.co.uk",
+ "friendlyorbit.cyou",
+ "friendsofbram.com",
+ "frive.site",
+ "frm.ovh",
+ "front14.org",
+ "frostaircon.xyz",
+ "frostamcdubai.xyz",
+ "frostfixit.xyz",
+ "frostmail.fr.nf",
+ "frostsplit.xyz",
+ "frostycoolservice.xyz",
+ "frostyfixit.xyz",
+ "frostyhvacpro.xyz",
+ "frostypeak.info",
+ "frostythermo.xyz",
+ "fruitservice.xyz",
+ "frwdmail.com",
+ "frza.me",
+ "fsercure.online",
+ "fsitip.com",
+ "fsotl.icu",
+ "fthcapital.com",
+ "ftlz.hair",
+ "ftp.sh",
+ "ftpinc.ca",
+ "ftrh.skin",
+ "fuckedupload.com",
+ "fuckingduh.com",
+ "fucklinuxdo.com",
+ "fuckme69.club",
+ "fuckneo.org",
+ "fucknloveme.top",
+ "fuckxxme.top",
+ "fudgerub.com",
+ "fudier.com",
+ "fuirio.com",
+ "fuk996.icu",
+ "fukaru.com",
+ "fukurou.ch",
+ "fullac.xyz",
+ "fullaircond.xyz",
+ "fullangle.org",
+ "fullcoolmaintenance.xyz",
+ "fullfreon.xyz",
+ "fulvie.com",
+ "fumemail.com",
+ "fun4k.com",
+ "fun64.com",
+ "fundapk.com",
+ "fundproceed.com",
+ "funnycodesnippets.com",
+ "funnyfail.app",
+ "funnylolcap.com",
+ "funnylolcap.fr",
+ "funnylolcap.org",
+ "funnylolcap.store",
+ "funnymail.abrdns.com",
+ "funnymail.de",
+ "funteka.com",
+ "furzauflunge.de",
+ "fusioninbox.com",
+ "futcost.online",
+ "futcost.ru",
+ "futuramind.com",
+ "fuvk.ru",
+ "fuvk.store",
+ "fuwa.be",
+ "fuwa.li",
+ "fuwamofu.com",
+ "fuwari.be",
+ "fux0ringduh.com",
+ "fviadropinbox.com",
+ "fviainboxes.com",
+ "fviamail.com",
+ "fviamail.work",
+ "fvz7wvh.ddnsfree.com",
+ "fxavaj.com",
+ "fxnxs.com",
+ "fxos.hair",
+ "fxt.ink",
+ "fxtubes.com",
+ "fxxf.beer",
+ "fxzig.com",
+ "fyawmtu.camdvr.org",
+ "fycloud.online",
+ "fyeiwh.eu.cc",
+ "fyii.de",
+ "fynix.sbs",
+ "fyronex.cfd",
+ "fznb.hair",
+ "g-mail.biz.id",
+ "g-mail.kr",
+ "g0pg02x.theworkpc.com",
+ "g14l71lb.com",
+ "g1xmail.top",
+ "g2xmail.top",
+ "g3xmail.top",
+ "g4hdrop.us",
+ "g947lzu.kozow.com",
+ "gacharevenue.cc",
+ "gafy.net",
+ "gagcalculator.me",
+ "gage.ga",
+ "gailiuzi.icu",
+ "gakhum.com",
+ "galaxim.fr.nf",
+ "galaxy.tv",
+ "galebird.cc.cd",
+ "gally.jp",
+ "gamadaiz.shop",
+ "gamail.top",
+ "gamapa.asia",
+ "gamebcs.com",
+ "gamegregious.com",
+ "gamen.me",
+ "gamepec.com",
+ "gamerai.shop",
+ "gamerai.space",
+ "gamerai.website",
+ "gamgling.com",
+ "gandobaba.bond",
+ "ganlin.shop",
+ "garasikita.pw",
+ "garbagecollector.org",
+ "garbagemail.org",
+ "gardenscape.ca",
+ "gardu.codes",
+ "garizo.com",
+ "garliclife.com",
+ "garod1.xyz",
+ "garrymccooey.com",
+ "gashoainam.io.vn",
+ "gassmail.com",
+ "gasuda.com",
+ "gav0.com",
+ "gavrom.com",
+ "gawab.com",
+ "gaxetovemail.com",
+ "gbcmail.win",
+ "gblw.site",
+ "gbmail.top",
+ "gbui.autos",
+ "gcfv.skin",
+ "gcmail.top",
+ "gcpfas.shop",
+ "gcphanter.skin",
+ "gcpmakerds.cyou",
+ "gcpowner.online",
+ "gcpripon.space",
+ "gcpriponteam.xyz",
+ "gcptoday.xyz",
+ "gdcrm.store",
+ "gddp2018.edu.vn",
+ "gdfgergrer.online",
+ "gdmail.top",
+ "gdmo.autos",
+ "gdqoe.net",
+ "gdxs.cc.cd",
+ "gear3.pro",
+ "gebrauchtwarencenter.com",
+ "gedmail.win",
+ "geekforex.com",
+ "geeksun.us.ci",
+ "geew.ru",
+ "gehensiemirnichtaufdensack.de",
+ "geldwaschmaschine.de",
+ "gelitik.in",
+ "gemapan.com",
+ "gemipro1.store",
+ "gemmarosefoundation.co.uk",
+ "gemniultra.me",
+ "gendeng.indevs.in",
+ "genderfuck.net",
+ "generator.email",
+ "generator1email.com",
+ "gentleinfopath.com",
+ "genzmaile.com",
+ "genzotp.com",
+ "genztrang.com",
+ "geometrics.icu",
+ "georgefletcher.org.uk",
+ "georgeheddon.co.uk",
+ "geronra.com",
+ "gerv.app",
+ "geschent.biz",
+ "get-mail.cf",
+ "get-mail.ga",
+ "get-mail.ml",
+ "get-mail.tk",
+ "get.pp.ua",
+ "get1mail.com",
+ "get2mail.fr",
+ "getai-hub.com",
+ "getairmail.cf",
+ "getairmail.com",
+ "getairmail.ga",
+ "getairmail.gq",
+ "getairmail.ml",
+ "getairmail.tk",
+ "getamccare.xyz",
+ "getasail.com",
+ "getbloomdata.com",
+ "getbox89.xyz",
+ "geteit.com",
+ "getemail.space",
+ "getfun.men",
+ "getgptcode.com",
+ "getmail.my.id",
+ "getmail1.com",
+ "getmailfree.cc",
+ "getmails.eu",
+ "getmails.top",
+ "getmule.com",
+ "getnada.cc",
+ "getnada.com",
+ "getnowtoday.cf",
+ "getol.pro",
+ "getonemail.com",
+ "getonemail.net",
+ "getotp.fun",
+ "getover.de",
+ "getsimpleemail.com",
+ "gett.icu",
+ "getthermo.xyz",
+ "gewalusitest1.cloud-ip.cc",
+ "gexik.com",
+ "gghjnvgfyjhk.top",
+ "ggmal.ml",
+ "ggoop69.kozow.com",
+ "ggrainn.com",
+ "ggu6ctm.ooguy.com",
+ "ggvendas.store",
+ "ggvk.ru",
+ "ggvk.store",
+ "ggxg.fashion",
+ "ggxhunter.com",
+ "ghfnhcf.xyz",
+ "ghk55.us",
+ "gholar.com",
+ "ghosttexter.de",
+ "ghvv.click",
+ "gi-mel.web.id",
+ "giacmosuaviet.info",
+ "giaiphapmuasam.com",
+ "giantmail.de",
+ "giare.fun",
+ "gifto12.com",
+ "gigglefern.lol",
+ "gimbmail.com",
+ "gimme-cooki.es",
+ "gimpmail.com",
+ "gimuemoa.fr.nf",
+ "ginzi.be",
+ "ginzi.co.uk",
+ "ginzi.es",
+ "ginzi.net",
+ "ginzy.co.uk",
+ "ginzy.eu",
+ "giodaingan.com",
+ "gioidev.news",
+ "giratex.com",
+ "girlfriend.ru",
+ "girlmail.win",
+ "girlsindetention.com",
+ "girlsundertheinfluence.com",
+ "gishpuppy.com",
+ "gislinghamsilverband.com",
+ "github-com-disposable-email-domains.loseyourip.com",
+ "giue.hair",
+ "giveh2o.info",
+ "givememail.club",
+ "givemetoken.xin",
+ "givmail.com",
+ "gixenmixen.com",
+ "gkd2323c.in",
+ "gkyd3vu.loseyourip.com",
+ "gladogmi.fr.nf",
+ "glasswhitehub.com",
+ "glastonventures.net",
+ "glaventra.cfd",
+ "glaviu.icu",
+ "glcspp.top",
+ "glenmoorcorp.net",
+ "gli.dpdns.org",
+ "glitch.sx",
+ "globalbizflow.com",
+ "globaltouron.com",
+ "glorylandconnections.com",
+ "gls-info.help",
+ "glubex.com",
+ "glucosegrin.com",
+ "gmaail.icu",
+ "gmaiil.top",
+ "gmail-xsniper.com",
+ "gmail-xsniper.site",
+ "gmail-xsniper.space",
+ "gmail.or.at",
+ "gmail2.gq",
+ "gmailedu.us",
+ "gmailinsta.com",
+ "gmaill.click",
+ "gmaill.vn",
+ "gmaillive.cloud",
+ "gmaillk.com",
+ "gmailold.com",
+ "gmailot.com",
+ "gmailxsn.com",
+ "gmailxsn.site",
+ "gmailxsn.space",
+ "gmailxyz.bond",
+ "gmali.online",
+ "gmatch.org",
+ "gmeenramy.com",
+ "gmel.biz.id",
+ "gmel.my.id",
+ "gmells.com",
+ "gmial.com",
+ "gmneel.eu.cc",
+ "gmx1mail.top",
+ "gmxmail.top",
+ "gmxmail.win",
+ "gnctr-calgary.com",
+ "gng.edu.pl",
+ "gni8.com",
+ "gnwzqaz.shop",
+ "go2farm.io.vn",
+ "go2usa.info",
+ "go2vpn.net",
+ "goacpro.xyz",
+ "goaircool.xyz",
+ "goalmillion.online",
+ "goatmail.uk",
+ "gob.re",
+ "godfare.com",
+ "godulygmail.com",
+ "goemailgo.com",
+ "goeschman.com",
+ "gohivezone.com",
+ "gokublue.me",
+ "golano2.co.uk",
+ "gold.edu.pl",
+ "goldairconrepair.xyz",
+ "goldamc.xyz",
+ "goldamccare.xyz",
+ "goldcoolmaintenance.xyz",
+ "goldcoolservice.xyz",
+ "goldenmagpies.com",
+ "goldenmaint.xyz",
+ "goldinbox.net",
+ "goldivis.work.gd",
+ "goldlicai.com",
+ "goldvester.com",
+ "golemico.com",
+ "goleudy.org.uk",
+ "goliszek.net",
+ "gomail.in",
+ "gomomy.sbs",
+ "gongjua.com",
+ "gonida.co.uk",
+ "gonida.com",
+ "gonida.uk",
+ "gonw3bb.camdvr.org",
+ "goodjzy.art",
+ "goodpetday.me",
+ "googl.win",
+ "goomail.club",
+ "goonby.com",
+ "gooner.cat",
+ "goplaygame.ru",
+ "gorillaswithdirtyarmpits.com",
+ "goround.info",
+ "gosarlar.com",
+ "gossipglove.uk",
+ "gosuslugi-spravka.ru",
+ "gothere.biz",
+ "gotmail.com",
+ "gotmail.net",
+ "gotmail.org",
+ "gotrak.org",
+ "gowcp.com",
+ "gowikibooks.com",
+ "gowikicampus.com",
+ "gowikicars.com",
+ "gowikifilms.com",
+ "gowikigames.com",
+ "gowikimusic.com",
+ "gowikinetwork.com",
+ "gowikitravel.com",
+ "gowikitv.com",
+ "gpt-mail-free-1200.dynv6.net",
+ "gpt-mail-free-140.dynv6.net",
+ "gpt-mail-free-1427.dynv6.net",
+ "gpt-mail-free-1550.dynv6.net",
+ "gpt-mail-free-161.dynv6.net",
+ "gpt-mail-free-1694.dynv6.net",
+ "gpt-mail-free-1721.dynv6.net",
+ "gpt-mail-free-1980.dynv6.net",
+ "gpt-mail-free-1998.dynv6.net",
+ "gpt-mail-free-2019.dynv6.net",
+ "gpt-mail-free-2049.dynv6.net",
+ "gpt-mail-free-2109.dynv6.net",
+ "gpt-mail-free-2141.dynv6.net",
+ "gpt-mail-free-2307.dynv6.net",
+ "gpt-mail-free-2310.dynv6.net",
+ "gpt-mail-free-2375.dynv6.net",
+ "gpt-mail-free-248.dynv6.net",
+ "gpt-mail-free-2491.dynv6.net",
+ "gpt-mail-free-3.dynv6.net",
+ "gpt-mail-free-3074.dynv6.net",
+ "gpt-mail-free-3093.dynv6.net",
+ "gpt-mail-free-3127.dynv6.net",
+ "gpt-mail-free-3291.dynv6.net",
+ "gpt-mail-free-3330.dynv6.net",
+ "gpt-mail-free-3348.dynv6.net",
+ "gpt-mail-free-3476.dynv6.net",
+ "gpt-mail-free-3527.dynv6.net",
+ "gpt-mail-free-366.dynv6.net",
+ "gpt-mail-free-3792.dynv6.net",
+ "gpt-mail-free-3927.dynv6.net",
+ "gpt-mail-free-4111.dynv6.net",
+ "gpt-mail-free-4547.dynv6.net",
+ "gpt-mail-free-4771.dynv6.net",
+ "gpt-mail-free-4775.dynv6.net",
+ "gpt-mail-free-4788.dynv6.net",
+ "gpt-mail-free-4821.dynv6.net",
+ "gpt-mail-free-4900.dynv6.net",
+ "gpt-mail-free-5018.dynv6.net",
+ "gpt-mail-free-5182.dynv6.net",
+ "gpt-mail-free-5211.dynv6.net",
+ "gpt-mail-free-5226.dynv6.net",
+ "gpt-mail-free-5241.dynv6.net",
+ "gpt-mail-free-5261.dynv6.net",
+ "gpt-mail-free-5386.dynv6.net",
+ "gpt-mail-free-5406.dynv6.net",
+ "gpt-mail-free-551.dynv6.net",
+ "gpt-mail-free-5539.dynv6.net",
+ "gpt-mail-free-561.dynv6.net",
+ "gpt-mail-free-5659.dynv6.net",
+ "gpt-mail-free-574.dynv6.net",
+ "gpt-mail-free-5902.dynv6.net",
+ "gpt-mail-free-6067.dynv6.net",
+ "gpt-mail-free-610.dynv6.net",
+ "gpt-mail-free-6221.dynv6.net",
+ "gpt-mail-free-6289.dynv6.net",
+ "gpt-mail-free-6320.dynv6.net",
+ "gpt-mail-free-6342.dynv6.net",
+ "gpt-mail-free-6397.dynv6.net",
+ "gpt-mail-free-6419.dynv6.net",
+ "gpt-mail-free-6603.dynv6.net",
+ "gpt-mail-free-6664.dynv6.net",
+ "gpt-mail-free-6691.dynv6.net",
+ "gpt-mail-free-6779.dynv6.net",
+ "gpt-mail-free-6812.dynv6.net",
+ "gpt-mail-free-6925.dynv6.net",
+ "gpt-mail-free-6962.dynv6.net",
+ "gpt-mail-free-7208.dynv6.net",
+ "gpt-mail-free-7298.dynv6.net",
+ "gpt-mail-free-7493.dynv6.net",
+ "gpt-mail-free-7548.dynv6.net",
+ "gpt-mail-free-7572.dynv6.net",
+ "gpt-mail-free-7582.dynv6.net",
+ "gpt-mail-free-7689.dynv6.net",
+ "gpt-mail-free-7939.dynv6.net",
+ "gpt-mail-free-8015.dynv6.net",
+ "gpt-mail-free-8049.dynv6.net",
+ "gpt-mail-free-8591.dynv6.net",
+ "gpt-mail-free-86.dynv6.net",
+ "gpt-mail-free-861.dynv6.net",
+ "gpt-mail-free-8611.dynv6.net",
+ "gpt-mail-free-8671.dynv6.net",
+ "gpt-mail-free-8773.dynv6.net",
+ "gpt-mail-free-8786.dynv6.net",
+ "gpt-mail-free-8791.dynv6.net",
+ "gpt-mail-free-882.dynv6.net",
+ "gpt-mail-free-8886.dynv6.net",
+ "gpt-mail-free-8933.dynv6.net",
+ "gpt-mail-free-8944.dynv6.net",
+ "gpt-mail-free-902.dynv6.net",
+ "gpt-mail-free-9243.dynv6.net",
+ "gpt-mail-free-9307.dynv6.net",
+ "gpt-mail-free-9446.dynv6.net",
+ "gpt-mail-free-9502.dynv6.net",
+ "gpt-mail-free-9515.dynv6.net",
+ "gpt-mail-free-9584.dynv6.net",
+ "gpt-mail-free-9609.dynv6.net",
+ "gpt-mail-free-9620.dynv6.net",
+ "gpt-mail-free-9695.dynv6.net",
+ "gpt-mail-free-9825.dynv6.net",
+ "gpt-mail-free-9935.dynv6.net",
+ "gpt-mail.myftp.org",
+ "gpt20260701.top",
+ "gptmail.cc.cd",
+ "gptmail.us.ci",
+ "gptmail.webredirect.org",
+ "gptworkone.dev",
+ "gpxmail.win",
+ "gqov.top",
+ "grabafilafoundation.org",
+ "gracetvglobal.com",
+ "grandmamail.com",
+ "grandmasmail.com",
+ "graphicanal.site",
+ "graphicbulk.site",
+ "graphicdany.site",
+ "graphicedge.click",
+ "graphicedge.io.vn",
+ "graphicedge.site",
+ "graphicedge.tokyo",
+ "graphicenda.site",
+ "graphiclean.click",
+ "graphiclean.io.vn",
+ "graphiclean.site",
+ "graphiclean.tokyo",
+ "graphiclens.site",
+ "graphiclever.site",
+ "graphicmen.site",
+ "grassdev.com",
+ "gravexia.cfd",
+ "gravityengine.cc",
+ "great-host.in",
+ "greencafe24.com",
+ "greendike.com",
+ "greenhousemail.com",
+ "greensloth.com",
+ "greenvin.io.vn",
+ "greggamel.com",
+ "greggamel.net",
+ "gregorsky.zone",
+ "gregorygamel.com",
+ "gregorygamel.net",
+ "grendel.qzz.io",
+ "grewq.makeup",
+ "grimshawholdings.net",
+ "grimstroke.me",
+ "grish.de",
+ "gristod.my",
+ "griuc.schule",
+ "grn.cc",
+ "groklan.com",
+ "groupbuff.com",
+ "grqccqbwg.top",
+ "grr.la",
+ "grtd.beer",
+ "gruene-no-thanks.xyz",
+ "grugrug.ru",
+ "gruz-m.ru",
+ "gs-arc.org",
+ "gsoleyfoyle.org.uk",
+ "gsredcross.org",
+ "gsrv.co.uk",
+ "gsttony.shop",
+ "gsxstring.ga",
+ "gthyjuytr.dynv6.net",
+ "gts.cc.cd",
+ "guanshuyun.com",
+ "gudanglowongan.com",
+ "gudri.com",
+ "guerillamail.biz",
+ "guerillamail.com",
+ "guerillamail.de",
+ "guerillamail.info",
+ "guerillamail.net",
+ "guerillamail.org",
+ "guerillamailblock.com",
+ "guerrillamail.biz",
+ "guerrillamail.com",
+ "guerrillamail.de",
+ "guerrillamail.info",
+ "guerrillamail.net",
+ "guerrillamail.org",
+ "guerrillamailblock.com",
+ "gufacoding.com",
+ "gufum.com",
+ "guima2.asia",
+ "gukox.org",
+ "gulfacmaintenance.xyz",
+ "gulfclimate.xyz",
+ "gulfhvacrepair.xyz",
+ "guozivpn.cc.cd",
+ "gustr.com",
+ "guysmail.com",
+ "guzukll.camdvr.org",
+ "gxemail.men",
+ "gxmail.top",
+ "gxybkq6.theworkpc.com",
+ "gyan-netra.com",
+ "gykf.hair",
+ "gyknife.com",
+ "gynzi.co.uk",
+ "gynzi.es",
+ "gzb.ro",
+ "gzeos.com",
+ "h0tmaii.com",
+ "h2beta.com",
+ "h8s.org",
+ "ha92.store",
+ "haadishah.co.uk",
+ "haben-wir.com",
+ "habenwir.com",
+ "habitnestguide.com",
+ "habitue.net",
+ "hacccc.com",
+ "hackersquad.tk",
+ "hackthatbit.ch",
+ "hahawrong.com",
+ "haibabon.com",
+ "haibaipei.ccwu.cc",
+ "haida-edu.cn",
+ "hailu.publicvm.com",
+ "hairs24.ru",
+ "halamaridfcfs.site",
+ "halashao1.dpdns.org",
+ "halashao3.us.ci",
+ "halcroft.org",
+ "haleloya.site",
+ "halionex.cfd",
+ "haltospam.com",
+ "hamham.uk",
+ "handrik.com",
+ "handsofhelpinc.org",
+ "handymandubaimarina.xyz",
+ "handymanfestivalcity.xyz",
+ "hanghai232.shop",
+ "hangxomcuatoilatotoro.ml",
+ "hanha.kozow.com",
+ "hanoinet.io.vn",
+ "happiseektest.com",
+ "happy-new-year.top",
+ "happy2023year.com",
+ "happy9toy.com",
+ "happydomik.ru",
+ "happyheadshed.org",
+ "happyhome.eu.cc",
+ "happyhorsespace.shop",
+ "haqimi.cc.cd",
+ "harakirimail.com",
+ "hardwork.icu",
+ "haren.uk",
+ "haribu.com",
+ "harpix.org.uk",
+ "hartbot.de",
+ "harthwaite.net",
+ "hasanmail.ml",
+ "hat-geld.de",
+ "hatespam.org",
+ "hathitrannhien.edu.vn",
+ "havefundarling.xyz",
+ "havertz.tk",
+ "hawrong.com",
+ "haydoo.com",
+ "hayys.app",
+ "hazelnut4u.com",
+ "hazelnuts4u.com",
+ "hazmat.help",
+ "hazmatshipping.org",
+ "hcap.ai",
+ "hccmail.win",
+ "hcypku.cloud",
+ "hdcroom.us",
+ "hdiscord.xyz",
+ "headerprism.me",
+ "headstrong.de",
+ "healingheartmission.org",
+ "healthforwomen.info",
+ "healxo.org",
+ "heathenhammer.com",
+ "heathenhero.com",
+ "hebase.com",
+ "hecat.es",
+ "heheee.com",
+ "heirfoundation.com",
+ "heisei.be",
+ "hellodns.site",
+ "hellodream.mobi",
+ "helloricky.com",
+ "hellosravan.in",
+ "helpinghandtaxcenter.org",
+ "helpjobs.ru",
+ "helpthechildren.uk",
+ "heocondeisng.cc",
+ "herclan.com",
+ "herilev.top",
+ "herlan.co.uk",
+ "heros3.com",
+ "herp.in",
+ "herpderp.nl",
+ "hertz.ltd",
+ "hesoyam.shop",
+ "hethongtudong.store",
+ "hetzez.com",
+ "heycmm.cn",
+ "heyzalo.vn",
+ "hezll.com",
+ "hfogzua.top",
+ "hh7f.com",
+ "hhan.top",
+ "hhe.org.uk",
+ "hhyru.us",
+ "hi2.in",
+ "hi5.si",
+ "hiddentragedy.com",
+ "hideam.com",
+ "hidebox.org",
+ "hidebusiness.xyz",
+ "hidemail.de",
+ "hidemail.pro",
+ "hidemail.us",
+ "hidesmail.net",
+ "hidevak.com",
+ "hidingmail.net",
+ "hidmail.org",
+ "hidzz.com",
+ "hiemail.net",
+ "hieu.in",
+ "hieuclone.net",
+ "highapes.run.place",
+ "highbros.org",
+ "higher-tech.linkpc.net",
+ "highnestle.top",
+ "highstar.shop",
+ "hightech.jo3.org",
+ "higogoya.com",
+ "higuruma.site",
+ "hihi.lol",
+ "hikuhu.com",
+ "hillaxiant.xyz",
+ "hilltopcove.top",
+ "hilmipremindo.com",
+ "hilostar.com",
+ "hiltonvr.com",
+ "him6.com",
+ "himacreative.id",
+ "himail.infos.st",
+ "himail.online",
+ "hiredify.com",
+ "hiringview.com",
+ "histartool.com",
+ "hitbase.net",
+ "hitbtcpool.cloud",
+ "hiverun.io",
+ "hjdkker-kdet.click",
+ "hkuv.skin",
+ "hkvtop.us",
+ "hldrive.com",
+ "hlkes.com",
+ "hmail.us",
+ "hmamail.com",
+ "hmh.ro",
+ "hnfw-ngd.top",
+ "hnfw-ngd.wiki",
+ "hoanggiaanh.com",
+ "hoanglong.tech",
+ "hochsitze.com",
+ "hohohim.com",
+ "hoichvu.ddnsfree.com",
+ "hokyflix.site",
+ "holio.day",
+ "holl.ga",
+ "holmes.de5.net",
+ "homeacpro.xyz",
+ "homeairconfix.xyz",
+ "homecoolair.xyz",
+ "homecoolservice.xyz",
+ "homecut.pro",
+ "homehappy.eu.cc",
+ "homesrepair.xyz",
+ "homesservicesdubai.xyz",
+ "homestartbromley.org.uk",
+ "homewiseleaf.com",
+ "homewrt.xx.kg",
+ "homvela.com",
+ "honesthirianinda.net",
+ "honesthvaccare.xyz",
+ "honesthvacpro.xyz",
+ "honestinterest.bond",
+ "honeydaybook.cyou",
+ "honeyparcels.shop",
+ "honeys.be",
+ "hongsemeixi.us.ci",
+ "honor-8.com",
+ "hook2ad.com",
+ "hookmono.click",
+ "hooooooo.store",
+ "hopefunder.com",
+ "hopemail.biz",
+ "hopesx.com",
+ "horizonspost.com",
+ "hormails.com",
+ "hornhorizon.com",
+ "hornyalwary.top",
+ "horsgit.com",
+ "host1s.com",
+ "hostcalls.com",
+ "hostguru.top",
+ "hosthalting.lol",
+ "hosthalting.site",
+ "hosthalting.store",
+ "hostingmail.me",
+ "hostlaba.com",
+ "hostpapa.dynv6.net",
+ "hot-mail.cf",
+ "hot-mail.ga",
+ "hot-mail.gq",
+ "hot-mail.ml",
+ "hot-mail.tk",
+ "hotboyckdh.shop",
+ "hotboyckdh1122.shop",
+ "hotboyckdhn55.shop",
+ "hotelpiesi.pro",
+ "hotelplazer.com",
+ "hotelplazer.fr",
+ "hotelplazer.org",
+ "hotelplazer.store",
+ "hotlinkimg.com",
+ "hotmai.com",
+ "hotmail-us.top",
+ "hotmailproduct.com",
+ "hotmailtrusted.com",
+ "hotmeil.net",
+ "hotmial.com",
+ "hotpop.com",
+ "hotprice.co",
+ "hotrokh.com",
+ "hotsmial.click",
+ "hotsoup.be",
+ "hottchurch.org.uk",
+ "hottmat.com",
+ "housat.com",
+ "houseaircon.xyz",
+ "houseclimate.xyz",
+ "housereformas.es",
+ "howardgiving.org",
+ "hpari.com",
+ "hpc.tw",
+ "hpj4l28.loseyourip.com",
+ "hqfi.hair",
+ "hqid.homes",
+ "hqpdf.com",
+ "hripleh.us.ci",
+ "hs.vc",
+ "hsmw.net",
+ "ht.cx",
+ "hta.2bd.net",
+ "hta.publicvm.com",
+ "htgb.help",
+ "hthlm.com",
+ "htmail.store",
+ "http.nyc.mn",
+ "httpsgreenwichmeantime.in",
+ "huage.me",
+ "hualabtech.com",
+ "huangao.xyz",
+ "huangniu8.com",
+ "hubaiclass.org",
+ "hubglee.com",
+ "hubhost.store",
+ "hudisk.com",
+ "hudska.dpdns.org",
+ "hudzer.com",
+ "huizk.com",
+ "hukkmu.tk",
+ "hulapla.de",
+ "hulisang.edu.kg",
+ "hulkteam.cyou",
+ "huluwaaaron.eu.cc",
+ "humaility.com",
+ "hungpackage.com",
+ "hungtpt.site",
+ "hunnur.com",
+ "huolala.de5.net",
+ "huongdanfb.com",
+ "huongvemientrung.store",
+ "huoyumars.cc.cd",
+ "hushmail.cf",
+ "huskion.net",
+ "husng-kang.top",
+ "hutathuww.org",
+ "hutudns.com",
+ "huychau.click",
+ "huychau.online",
+ "huynhlx.shop",
+ "huynhtran.io.vn",
+ "huytuyen.click",
+ "huytuyen.online",
+ "huytuyen.site",
+ "huyvillafb.online",
+ "hvaccaredubai.xyz",
+ "hvaccaredxb.xyz",
+ "hvaccheckupuae.xyz",
+ "hvaccitywalk.xyz",
+ "hvaccompanydxb.xyz",
+ "hvaccontractorsdxb.xyz",
+ "hvaccrewuae.xyz",
+ "hvacdeskdubai.xyz",
+ "hvacdubaicreek.xyz",
+ "hvacexpertdubai.xyz",
+ "hvacexpertsuae.xyz",
+ "hvachubdxb.xyz",
+ "hvacinstallations247.xyz",
+ "hvacinstalldubai.xyz",
+ "hvacmotorcity.xyz",
+ "hvacpointdxb.xyz",
+ "hvacprosdxb.xyz",
+ "hvacrepairuae.xyz",
+ "hvacsatwa.xyz",
+ "hvacservicedubai.xyz",
+ "hvacservicesdubai.xyz",
+ "hvacservicingdubai.xyz",
+ "hvacsolutionsdubai.xyz",
+ "hvacspecialistuae.xyz",
+ "hvacsportshub.xyz",
+ "hvactechdxb.xyz",
+ "hvacunitdxb.xyz",
+ "hvacworksuae.xyz",
+ "hvaczonedubai.xyz",
+ "hvastudiesucces.nl",
+ "hvhg.skin",
+ "hviy.skin",
+ "hvva.hair",
+ "hwax4h4.giize.com",
+ "hwqmh2g.ddnsfree.com",
+ "hwsye.net",
+ "hxopi.ru",
+ "hxopi.store",
+ "hygle.net",
+ "hyhotmail.ccwu.cc",
+ "hymenjj.cfd",
+ "hypenated-domain.com",
+ "hyproxy.lol",
+ "i-dork.com",
+ "i-love-you-3000.net",
+ "i0mn8cx.mywire.org",
+ "i2b.asia",
+ "i2b.cam",
+ "i2b.com.vn",
+ "i2b.email",
+ "i2b.fun",
+ "i2b.id.vn",
+ "i2b.info",
+ "i2b.io.vn",
+ "i2b.lol",
+ "i2b.lu",
+ "i2b.mov",
+ "i2b.pw",
+ "i2b.space",
+ "i2b.vn",
+ "i2bgmail.com",
+ "i2c.email",
+ "i2c.pw",
+ "i2c.vn",
+ "i2i.vn",
+ "i2m.email",
+ "i2m.vn",
+ "i2n.vn",
+ "i2p.vn",
+ "i2pmail.org",
+ "i2u.vn",
+ "i2x.vn",
+ "i6.cloudns.cc",
+ "iafu.hair",
+ "iafu.skin",
+ "iandsec.cc.cd",
+ "iaoss.com",
+ "ibande.xyz",
+ "ibmdoors.com",
+ "ibmdoors.com.cn",
+ "ibnuh.bz",
+ "ibolinva.com",
+ "ibymail.com",
+ "icantbelieveineedtoexplainthisshit.com",
+ "icbr.us",
+ "iceans.cc.cd",
+ "icelandgcp.org.uk",
+ "icemail.club",
+ "ich-essen-fleisch.bio",
+ "ichecker.tech",
+ "ichigo.me",
+ "icidroit.info",
+ "ickletonrelief.co.uk",
+ "ickletonrelief.shop",
+ "icloudmail.kr",
+ "icloulb.com",
+ "icmans.com",
+ "iconcubic.com",
+ "iconmal.com",
+ "icotz.com",
+ "icousd.com",
+ "icubik.com",
+ "icwr.beer",
+ "icx.in",
+ "icx.ro",
+ "icznn.com",
+ "ideuse.com",
+ "idf.ovh",
+ "idfd.live",
+ "idg.io.vn",
+ "idns7v8.ooguy.com",
+ "idont.date",
+ "idu4aa4.info",
+ "idx4.com",
+ "idxue.com",
+ "ieatspam.eu",
+ "ieatspam.info",
+ "ieh-mail.de",
+ "iencm.com",
+ "ifeq.hair",
+ "iffymedia.com",
+ "ifoxdd.com",
+ "ige.es",
+ "igg.biz",
+ "ignoremail.com",
+ "igty.autos",
+ "ihateyoualot.info",
+ "ihazspam.ca",
+ "ihc3znb.theworkpc.com",
+ "iheartspam.org",
+ "ihnpo.food",
+ "ihra.hair",
+ "ikbenspamvrij.nl",
+ "ikewe.com",
+ "ikomail.com",
+ "ikuromi.com",
+ "ilck.hair",
+ "ilcvn.cn",
+ "illistnoise.com",
+ "illnessth.com",
+ "illubd.com",
+ "iloveion.com",
+ "ilovespam.com",
+ "ilyx7j5.theworkpc.com",
+ "im5z.com",
+ "imaanpharmacy.com",
+ "imagenbox.xyz",
+ "imagic.wiki",
+ "imail.edu.vn",
+ "imail1.net",
+ "imails.asso.st",
+ "imails.info",
+ "imailt.com",
+ "imap.fr.nf",
+ "imashr.com",
+ "imbo.hair",
+ "imc.io.vn",
+ "imfaya.com",
+ "img-free.com",
+ "imgof.com",
+ "imgv.de",
+ "immo-gerance.info",
+ "imnart.com",
+ "imperialcnk.com",
+ "importemail.com",
+ "imranbt.store",
+ "imranccnn.buzz",
+ "imrancm.buzz",
+ "imrancumi.buzz",
+ "imranking.buzz",
+ "imranmax.buzz",
+ "imranspc.buzz",
+ "imstations.com",
+ "imul.info",
+ "in-ulm.de",
+ "in2reach.com",
+ "inactivemachine.com",
+ "inbax.tk",
+ "inbound.plus",
+ "inbox.si",
+ "inbox2.info",
+ "inboxalias.com",
+ "inboxbear.com",
+ "inboxclean.com",
+ "inboxclean.org",
+ "inboxdesign.me",
+ "inboxed.im",
+ "inboxed.pw",
+ "inboxfast.space",
+ "inboxfly.space",
+ "inboxkitten.com",
+ "inboxly.website",
+ "inboxnow.ru",
+ "inboxnow.store",
+ "inboxopen.space",
+ "inboxorigin.com",
+ "inboxproxy.com",
+ "inboxstore.me",
+ "inclick.net",
+ "inclusiveprogress.com",
+ "incognitomail.com",
+ "incognitomail.net",
+ "incognitomail.org",
+ "incq.com",
+ "inctart.com",
+ "ind.st",
+ "inderpalsandhu.com",
+ "indevgo.com",
+ "index-mail.com",
+ "indianahorsecouncil.org",
+ "indieclad.com",
+ "indigobook.com",
+ "indirect.ws",
+ "indomaed.pw",
+ "indomina.cf",
+ "indoserver.stream",
+ "indosukses.press",
+ "indoxs.bond",
+ "indsec.online",
+ "ineec.net",
+ "inetopenwrt.dpdns.org",
+ "infix1.io.vn",
+ "inflyratech.site",
+ "infobuzzsite.com",
+ "infocom.zp.ua",
+ "infoisp.me",
+ "infolinewest.com",
+ "ingam.online",
+ "inggo.org",
+ "inilogic.com",
+ "init.ink",
+ "inkiny.com",
+ "inkomail.com",
+ "inlook.cloud",
+ "inly.vn",
+ "inmune.ddns.net",
+ "inmynetwork.tk",
+ "innovationvirtuelle.asia",
+ "inoutmail.de",
+ "inoutmail.eu",
+ "inoutmail.info",
+ "inoutmail.net",
+ "inpwa.com",
+ "inreur.com",
+ "insanony.art",
+ "insanony.one",
+ "insanony.store",
+ "insanumingeniumhomebrew.com",
+ "insgrmail.site",
+ "inshuan.com",
+ "insorg-mail.info",
+ "instaddr.ch",
+ "instaddr.uk",
+ "instaddr.win",
+ "instagr.vn",
+ "instagra.vn",
+ "instagram.io.vn",
+ "installalquoz.xyz",
+ "installdubai.xyz",
+ "installkarama.xyz",
+ "installmiddleeast.xyz",
+ "installmydubai.xyz",
+ "installwarsan.xyz",
+ "instance-email.com",
+ "instant-mail.de",
+ "instantaircon.xyz",
+ "instantblingmail.info",
+ "instantemailaddress.com",
+ "instantfreon.xyz",
+ "instantletter.net",
+ "instantmail.fr",
+ "instantrepairs.xyz",
+ "instmail.uk",
+ "instrete.com",
+ "intady.com",
+ "interestgrove.bond",
+ "internet-v-stavropole.ru",
+ "internetkeno.com",
+ "internetoftags.com",
+ "interstats.org",
+ "intersteller.com",
+ "intlgalleryheritageculture.org",
+ "intobx.com",
+ "intopwa.com",
+ "intopwa.net",
+ "intopwa.org",
+ "introex.com",
+ "inversiq.cfd",
+ "investore.co",
+ "inwagit.com",
+ "inxout.dpdns.org",
+ "ioioioio.sbs",
+ "iojn.hair",
+ "iolq.top",
+ "ionolenonos.com",
+ "ionolenonos.fr",
+ "ionolenonos.info",
+ "ionolenonos.store",
+ "ionq.pl",
+ "iotf.net",
+ "iozak.com",
+ "ip4.pp.ua",
+ "ip6.pp.ua",
+ "ipaddressforme.com",
+ "ipinf.eu.org",
+ "ipoo.org",
+ "ippandansei.tk",
+ "ipsur.org",
+ "ipusgo.cfd",
+ "ipxwan.com",
+ "iqec.skin",
+ "iqfucsod.top",
+ "iqxu.hair",
+ "irabops.com",
+ "iralborz.bid",
+ "iraniandsa.org",
+ "iraqedu.online",
+ "irc.so",
+ "irealiy.cyou",
+ "iridales.com",
+ "irinakicka.site",
+ "irish2me.com",
+ "irishspringrealty.com",
+ "iroid.com",
+ "ironbrookco.com",
+ "ironiebehindert.de",
+ "irpine.com",
+ "irssi.tv",
+ "is.af",
+ "isdaq.com",
+ "isep.fr.nf",
+ "isfew.com",
+ "ishop2k.com",
+ "ismartsense.online",
+ "isosq.com",
+ "ispeedtest.digital",
+ "ist-hier.com",
+ "istii.ro",
+ "isukrainestillacountry.com",
+ "iswin.ccwu.cc",
+ "it7.ovh",
+ "italy-mail.com",
+ "itaolo.com",
+ "itcompu.com",
+ "itduniya.online",
+ "itfast.net",
+ "itit.tk",
+ "itmj3po.ddnsfree.com",
+ "itmo.edu.pl",
+ "itos.cloudns.org",
+ "itquoted.com",
+ "itsbds.com",
+ "itsedit.click",
+ "itsjiff.com",
+ "itspid.com",
+ "itunesgiftcodegenerator.com",
+ "iuanhoi.store",
+ "iubridge.com",
+ "iuemail.men",
+ "iuno.fun",
+ "iupo219.ddnsfree.com",
+ "ivpnxoo.cfd",
+ "ivythorn.net",
+ "iwatermail.com",
+ "iwx4k98.theworkpc.com",
+ "ixaks.com",
+ "ixhale.com",
+ "ixqr.hair",
+ "ixunbo.com",
+ "ixx.io",
+ "iya.fr.nf",
+ "iysqv.com",
+ "izhowto.com",
+ "izunasan.my.id",
+ "izunasuite.biz.id",
+ "j-p.us",
+ "j3r8qd.space",
+ "j5bie4.tech",
+ "jabmag.com",
+ "jackierobinsonparkoffame.org",
+ "jafps.com",
+ "jaga.email",
+ "jagomail.com",
+ "jailbreakeverything.com",
+ "jaipas.lu",
+ "jajxz.com",
+ "jakarta.io.vn",
+ "jakemsr.com",
+ "jakl.li",
+ "jamesat.biz.id",
+ "jamesat.my.id",
+ "jamesjfattorini.rocks",
+ "jamesthynnealmshouse.co.uk",
+ "jamyjmkqdsc.com",
+ "janestore.xyz",
+ "janproz.com",
+ "japanesegirls.cc",
+ "japnc.com",
+ "jaqis.com",
+ "jarszone.com",
+ "jasminesports.com",
+ "jasonbella.online",
+ "jasonswheels.org",
+ "jav8.de",
+ "javadmin.com",
+ "javaemail.com",
+ "javaka.live",
+ "javindra.cfd",
+ "jazzvip.site",
+ "jbsu.hair",
+ "jbsze.com",
+ "jbsze.ne",
+ "jbsze.net",
+ "jc58t04.kozow.com",
+ "jcnorris.com",
+ "jdmadventures.com",
+ "jdz.ro",
+ "je-recycle.info",
+ "jejes.de",
+ "jejesfb.biz.id",
+ "jellow.ml",
+ "jellycat.eu.cc",
+ "jellyraccoon.lol",
+ "jellyrolls.com",
+ "jeoce.com",
+ "jeremytunnell.net",
+ "jessitrust.com",
+ "jessyx.bond",
+ "jet-renovation.fr",
+ "jetable.com",
+ "jetable.fr.nf",
+ "jetable.net",
+ "jetable.org",
+ "jetable.pp.ua",
+ "jgjitffy.store",
+ "jgkcr.com",
+ "jhfnre-kjf.sbs",
+ "jhkf.beer",
+ "jhphoto.top",
+ "ji-a.cc",
+ "ji5.de",
+ "ji6.de",
+ "ji7.de",
+ "jiangwy.one",
+ "jiaoo.pp.ua",
+ "jiaxi.work",
+ "jibbo01.com",
+ "jieluv.com",
+ "jika.gg",
+ "jilingling.eu.cc",
+ "jincer.com",
+ "jingood.dpdns.org",
+ "jinkeyun.com",
+ "jinva.fr.nf",
+ "jiongguo.top",
+ "jiooq.com",
+ "jioso.com",
+ "jisan.uk",
+ "jisankhan1.site",
+ "jiuji.asia",
+ "jjfcharitabletrust.co.uk",
+ "jjice.com",
+ "jjj.ee",
+ "jjk.app",
+ "jjlj.fashion",
+ "jjmj.fashion",
+ "jjuisyj.top",
+ "jjxj.fashion",
+ "jjxt6lv.mywire.org",
+ "jkotypc.com",
+ "jksndh-afd2.wiki",
+ "jlpevents.co.uk",
+ "jmail.fr.nf",
+ "jmail.ovh",
+ "jmail.ro",
+ "jmods.me",
+ "jmt2469.xyz",
+ "jnad.skin",
+ "jndp.hair",
+ "jnwexkd.giize.com",
+ "jnxjn.com",
+ "jobbikszimpatizans.hu",
+ "jobbrett.com",
+ "jobdatei.de",
+ "jobposts.net",
+ "jobs-to-be-done.net",
+ "jobscai.com",
+ "jobsma.pp.ua",
+ "jobzyy.com",
+ "joelpet.com",
+ "joetestalot.com",
+ "jofuso.com",
+ "johorulcloud.space",
+ "joihonm.shop",
+ "jojolt.shop",
+ "jojomedia.store",
+ "jonira.sbs",
+ "jonsjav.cc",
+ "jopho.com",
+ "jora.dpdns.org",
+ "joseihorumon.info",
+ "josse.ltd",
+ "jourrapide.com",
+ "joymail.cyou",
+ "joywavepoint.com",
+ "jp-ml.com",
+ "jpco.org",
+ "jrhmail.com",
+ "jsifh5i.kozow.com",
+ "jsnc.cloud",
+ "jsncos.com",
+ "jsonlark.me",
+ "jsonmender.me",
+ "jsrsolutions.com",
+ "jualakun.com",
+ "jualakunfb.co",
+ "jualfb.co",
+ "juicehour.com",
+ "jumeirahmaintenance.com",
+ "jumeirahparkmaintenance.com",
+ "jumonji.tk",
+ "jungkamushukum.com",
+ "junk.to",
+ "junk1e.com",
+ "junkmail.ga",
+ "junkmail.gq",
+ "just-email.com",
+ "justdefinition.com",
+ "justemail.ml",
+ "juvanta.cfd",
+ "juyouxi.com",
+ "jvcmaintenance.com",
+ "jvtmaintenance.com",
+ "jwork.ru",
+ "jxin.hair",
+ "jxpomup.com",
+ "jywa.social",
+ "jzlvoei.cn",
+ "jzlz.hair",
+ "jzzm.top",
+ "k-global.dev",
+ "k4money.com",
+ "kabamail.com",
+ "kabarr.com",
+ "kabinbilla.com",
+ "kademen.com",
+ "kadokawa.cf",
+ "kadokawa.ga",
+ "kadokawa.gq",
+ "kadokawa.ml",
+ "kadokawa.tk",
+ "kaengu.ru",
+ "kagi.be",
+ "kajaib.social",
+ "kaka0.kr",
+ "kakadua.net",
+ "kakao-mail.com",
+ "kakaomail.kr",
+ "kakator.com",
+ "kakaveo3.io.vn",
+ "kakslsie.store",
+ "kalapi.org",
+ "kalquoris.cfd",
+ "kalwa.fun",
+ "kamen-market.ru",
+ "kamete.org",
+ "kampret.asia",
+ "kamsg.com",
+ "kankoplm.eu.cc",
+ "kanlangld.shop",
+ "kanonmail.com",
+ "kantclass.com",
+ "kaoing.com",
+ "kaovo.com",
+ "kappala.info",
+ "kapten.site",
+ "kaptencpl.com",
+ "kara-turk.net",
+ "karatraman.ml",
+ "karenkey.com",
+ "kariplan.com",
+ "karta-kykyruza.ru",
+ "kartvelo.com",
+ "kashre.com",
+ "kasmail.com",
+ "kasmirfx.buzz",
+ "kaspop.com",
+ "katalyst.indevs.in",
+ "kathrynis.my.id",
+ "kathrynis.web.id",
+ "katputli.world",
+ "kattthu.us",
+ "katztube.com",
+ "kavio.my.id",
+ "kaw-sar.top",
+ "kawneha.site",
+ "kaybooks.top",
+ "kayfilms.top",
+ "kaygroup.top",
+ "kayilo.com",
+ "kaynlab.com",
+ "kazava.tech",
+ "kazelink.ml",
+ "kbishwanathuk.cc",
+ "kbox.li",
+ "kcoporation.com",
+ "kcql.hair",
+ "kcrw.de",
+ "kcya.skin",
+ "kd2.org",
+ "kdpsl.org",
+ "ke1le.cyou",
+ "kee40vq.ddnsfree.com",
+ "keecs.com",
+ "keepmail.dev",
+ "keepmymail.com",
+ "kein.date",
+ "keinhirn.de",
+ "keipino.de",
+ "kejinxi.xyz",
+ "kekita.com",
+ "kelenson.com",
+ "kelimo.sbs",
+ "kellychibale-researchgroup-uct.com",
+ "kemail.uk",
+ "kempstoncharities.band",
+ "kempstoncharities.co.uk",
+ "kemptvillebaseball.com",
+ "kenari.online",
+ "kendev.id.vn",
+ "kenfern.com",
+ "kenhbanme.com",
+ "kenhphim.net",
+ "kenvanharen.com",
+ "kepkat.com",
+ "kepkat.site",
+ "kerotu.com",
+ "ket-qua.org",
+ "ketimunfam.online",
+ "ketua.id",
+ "kgfu.makeup",
+ "khaibn.com",
+ "khamu.me",
+ "khoke.nl",
+ "khtextile.com",
+ "kia-sdn.me",
+ "kiani.com",
+ "kidaroa.com",
+ "kiecchn.com",
+ "kierko.com",
+ "kiki287666.xyz",
+ "killlove.site",
+ "killmail.com",
+ "killmail.net",
+ "killyourtime.com",
+ "kilverstone.net",
+ "kimagure.ccwu.cc",
+ "kimagure1.cc.cd",
+ "kimdyn.com",
+ "kimgmail.com",
+ "kimora.space",
+ "kimsdisk.com",
+ "kimzavin.shop",
+ "kinda.email",
+ "kindamail.com",
+ "kindnessjunction.sbs",
+ "kindredpicnic.cyou",
+ "kinfolkcanvas.sbs",
+ "kingdais.com",
+ "kingproplan.site",
+ "kingsholme.net",
+ "kingsq.ga",
+ "kingss.de5.net",
+ "kino-100.ru",
+ "kintil.buzz",
+ "kiois.com",
+ "kismail.ru",
+ "kisoq.com",
+ "kissgy.com",
+ "kisstwink.com",
+ "kitap.az",
+ "kitnastar.com",
+ "kiwkiw.shop",
+ "kjhg.hair",
+ "kjkpc.net",
+ "kjkszpjcompany.com",
+ "kjnbtv.dedyn.io",
+ "kk.io.vn",
+ "kkdty.dynv6.net",
+ "kkkoouy.cyou",
+ "kkmail.be",
+ "kkoup.com",
+ "kksm.be",
+ "klassmaster.com",
+ "klassmaster.net",
+ "kleinanzeigen-verification.de",
+ "klick-tipp.us",
+ "kliknesia.io",
+ "klingai.asia",
+ "klipschx12.com",
+ "kloap.com",
+ "klovenode.com",
+ "kludgemush.com",
+ "klut.io",
+ "klyverra.cfd",
+ "klzlk.com",
+ "kmail.li",
+ "kmail.live",
+ "kmail.qzz.io",
+ "kmail123.com",
+ "kmhow.com",
+ "knaresboroughtownmuseum.org",
+ "knickerbockerban.de",
+ "knilok.com",
+ "knmcadibav.com",
+ "knol-power.nl",
+ "knowledgemd.com",
+ "kobrandly.com",
+ "kodeholik.site",
+ "kodpan.com",
+ "kokalo.store",
+ "kokklsd.dpdns.org",
+ "koletter.com",
+ "kommunity.biz",
+ "kon42.com",
+ "konican.com",
+ "konigyuan.us.ci",
+ "konterkulo.com",
+ "kontoko.org",
+ "konultant-jurist.ru",
+ "kook.ml",
+ "kopagas.com",
+ "kopaka.net",
+ "korimoddin.store",
+ "korona-nedvizhimosti.ru",
+ "korvanta.cfd",
+ "koshu.ru",
+ "kosmetik-obatkuat.com",
+ "kostenlosemailadresse.de",
+ "koszmail.pl",
+ "kpay.be",
+ "kpmn-design.org",
+ "kpooa.com",
+ "kpost.be",
+ "kppk.beer",
+ "kpxj.hair",
+ "kpxj.skin",
+ "kqbz.beer",
+ "kqhmln6.theworkpc.com",
+ "kravintis.cfd",
+ "krd.ag",
+ "kreabaka.site",
+ "krouter.net",
+ "krsw.tk",
+ "kruay.com",
+ "krypton.tk",
+ "krysentra.cfd",
+ "kskblzdjdwkzkbl.top",
+ "ksmtrck.tk",
+ "kswm.beer",
+ "ktisocial.asia",
+ "ktpingprox.id.vn",
+ "kuhaku.indevs.in",
+ "kuhrap.com",
+ "kuku.lu",
+ "kulmeo.com",
+ "kulturbetrieb.info",
+ "kumli.racing",
+ "kurin.sbs",
+ "kuromee.com",
+ "kuromee.store",
+ "kurumibnb.dog",
+ "kurzepost.de",
+ "kutakbisajauhjauh.gq",
+ "kv2vpn.eu.org",
+ "kvbs.hair",
+ "kvhrr.com",
+ "kvhrs.com",
+ "kvhrw.com",
+ "kwifa.com",
+ "kwift.net",
+ "kwilco.net",
+ "kwtest.io",
+ "kxbj.beer",
+ "kyal.pl",
+ "kynninc.com",
+ "kyntaris.cfd",
+ "kyois.com",
+ "kyriog.fr",
+ "kyuusei.fr.nf",
+ "kyx9sgz.loseyourip.com",
+ "kzccv.com",
+ "l-c-a.us",
+ "l2gyw37.mywire.org",
+ "l2rrz31.ddnsfree.com",
+ "l33r.eu",
+ "l6factors.com",
+ "laafd.com",
+ "labananmakmur.com",
+ "labetteraverouge.at",
+ "labforge.freeddns.org",
+ "laborderie.com",
+ "labworld.org",
+ "lacedmail.com",
+ "lackmail.net",
+ "lackmail.ru",
+ "lacraffe.fr.nf",
+ "lacto.info",
+ "lags.us",
+ "laicai8.sbs",
+ "lain.ch",
+ "lak.pp.ua",
+ "lakeground.shop",
+ "lakelivingstonrealestate.com",
+ "lakqs.com",
+ "laksamantunggalakma.me",
+ "lalayun.cyou",
+ "lalulahoot.me",
+ "lamasticots.com",
+ "lambsauce.de",
+ "landmail.co",
+ "lanstudio.top",
+ "lanuiopl.bond",
+ "laoeq.com",
+ "laoia.com",
+ "lapakqu.com",
+ "lapluz.xyz",
+ "lappies.net",
+ "laptoplonghai.com",
+ "larisia.com",
+ "larland.com",
+ "lashyd.com",
+ "last-chance.pro",
+ "laste.ml",
+ "lastexile.site",
+ "lastmail.co",
+ "lastmail.com",
+ "laulund.info",
+ "launchpadx.shop",
+ "launders.money",
+ "lawlita.com",
+ "laxex.ru",
+ "laxex.store",
+ "laxiw.org",
+ "layanmaintenance.com",
+ "laymro.com",
+ "lazerbade.com",
+ "lazerbade.fr",
+ "lazerbade.org",
+ "lazerbade.store",
+ "lazyinbox.com",
+ "lazyinbox.us",
+ "lbrmail666.site",
+ "lbvz.homes",
+ "lcvfdvynb.top",
+ "ldaho.biz",
+ "ldkll.cn",
+ "ldofy2e.ooguy.com",
+ "ldop.com",
+ "ldtp.com",
+ "le-tim.ru",
+ "leadwizzer.com",
+ "leafflip.com",
+ "lebetrust.org",
+ "lecn.beer",
+ "lee.mx",
+ "leechchannel.com",
+ "leeching.net",
+ "leetmail.co",
+ "lefruit-dev.work",
+ "legalrc.loan",
+ "legendaryseller.com",
+ "leie.me",
+ "lellno.gq",
+ "lellol.dev",
+ "lellol.tech",
+ "lemonkk.cn",
+ "lenovog4.com",
+ "leoguio.site",
+ "leopik.com",
+ "lerany.com",
+ "lerbhe.com",
+ "lerch.ovh",
+ "lero3.com",
+ "leruvo.icu",
+ "lesobprovermail.com",
+ "lessismore.eu.cc",
+ "letterhaven.net",
+ "letterprotect.net",
+ "lettersboxmail.com",
+ "letthemeatspam.com",
+ "levi.cc.cd",
+ "leviacerman.store",
+ "lez.se",
+ "lfsz.beer",
+ "lftl.hair",
+ "lgxscreen.com",
+ "lhsdv.com",
+ "liadhene.com",
+ "liamcyrus.com",
+ "liang-tts.shop",
+ "liangganxiaopu.bond",
+ "libinit.com",
+ "licccons.icu",
+ "licenses.sbs",
+ "lifebitsnotes.com",
+ "lifebyfood.com",
+ "lifetimefriends.info",
+ "lifetotech.com",
+ "lifetreechurch.co.uk",
+ "lifewiseleaf.com",
+ "liggegi.com",
+ "lightnestmarket.shop",
+ "lightv.online",
+ "ligsb.com",
+ "likeageek.fr.nf",
+ "likemovie.net",
+ "likevip.net",
+ "lilbahlil.me",
+ "lillemap.net",
+ "lilo.me",
+ "lilspam.com",
+ "lilsuite.id",
+ "limcil.cyou",
+ "lindenbaumjapan.com",
+ "lindenmere.net",
+ "link2mail.net",
+ "linkbento.me",
+ "linkcoll.my.id",
+ "linkedindiscount.com",
+ "linkedintuts2016.pw",
+ "linkmail.info",
+ "linkspo.my.id",
+ "linlin.cloud",
+ "linlshe.com",
+ "linshiyou.com",
+ "linshiyouxiang.net",
+ "linux-do.me",
+ "linuxgo.us.ci",
+ "linuxmail.so",
+ "linxi.online",
+ "linyu.qzz.io",
+ "linzefor.asia",
+ "linzefor.shop",
+ "liocbrco.com",
+ "liondapt.com",
+ "liopsacac.shop",
+ "lista.cc",
+ "litedrop.com",
+ "littlecourtyard.homes",
+ "littlejohns.info",
+ "littlemvh.org.uk",
+ "littlewildnote.cyou",
+ "littsocial.com",
+ "liun.cc.cd",
+ "liuyuyu.space",
+ "liveforms.org",
+ "liveradio.tk",
+ "livinglegendsmaintenance.com",
+ "livinitlarge.net",
+ "lixlord.xyz",
+ "liyao.edu.kg",
+ "lizery.com",
+ "lkgn.se",
+ "llciuoau.top",
+ "llogin.ru",
+ "lloudfsxly.top",
+ "lnovic.com",
+ "lnwtt.com",
+ "loadby.us",
+ "loan101.pro",
+ "loaoa.com",
+ "loapq.com",
+ "localairconservice.xyz",
+ "localamcrepair.xyz",
+ "localhvaccare.xyz",
+ "localmingle.sbs",
+ "locanto1.club",
+ "locantofuck.top",
+ "locantowsite.club",
+ "locawin.com",
+ "locomodev.net",
+ "loganister.com",
+ "logicfieldzen.com",
+ "logicstreak.com",
+ "login-email.cf",
+ "login-email.ga",
+ "login-email.ml",
+ "login-email.tk",
+ "logsmarter.net",
+ "logular.com",
+ "loh.pp.ua",
+ "lohinja.com",
+ "loin.in",
+ "lokenathassociation.co.uk",
+ "lolfreak.net",
+ "lolmail.biz",
+ "lonaxter.site",
+ "londonyouthsailing.org",
+ "longsbkt.xyz",
+ "lookugly.com",
+ "loommosaic.cfd",
+ "lordfortescue.news",
+ "lordfortescue.org.uk",
+ "lordofmysteries.org",
+ "lordsofts.com",
+ "lortemail.dk",
+ "losemymail.com",
+ "lostnow.org",
+ "love-your.mom",
+ "lovecalculatorname.org",
+ "lovecv.cn",
+ "lovegivingsadaqah.org",
+ "lovemeet.faith",
+ "lovemeleaveme.com",
+ "lovepet.vip",
+ "lovertboy.online",
+ "loveuu.xyz",
+ "lowprizo.com",
+ "lpfmgmtltd.com",
+ "lqkrxvc.loseyourip.com",
+ "lr7.us",
+ "lr78.com",
+ "lroid.com",
+ "lru.me",
+ "ls-server.ru",
+ "lsaar.com",
+ "lsbl6fq.mywire.org",
+ "lsd10086.ccwu.cc",
+ "lsd8866.ccwu.cc",
+ "lsereborn.com",
+ "lsfh.hair",
+ "lsirdomain.us.ci",
+ "lsyx24.com",
+ "lts35.help",
+ "lu1mtac.ooguy.com",
+ "luckfeed.com",
+ "lucktaro.com",
+ "luckymail.org",
+ "luffygadgets.com",
+ "lujialu.edu.kg",
+ "lujian.de5.net",
+ "lukecarriere.com",
+ "lukemail.info",
+ "lukop.dk",
+ "lum25kcredit.io.vn",
+ "lumeika.com",
+ "lumg.uk",
+ "luminexis.cfd",
+ "luminix1.shop",
+ "lumm25k.io.vn",
+ "lumyrix.cfd",
+ "lunarmoon.online",
+ "lunetra.shop",
+ "lunor.cfd",
+ "lushiyi.eu.cc",
+ "lushosa.com",
+ "lutech.uk",
+ "luv2.us",
+ "luxentra.cfd",
+ "luxovira.cfd",
+ "luxudata.com",
+ "lxbeta.com",
+ "lxydawang.top",
+ "lydir.com",
+ "lyfestylecreditsolutions.com",
+ "lyft.live",
+ "lympstonevillagehall.co.uk",
+ "lynex.sbs",
+ "lyngo.tech",
+ "lynwise.shop",
+ "lyricspad.net",
+ "lyxhzz.com.cn",
+ "lzoaq.com",
+ "lzum.homes",
+ "m21.cc",
+ "m4ilweb.info",
+ "ma1l.duckdns.org",
+ "mabal.fr.nf",
+ "maboard.com",
+ "mabox.eu",
+ "mac-24.com",
+ "machen-wir.com",
+ "macmail.info",
+ "macos.one",
+ "macr2.com",
+ "macromaid.com",
+ "macromice.info",
+ "magamail.com",
+ "magbit.food",
+ "maggotymeat.ga",
+ "magicbox.ro",
+ "magim.be",
+ "magspam.net",
+ "mahreenlifestyle.shop",
+ "maidlow.info",
+ "mail-burn.com",
+ "mail-card.net",
+ "mail-easy.fr",
+ "mail-filter.com",
+ "mail-help.net",
+ "mail-hosting.co",
+ "mail-hub.info",
+ "mail-mario.fr.nf",
+ "mail-now.top",
+ "mail-owl.com",
+ "mail-share.com",
+ "mail-temporaire.com",
+ "mail-temporaire.fr",
+ "mail-tester.com",
+ "mail.by",
+ "mail.gw",
+ "mail.tm",
+ "mail.wtf",
+ "mail0.ga",
+ "mail1.top",
+ "mail101.xyz",
+ "mail10m.com",
+ "mail11.xyz",
+ "mail114.net",
+ "mail12h.com",
+ "mail1a.de",
+ "mail1web.org",
+ "mail21.cc",
+ "mail22.club",
+ "mail2me.co",
+ "mail2rss.org",
+ "mail333.com",
+ "mail4.uk",
+ "mail4qa.com",
+ "mail4trash.com",
+ "mail666.ru",
+ "mail7.io",
+ "mail707.com",
+ "mail72.com",
+ "mail789.dedyn.io",
+ "mailaddress.de",
+ "mailadresi.tk",
+ "mailapp.top",
+ "mailapril.org",
+ "mailba.uk",
+ "mailback.com",
+ "mailbai.com",
+ "mailbali.com",
+ "mailbidon.com",
+ "mailbiscuit.com",
+ "mailbiz.biz",
+ "mailblocks.com",
+ "mailbox.in.ua",
+ "mailbox.zip",
+ "mailbox49.com",
+ "mailbox52.ga",
+ "mailbox80.biz",
+ "mailbox82.biz",
+ "mailbox87.de",
+ "mailbox92.biz",
+ "mailboxify.ru",
+ "mailboxify.store",
+ "mailboxly.ru",
+ "mailboxly.store",
+ "mailboxy.fun",
+ "mailboxy.ru",
+ "mailboxy.store",
+ "mailbucket.org",
+ "mailcat.biz",
+ "mailcatch.com",
+ "mailchannels.de",
+ "mailchatgpt.eu.cc",
+ "mailchop.com",
+ "mailcker.com",
+ "mailclone2023.top",
+ "maildax.me",
+ "mailde.de",
+ "mailde.info",
+ "maildrop.cc",
+ "maildrop.cf",
+ "maildrop.ga",
+ "maildrop.gq",
+ "maildrop.ml",
+ "maildu.de",
+ "maildx.com",
+ "maildy.site",
+ "maileater.com",
+ "mailed.in",
+ "mailed.ro",
+ "mailedu.edu.pl",
+ "maileduvn.us",
+ "maileimer.de",
+ "maileme101.com",
+ "mailer.edu.pl",
+ "mailer.io.vn",
+ "mailers.edu.pl",
+ "mailexpire.com",
+ "mailf5.com",
+ "mailfa.tk",
+ "mailfall.com",
+ "mailfast.pro",
+ "mailfirefly.com",
+ "mailfirst.icu",
+ "mailfor1month.shop",
+ "mailforgo.site",
+ "mailforspam.com",
+ "mailfortiktok.us",
+ "mailfree.ga",
+ "mailfree.gq",
+ "mailfree.ml",
+ "mailfreeonline.com",
+ "mailfs.com",
+ "mailgo.cyou",
+ "mailguard.me",
+ "mailgutter.com",
+ "mailhazard.com",
+ "mailhazard.us",
+ "mailhex.com",
+ "mailhz.me",
+ "mailimate.com",
+ "mailin8r.com",
+ "mailinatar.com",
+ "mailinater.com",
+ "mailinator.co.uk",
+ "mailinator.com",
+ "mailinator.gq",
+ "mailinator.info",
+ "mailinator.net",
+ "mailinator.org",
+ "mailinator.us",
+ "mailinator0.com",
+ "mailinator1.com",
+ "mailinator2.com",
+ "mailinator2.net",
+ "mailinator3.com",
+ "mailinator4.com",
+ "mailinator5.com",
+ "mailinator6.com",
+ "mailinator7.com",
+ "mailinator8.com",
+ "mailinator9.com",
+ "mailincubator.com",
+ "mailisia.com",
+ "mailismagic.com",
+ "mailita.tk",
+ "mailjunk.cf",
+ "mailjunk.ga",
+ "mailjunk.gq",
+ "mailjunk.ml",
+ "mailjunk.tk",
+ "mailku.co",
+ "mailllshop.website",
+ "maillog.uk",
+ "mailmagnet.co",
+ "mailmate.com",
+ "mailmaxy.one",
+ "mailmay.org",
+ "mailme.gq",
+ "mailme.ir",
+ "mailme.lv",
+ "mailme24.com",
+ "mailmenot.io",
+ "mailmetrash.com",
+ "mailmoat.com",
+ "mailmoth.com",
+ "mailms.com",
+ "mailna.biz",
+ "mailna.co",
+ "mailna.in",
+ "mailna.me",
+ "mailnada.cc",
+ "mailnada.com",
+ "mailnator.com",
+ "mailnesia.com",
+ "mailnet.io.vn",
+ "mailnoop.store",
+ "mailnull.com",
+ "mailnuo.com",
+ "mailo.edu.pl",
+ "mailold.us",
+ "mailonaut.com",
+ "mailorc.com",
+ "mailorg.org",
+ "mailosaur.net",
+ "mailox.fun",
+ "mailp.org",
+ "mailphu.com",
+ "mailpick.biz",
+ "mailpluss.com",
+ "mailpooch.com",
+ "mailpoof.com",
+ "mailpress.gq",
+ "mailpro.lat",
+ "mailpro.live",
+ "mailproxsy.com",
+ "mailpts.com",
+ "mailpull.com",
+ "mailquack.com",
+ "mailrock.biz",
+ "mailrun.one",
+ "mailsa.biz.id",
+ "mailsa.my.id",
+ "mailsac.com",
+ "mailsafe.fr.nf",
+ "mailsbay.com",
+ "mailscrap.com",
+ "mailseal.de",
+ "mailserv.info",
+ "mailshan.com",
+ "mailshell.com",
+ "mailshiv.com",
+ "mailshou.com",
+ "mailshun.com",
+ "mailsiphon.com",
+ "mailslapping.com",
+ "mailslite.com",
+ "mailslurp.com",
+ "mailsucker.net",
+ "mailt.net",
+ "mailt.top",
+ "mailtao.me",
+ "mailtechx.com",
+ "mailtecom.com",
+ "mailtemp.info",
+ "mailtemple.xyz",
+ "mailtemporaire.com",
+ "mailtemporaire.fr",
+ "mailtest.ccwu.cc",
+ "mailto.plus",
+ "mailtome.de",
+ "mailtothis.com",
+ "mailtraps.com",
+ "mailtrash.net",
+ "mailtrix.net",
+ "mailtub.com",
+ "mailtv.net",
+ "mailtv.tv",
+ "mailuniverse.co.uk",
+ "mailvip.net",
+ "mailw.site",
+ "mailx.click",
+ "mailxyz.cyou",
+ "mailyan.my.id",
+ "mailzi.ru",
+ "mailzilla.com",
+ "mailzilla.org",
+ "mailzy.org",
+ "mainerfolg.info",
+ "mainkask.site",
+ "maintdxb.xyz",
+ "maintenancecitywalk.xyz",
+ "maintenancedamachills.xyz",
+ "maintenancedso.xyz",
+ "maintenancejaddaf.xyz",
+ "maintenancevictory.xyz",
+ "maintmudon.xyz",
+ "maintsportshub.xyz",
+ "make.cc.cd",
+ "makemenaughty.club",
+ "makemetheking.com",
+ "makeraura.online",
+ "makertrial.com",
+ "makesomestone.shop",
+ "makmursrondol.store",
+ "makoli.sbs",
+ "makoli.space",
+ "malahov.de",
+ "malayalamdtp.com",
+ "malioter.pro",
+ "mama3.org",
+ "mamabood.com",
+ "mamangbadrut.site",
+ "mameganteng.store",
+ "mamulenok.ru",
+ "managedisabled.online",
+ "mandraghen.cf",
+ "manhmmonew.online",
+ "manifestgenerator.com",
+ "mannawo.com",
+ "mansiondev.com",
+ "maohe.cloud",
+ "maplemarketry.shop",
+ "mapleveranda.homes",
+ "mapsguy.com",
+ "marchguidesassociation.org",
+ "marchguidesassociation.org.uk",
+ "marhumal.tech",
+ "marineso.com",
+ "mariyaa.pp.ua",
+ "mark-compressoren.ru",
+ "markableytrust.org.uk",
+ "markaihub.shop",
+ "markandvirginiarometty.org",
+ "marketlink.info",
+ "markmurfin.com",
+ "markstonehub.org",
+ "marrytodo.de",
+ "martenson-mail.mywire.org",
+ "martenson-mail.ooguy.com",
+ "martenson-mail.theworkpc.com",
+ "masbahlil.xyz",
+ "mask03.ru",
+ "masonline.info",
+ "master24x7.xyz",
+ "masterannual24x7.xyz",
+ "masterannualquick.xyz",
+ "mastercareae.xyz",
+ "mastercareexpress.xyz",
+ "masterhandyman.xyz",
+ "masteryforn.shop",
+ "maswae.world",
+ "matamuasu.ga",
+ "matchpol.net",
+ "matjoa.com",
+ "matmayer.com",
+ "matra.site",
+ "max-mail.org",
+ "maxresistance.com",
+ "maxseeding.com",
+ "maxtanie.com",
+ "maxturns.com",
+ "maxxspace.com",
+ "mayre.shop",
+ "mbox.re",
+ "mbrcityservices.com",
+ "mbx.cc",
+ "mbygjjo.mywire.org",
+ "mc-fly.be",
+ "mc088ue.loseyourip.com",
+ "mcache.net",
+ "mcdomaine.fr.nf",
+ "mchasd.eu.cc",
+ "mciek.com",
+ "mcpservers.one",
+ "mcs.cc.cd",
+ "mcunnm.ccwu.cc",
+ "mdahld.eu.cc",
+ "mdhc.tk",
+ "mdz.email",
+ "meadowsdubaimaintenance.com",
+ "mealwhim.me",
+ "meantinc.com",
+ "mebeiuk.co.uk",
+ "mebelnu.info",
+ "mech2build.io.vn",
+ "mechanicalresumes.com",
+ "mechanicspedia.com",
+ "medevsa.com",
+ "mediaeast.uk",
+ "mediaholy.com",
+ "medicichoir.org",
+ "mediseat.com",
+ "medkabinet-uzi.ru",
+ "meef.uk",
+ "meepsheep.eu",
+ "meevpo.id",
+ "megasend.org",
+ "mehda.eu.cc",
+ "mehr-bitcoin.de",
+ "meidecn.com",
+ "meikeya.com",
+ "meinspamschutz.de",
+ "meinvr.com",
+ "meitu.io.vn",
+ "meituy.com",
+ "mekqh.eu.cc",
+ "melbourne.edu.pl",
+ "melbournecrest.net",
+ "mellowharbor.cyou",
+ "meltedbrownies.com",
+ "meltmail.com",
+ "meltp.com",
+ "memeazon.com",
+ "memoniclife.online",
+ "memsg.site",
+ "meng2026.bond",
+ "meng2026.cyou",
+ "mengma.eu.cc",
+ "mengundang.live",
+ "mentonit.net",
+ "meocon.org",
+ "meomo.store",
+ "meonvr.com",
+ "mepost.pw",
+ "merepost.com",
+ "merry.pink",
+ "meruado.uk",
+ "merumart.com",
+ "merxo.me",
+ "mesemails.fr.nf",
+ "mess-mails.fr.nf",
+ "messagebeamer.de",
+ "messibucu.click",
+ "messwiththebestdielikethe.rest",
+ "meta.aid.pl",
+ "meta.auto.pl",
+ "meta.corsica",
+ "meta.mail.pl",
+ "metadownload.org",
+ "metaintern.net",
+ "metajaya.biz.id",
+ "metalunits.com",
+ "metaorbitx.com",
+ "metas.io.vn",
+ "metroacpro.xyz",
+ "metroamcservice.xyz",
+ "metrofixit.xyz",
+ "mexvat.com",
+ "mezimages.net",
+ "mfapi.dpdns.org",
+ "mfsa.info",
+ "mfsa.ru",
+ "mfunza.com",
+ "mfxis.com",
+ "mgdns.shop",
+ "mgtop.cc",
+ "mgxianlu.gq",
+ "mhzayt.online",
+ "miaferrari.com",
+ "mianfeibuluo.com",
+ "miaowuai.buzz",
+ "miauj.com",
+ "michaeladmin.eu",
+ "michaeladmin.fr",
+ "michaeladmin.org",
+ "michaeladmin.store",
+ "mickaben.fr.nf",
+ "midcoastcustoms.com",
+ "midcoastcustoms.net",
+ "midcoastsolutions.com",
+ "midcoastsolutions.net",
+ "middletoncheneyuc.uk",
+ "mideastlens.com",
+ "mideastview.com",
+ "midiharmonica.com",
+ "midlertidig.com",
+ "midlertidig.net",
+ "midlertidig.org",
+ "midwestpride.org",
+ "mierdamail.com",
+ "migmail.net",
+ "migmail.pl",
+ "migumail.com",
+ "mihep.com",
+ "miistermail.fr",
+ "mijnhva.nl",
+ "mikesomelike.shop",
+ "mikfarm.com",
+ "mikrotikvietnam.com",
+ "mikrotikvn.com",
+ "mikrotikx.com",
+ "mikumirai.top",
+ "milllogiciel.asia",
+ "miloras.fr.nf",
+ "minafter.com",
+ "minefieldmail.com",
+ "minhquang2000.com",
+ "minidmx.icu",
+ "minimail.gq",
+ "ministry-of-silly-walks.de",
+ "minitts.net",
+ "minsmail.com",
+ "mintemail.com",
+ "minuteafter.com",
+ "minuteinbox.com",
+ "mipg.autos",
+ "mirai.re",
+ "miramaintenance.com",
+ "mirasync.tokyo",
+ "mirdifmaintenance.com",
+ "miros.cloud",
+ "missionbulldogs.org",
+ "misterpinball.de",
+ "mitico.org",
+ "mitrabisa.com",
+ "mitraprem.online",
+ "mitraprem.site",
+ "miucce.com",
+ "miwacle.com",
+ "mixmail.site",
+ "mixzu.net",
+ "mji.ro",
+ "mjj.edu.ge",
+ "mjukglass.nu",
+ "mkeya.com",
+ "mkpfilm.com",
+ "mkql.autos",
+ "mkseiuuda.top",
+ "mkvesta.io.vn",
+ "mkvo.hair",
+ "mkzaso.com",
+ "ml8.ca",
+ "mlanm.online",
+ "mlgmail.top",
+ "mliok.com",
+ "mlumzmh.cn",
+ "mm.my",
+ "mm2flip.pw",
+ "mm5.se",
+ "mmaily.com",
+ "mmcat.icu",
+ "mmdesign.work.gd",
+ "mmmggg0123.ccwu.cc",
+ "mmmo.us.ci",
+ "mmo.us.ci",
+ "mmo365.store",
+ "mmyuu.cn",
+ "mnode.me",
+ "mnvr.site",
+ "moakt.cc",
+ "moakt.co",
+ "moakt.com",
+ "moakt.ws",
+ "mobileninja.co.uk",
+ "mobilevpn.top",
+ "moblha.com",
+ "moburl.com",
+ "mochuangcheng.com",
+ "mockata.net",
+ "mockmyid.com",
+ "modirosa.com",
+ "modolku.store",
+ "moeri.org",
+ "mofu.be",
+ "mohmal.com",
+ "mohmal.im",
+ "mohmal.in",
+ "mohmal.tech",
+ "moimoi.re",
+ "mokondo.today",
+ "mokondoo.cyou",
+ "mokook.com",
+ "molabo.bond",
+ "moldind.help",
+ "molms.com",
+ "momentics.ru",
+ "momoi.uk",
+ "momopifa.cc.cd",
+ "mona.edu.kg",
+ "mona.edu.pl",
+ "mona.edu.rs",
+ "monachat.tk",
+ "monadi.ml",
+ "monchu.fr",
+ "moncourrier.fr.nf",
+ "mondial.asso.st",
+ "monemail.fr.nf",
+ "moneypipe.net",
+ "monglung.space",
+ "mongrec.top",
+ "monity.top",
+ "monmail.fr.nf",
+ "monomoho.site",
+ "monopacket.com",
+ "monotensor.com",
+ "monumentmail.com",
+ "monyal.shop",
+ "moolee.net",
+ "moonapps.org",
+ "moondyal.com",
+ "moonfee.com",
+ "moonhuddle.cyou",
+ "moonikent.us",
+ "moonlitwares.shop",
+ "moonstarsun.shop",
+ "moonwake.com",
+ "mooo.com",
+ "moorgatecorp.net",
+ "moot.es",
+ "mopess.help",
+ "mora45ktil.io.vn",
+ "moravita.com",
+ "moreawesomethanyou.com",
+ "moreorcs.com",
+ "morriesworld.ml",
+ "morsin.com",
+ "moruzza.com",
+ "mossatelier.cfd",
+ "mosslandings.homes",
+ "mosswoodmissionhall.bio",
+ "motion.jo3.org",
+ "motique.de",
+ "motorcitymaintenance.com",
+ "mototoke.click",
+ "mountainregionallibrary.net",
+ "moutianwuliu.top",
+ "mowline.com",
+ "mox.pp.ua",
+ "moy-elektrik.ru",
+ "moyang303.de5.net",
+ "moza.pl",
+ "mozej.com",
+ "mp-j.ga",
+ "mp3oxi.com",
+ "mpk.ovh",
+ "mr-email.fr.nf",
+ "mr24.co",
+ "mrcheannko45k.io.vn",
+ "mriscan.live",
+ "mriscanner.live",
+ "mrizkifreelace.dpdns.org",
+ "mrnz.skin",
+ "mrotzis.com",
+ "mrphoto.org",
+ "mrvpm.net",
+ "mrvpt.com",
+ "msarra.com",
+ "msgos.com",
+ "msn-mail-free-1.dynv6.net",
+ "msn-mail-free-1092.dynv6.net",
+ "msn-mail-free-1268.dynv6.net",
+ "msn-mail-free-1522.dynv6.net",
+ "msn-mail-free-1659.dynv6.net",
+ "msn-mail-free-1750.dynv6.net",
+ "msn-mail-free-189.dynv6.net",
+ "msn-mail-free-1974.dynv6.net",
+ "msn-mail-free-1980.dynv6.net",
+ "msn-mail-free-2112.dynv6.net",
+ "msn-mail-free-2193.dynv6.net",
+ "msn-mail-free-2205.dynv6.net",
+ "msn-mail-free-2634.dynv6.net",
+ "msn-mail-free-2681.dynv6.net",
+ "msn-mail-free-3011.dynv6.net",
+ "msn-mail-free-3229.dynv6.net",
+ "msn-mail-free-3232.dynv6.net",
+ "msn-mail-free-3270.dynv6.net",
+ "msn-mail-free-33.dynv6.net",
+ "msn-mail-free-3312.dynv6.net",
+ "msn-mail-free-3373.dynv6.net",
+ "msn-mail-free-3414.dynv6.net",
+ "msn-mail-free-3622.dynv6.net",
+ "msn-mail-free-3632.dynv6.net",
+ "msn-mail-free-3737.dynv6.net",
+ "msn-mail-free-3812.dynv6.net",
+ "msn-mail-free-4062.dynv6.net",
+ "msn-mail-free-4088.dynv6.net",
+ "msn-mail-free-4151.dynv6.net",
+ "msn-mail-free-4290.dynv6.net",
+ "msn-mail-free-4304.dynv6.net",
+ "msn-mail-free-4411.dynv6.net",
+ "msn-mail-free-4518.dynv6.net",
+ "msn-mail-free-4640.dynv6.net",
+ "msn-mail-free-4718.dynv6.net",
+ "msn-mail-free-4868.dynv6.net",
+ "msn-mail-free-5064.dynv6.net",
+ "msn-mail-free-5162.dynv6.net",
+ "msn-mail-free-5184.dynv6.net",
+ "msn-mail-free-5198.dynv6.net",
+ "msn-mail-free-5402.dynv6.net",
+ "msn-mail-free-5415.dynv6.net",
+ "msn-mail-free-554.dynv6.net",
+ "msn-mail-free-5696.dynv6.net",
+ "msn-mail-free-5718.dynv6.net",
+ "msn-mail-free-5872.dynv6.net",
+ "msn-mail-free-6047.dynv6.net",
+ "msn-mail-free-6113.dynv6.net",
+ "msn-mail-free-6326.dynv6.net",
+ "msn-mail-free-6581.dynv6.net",
+ "msn-mail-free-6589.dynv6.net",
+ "msn-mail-free-6760.dynv6.net",
+ "msn-mail-free-678.dynv6.net",
+ "msn-mail-free-6877.dynv6.net",
+ "msn-mail-free-6883.dynv6.net",
+ "msn-mail-free-6906.dynv6.net",
+ "msn-mail-free-6943.dynv6.net",
+ "msn-mail-free-6959.dynv6.net",
+ "msn-mail-free-6981.dynv6.net",
+ "msn-mail-free-708.dynv6.net",
+ "msn-mail-free-7155.dynv6.net",
+ "msn-mail-free-727.dynv6.net",
+ "msn-mail-free-733.dynv6.net",
+ "msn-mail-free-7424.dynv6.net",
+ "msn-mail-free-7429.dynv6.net",
+ "msn-mail-free-7434.dynv6.net",
+ "msn-mail-free-7509.dynv6.net",
+ "msn-mail-free-7570.dynv6.net",
+ "msn-mail-free-7742.dynv6.net",
+ "msn-mail-free-7760.dynv6.net",
+ "msn-mail-free-8047.dynv6.net",
+ "msn-mail-free-810.dynv6.net",
+ "msn-mail-free-8131.dynv6.net",
+ "msn-mail-free-8282.dynv6.net",
+ "msn-mail-free-8582.dynv6.net",
+ "msn-mail-free-86.dynv6.net",
+ "msn-mail-free-8632.dynv6.net",
+ "msn-mail-free-8704.dynv6.net",
+ "msn-mail-free-8776.dynv6.net",
+ "msn-mail-free-8841.dynv6.net",
+ "msn-mail-free-890.dynv6.net",
+ "msn-mail-free-8952.dynv6.net",
+ "msn-mail-free-8986.dynv6.net",
+ "msn-mail-free-9002.dynv6.net",
+ "msn-mail-free-9061.dynv6.net",
+ "msn-mail-free-9121.dynv6.net",
+ "msn-mail-free-9173.dynv6.net",
+ "msn-mail-free-9224.dynv6.net",
+ "msn-mail-free-9313.dynv6.net",
+ "msn-mail-free-9368.dynv6.net",
+ "msn-mail-free-9382.dynv6.net",
+ "msn-mail-free-9444.dynv6.net",
+ "msn-mail-free-9536.dynv6.net",
+ "msn-mail-free-9587.dynv6.net",
+ "msn-mail-free-9597.dynv6.net",
+ "msn-mail-free-9649.dynv6.net",
+ "msn-mail-free-9734.dynv6.net",
+ "msn-mail-free-9921.dynv6.net",
+ "msn-mail-free-9990.dynv6.net",
+ "msn-mail-free-9998.dynv6.net",
+ "mspeciosa.com",
+ "msqatl.com",
+ "msrc.ml",
+ "msssg.com",
+ "msvw.skin",
+ "mswork.ru",
+ "msxd.com",
+ "mt2009.com",
+ "mt2014.com",
+ "mt2015.com",
+ "mtdalmshouse.fitness",
+ "mtdalmshouse.uk",
+ "mtdog.xyz",
+ "mtmdev.com",
+ "mtnewtoy.us",
+ "mtshop18.io.vn",
+ "muadaingan.com",
+ "muareview.com",
+ "muataikhoan.info",
+ "muathegame.com",
+ "muchomail.com",
+ "mucincanon.com",
+ "mudahmaxwin.com",
+ "mudonmaintenance.com",
+ "muehlacker.tk",
+ "muell.icu",
+ "muell.io",
+ "muell.monster",
+ "muell.xyz",
+ "muellemail.com",
+ "muellmail.com",
+ "muetop.store",
+ "mugadget.com",
+ "mui.cc.cd",
+ "mui.de5.net",
+ "mukn.skin",
+ "multicorse.com",
+ "muncloud.com",
+ "munik.edu.pl",
+ "munoubengoshi.gq",
+ "muonwhila.com",
+ "muralin-aja.com",
+ "muratec.eu.org",
+ "muratreis.icu",
+ "musiccode.me",
+ "musicflow.buzz",
+ "musiku.studio",
+ "mutant.me",
+ "mutudev.com",
+ "mvpmedix.com",
+ "mvrht.com",
+ "mvrht.net",
+ "mwalshabs.dev",
+ "mwarner.org",
+ "mwlp.skin",
+ "mwqj.xyz",
+ "mxclip.com",
+ "mxfuel.com",
+ "mxhquxyi.top",
+ "mxmm.hair",
+ "mxvia.com",
+ "mxwm.hair",
+ "my-pomsies.ru",
+ "my-teddyy.ru",
+ "my10minutemail.com",
+ "myairconpro.xyz",
+ "myamya.tech",
+ "mybitti.de",
+ "mycleaninbox.net",
+ "mycorneroftheinter.net",
+ "mycreativeinbox.com",
+ "myde.ml",
+ "mydefipet.live",
+ "mydemo.equipment",
+ "myecho.es",
+ "myemailboxy.com",
+ "myerly.com",
+ "myfixit.xyz",
+ "myfxspot.com",
+ "mygeoweb.info",
+ "mygoo.vip",
+ "myhochzeitsfilm.de",
+ "myhosrn.servehalflife.com",
+ "myindohome.services",
+ "myinfoinc.com",
+ "myinterserver.ml",
+ "mykickassideas.com",
+ "myletters.online",
+ "mymail-in.net",
+ "mymail.infos.st",
+ "mymail90.com",
+ "mymaildo.kro.kr",
+ "mymailoasis.com",
+ "mymaily.lol",
+ "mymrxuyrd.top",
+ "mynes.com",
+ "mynetstore.de",
+ "mynoop.store",
+ "myntora.cfd",
+ "myopang.com",
+ "mypacks.net",
+ "mypartyclip.de",
+ "mypdj08.mywire.org",
+ "myphantomemail.com",
+ "mypk.beer",
+ "mysadfjwefwf.us.ci",
+ "mysamp.de",
+ "myself.fr.nf",
+ "myspaceinc.com",
+ "myspaceinc.net",
+ "myspaceinc.org",
+ "myspacepimpedup.com",
+ "myspamless.com",
+ "mystvpn.com",
+ "mysugartime.ru",
+ "mytemp.email",
+ "mytempemail.com",
+ "mytempmail.com",
+ "mytrashmail.com",
+ "mywarnernet.net",
+ "myweblaw.com",
+ "mywrld.site",
+ "mywrld.top",
+ "myxentra.cfd",
+ "myzx.com",
+ "mzico.com",
+ "n1nja.org",
+ "n24n8x9.giize.com",
+ "n8.gs",
+ "na-cat.com",
+ "na3qxuk.kozow.com",
+ "naacpadvancement.org",
+ "naah.ru",
+ "naah.store",
+ "nabaxox.edu.pl",
+ "nabilalo.bond",
+ "nabomail.com",
+ "nabuma.com",
+ "nada.email",
+ "nada.ltd",
+ "nadalshebaservices.com",
+ "nagi.be",
+ "nailooong.site",
+ "naka.edu.pl",
+ "nakedtruth.biz",
+ "namdev-mail-01.bond",
+ "namdev-mail-01.cyou",
+ "namdevmail2.qzz.io",
+ "namenan.me",
+ "namewok.com",
+ "nangspa.vn",
+ "naniniva.com",
+ "nanonym.ch",
+ "nanopools.info",
+ "nanoskin.vn",
+ "naobk.com",
+ "naocan.org",
+ "napthex4.com",
+ "narrowment.com",
+ "narsub.online",
+ "narsub.shop",
+ "nashapoultryshop.com",
+ "naslazhdai.ru",
+ "natachai.me",
+ "natange.org.uk",
+ "nategetvpn.store",
+ "nationalgardeningclub.com",
+ "natuanal.com",
+ "nautonk.com",
+ "navalcadets.com",
+ "naverapp.com",
+ "naverly.com",
+ "navermail.kr",
+ "navura.in",
+ "nawmin.info",
+ "naxx.dev",
+ "naylonksosmed.com",
+ "naymedia.com",
+ "nazisat.com",
+ "nbmbb.com",
+ "nbskyhope.shop",
+ "nbspace.us",
+ "nbvrluhsh.top",
+ "nbzmr.com",
+ "nbzy.hair",
+ "ncfx.beer",
+ "ncien.com",
+ "ncleap.com",
+ "ndade.eu.cc",
+ "ndnphim.io.vn",
+ "ndut.pro",
+ "neatdaynote.com",
+ "neatdrape.cfd",
+ "necub.com",
+ "negated.com",
+ "neghtlefi.com",
+ "neighborhoodtide.sbs",
+ "neko2.net",
+ "nekochan.fr",
+ "nekosan.uk",
+ "neoamc.xyz",
+ "neomailbox.com",
+ "neonbuzzline.buzz",
+ "neonorbitx.com",
+ "neosstudy.work",
+ "neotlozhniy-zaim.ru",
+ "neowd.com",
+ "nepwk.com",
+ "neroki.shop",
+ "nervmich.net",
+ "nervtmich.net",
+ "nesopf.com",
+ "nespf.com",
+ "nespj.com",
+ "nespressopixie.com",
+ "nestlynotes.com",
+ "net1mail.com",
+ "netcnet.uk",
+ "netcom.ws",
+ "netfusionhub.surf",
+ "netfxd.com",
+ "netgcp.online",
+ "nethercottventures.net",
+ "netherfieldvillagehall.co.uk",
+ "nethubmail.net",
+ "netmails.com",
+ "netmails.net",
+ "netn.top",
+ "netoiu.com",
+ "netricity.nl",
+ "netris.net",
+ "netviewer-france.com",
+ "networksolutionshk.asia",
+ "netzidiot.de",
+ "nevanata.com",
+ "nevermail.de",
+ "neverover.life",
+ "new-api.cc.cd",
+ "newaddr.com",
+ "newairconpro.xyz",
+ "newbpotato.tk",
+ "newbreedapps.com",
+ "newconnect.uk",
+ "newcoolfix.xyz",
+ "newdelhi.io.vn",
+ "newfilm24.ru",
+ "newgreenpath.org",
+ "newideasfornewpeople.info",
+ "newmail.top",
+ "newmano.store",
+ "newsteadabbeypartnership.org",
+ "newyork.io.vn",
+ "nexarisystems.online",
+ "nexarsh.store",
+ "nexarshop.store",
+ "nexentia.cfd",
+ "nexio.sbs",
+ "nexorabio.pro.vn",
+ "nexosphere.lifestyle",
+ "next.ovh",
+ "nextamcrepair.xyz",
+ "nextcoolrepair.xyz",
+ "nexthandyman.xyz",
+ "nextmail.info",
+ "nextpomi.dynv6.net",
+ "nextstopvalhalla.com",
+ "nextsuns.com",
+ "nexus-streams.online",
+ "nexushost.site",
+ "nexverseal.shop",
+ "nezdiro.org",
+ "nezid.com",
+ "nezuka.net",
+ "nezumi.be",
+ "nezzart.com",
+ "nfast.net",
+ "nfengwu.bond",
+ "nfkah.eu.cc",
+ "nfuo.makeup",
+ "nghienplus.io.vn",
+ "nghienplus.store",
+ "nguyendanhkietisocial.com",
+ "nguyenlieu24h.com",
+ "nguyentinhblog.com",
+ "nguyenusedcars.com",
+ "nh3.ro",
+ "nhienpv1.io.vn",
+ "nhmi1.com",
+ "nhmvn.com",
+ "nhoopmail.store",
+ "nhotv.com",
+ "nhungdang.xyz",
+ "niang-sfx.biz",
+ "nianshen09.help",
+ "nianshens.help",
+ "nice-4u.com",
+ "nice.qzz.io",
+ "nicebad.com",
+ "niceground.shop",
+ "nicemail.cc",
+ "niceminute.com",
+ "nichaoxing.cc",
+ "nick-ao.com",
+ "nickmxh.com",
+ "nicknassar.com",
+ "nicusaurelus.online",
+ "nigeriansincoventry.org",
+ "nightfood.studio",
+ "nightorb.com",
+ "nik.edu.pl",
+ "nikora.fr.nf",
+ "nimbacharity.com",
+ "nincsmail.com",
+ "nincsmail.hu",
+ "ninepacman.com",
+ "niprack.com",
+ "niseko.be",
+ "niulaibulai.me",
+ "niwave.shop",
+ "niwave.top",
+ "niwl.net",
+ "njwzzvmtin.my",
+ "nlpdy8m.ooguy.com",
+ "nm123.com",
+ "nm7.cc",
+ "nmail.cf",
+ "nnh.com",
+ "nnnetworks.com",
+ "nnot.net",
+ "nnoway.ru",
+ "no-spam.ws",
+ "no-trash.ru",
+ "no-ux.com",
+ "no2bullyingatwork.org.uk",
+ "nobleaccare.xyz",
+ "noblefacility.xyz",
+ "noblepioneer.com",
+ "nobugmail.com",
+ "nobulk.com",
+ "nobuma.com",
+ "noc5yva.theworkpc.com",
+ "noclickemail.com",
+ "nocp.ru",
+ "nocp.store",
+ "nodejs.uk",
+ "nodezine.com",
+ "noexpire.top",
+ "nogmailspam.info",
+ "noicd.com",
+ "noinspect.top",
+ "noizonicfoundation.com",
+ "nokdot.com",
+ "nokiamail.com",
+ "nolemail.ga",
+ "nolettersbox.com",
+ "nomail.cf",
+ "nomail.ga",
+ "nomail.pw",
+ "nomail2me.com",
+ "nomes.fr.nf",
+ "nomomail.com",
+ "nomorespamemails.com",
+ "nomorix.cfd",
+ "nonchalantresmita.biz",
+ "nondon.site",
+ "nondon.store",
+ "nongnue.com",
+ "nonspam.eu",
+ "nonspammer.de",
+ "nonze.ro",
+ "noodleparade.lol",
+ "nooploop.store",
+ "noopmail.com",
+ "noproposal.com",
+ "noref.in",
+ "noreply.fr",
+ "nori24.tv",
+ "norovpn.site",
+ "norseforce.com",
+ "northboveymeadow.business",
+ "northpinnacle.top",
+ "northsixty.com",
+ "northwindfinds.shop",
+ "norvanta.cfd",
+ "norwegischlernen.info",
+ "noship.shop",
+ "noshiper.pro",
+ "nospam.fr.nf",
+ "nospam.today",
+ "nospam4.us",
+ "nospamfor.us",
+ "nospamthanks.info",
+ "nosubcriber.shop",
+ "notboxletters.com",
+ "nothingtoseehere.ca",
+ "notif.me",
+ "notipr.com",
+ "notlettersmail.com",
+ "notmailinator.com",
+ "notrnailinator.com",
+ "notsharingmy.info",
+ "novacontigencia.xyz",
+ "novaemail.com",
+ "novahvaccare.xyz",
+ "novaprime.vip",
+ "novaspherelabs.store",
+ "novasub.click",
+ "novyrix.cfd",
+ "now.im",
+ "nowhere.org",
+ "nowmymail.com",
+ "nowmymail.net",
+ "nowrouter.store",
+ "nowtopzen.com",
+ "nowyoudme1.cc.cd",
+ "noyavip.com",
+ "noyp.fr.nf",
+ "nproxi.com",
+ "nqmo.com",
+ "nrbs.me",
+ "nrehi.com",
+ "nriza.com",
+ "nrlord.com",
+ "ns01.biz",
+ "nsvpn.com",
+ "nsyncxy.com",
+ "nthrl.com",
+ "ntlhelp.net",
+ "ntto.us",
+ "nttoadmin2.us",
+ "nu9233b.theworkpc.com",
+ "nubescontrol.com",
+ "nucleant.org",
+ "nuknjkv.ooguy.com",
+ "nulla.de5.net",
+ "nullbox.asia",
+ "nullbox.info",
+ "nuoifb.com",
+ "nuox.eu.org",
+ "nurfuerspam.de",
+ "nut.cc",
+ "nutpa.net",
+ "nuts2trade.com",
+ "nv6ei2v.kozow.com",
+ "nvhrw.com",
+ "nvqt304.xyz",
+ "nwfa.hair",
+ "nwldx.com",
+ "nwrlr.org",
+ "nwytg.com",
+ "nwytg.net",
+ "nxdbxd.top",
+ "nxirad.online",
+ "ny7.me",
+ "ny7u9qr.kozow.com",
+ "nyasan.com",
+ "nyfhk.com",
+ "nygren-realty.com",
+ "nygrenrealty.com",
+ "nypato.com",
+ "nyrmusic.com",
+ "nyxmail.pro",
+ "nyxpanel.xyz",
+ "nyxstores.id",
+ "o2stk.org",
+ "o2y50o5.loseyourip.com",
+ "o326lav.kozow.com",
+ "o7i.net",
+ "oakon.com",
+ "oalsp.com",
+ "oasisclimate.xyz",
+ "obelion.cfd",
+ "obfusko.com",
+ "objectmail.com",
+ "obobbo.com",
+ "oborudovanieizturcii.ru",
+ "obxpestcontrol.com",
+ "ocbxpz3.camdvr.org",
+ "oceva.site",
+ "octovie.com",
+ "oczt15m.kozow.com",
+ "odaymail.com",
+ "odeask.com",
+ "odem.com",
+ "odnorazovoe.ru",
+ "oebd.hair",
+ "oepia.com",
+ "oerpub.org",
+ "ofags.com",
+ "ofanda.com",
+ "officecombine.com",
+ "offshore-proxies.net",
+ "ofisher.net",
+ "ogoplace.pro",
+ "ogprism.me",
+ "ogrencikredisi.org",
+ "ogzmail.com",
+ "ohaaa.de",
+ "ohgitu.me",
+ "ohi.tw",
+ "ohm.edu.pl",
+ "oida.icu",
+ "oiioii.email",
+ "oiioii.vip",
+ "oing.cf",
+ "okauq.store",
+ "okcdeals.com",
+ "okclprojects.com",
+ "okcx.edu.rs",
+ "okcxhssgk.top",
+ "okhko.com",
+ "okiae.com",
+ "okinawa.li",
+ "okrent.us",
+ "oku.ovh",
+ "oky.ovh",
+ "okzk.com",
+ "oldao.com",
+ "oldcrestgroup.net",
+ "oldhamgroup.net",
+ "oliespace.id",
+ "oliespace.site",
+ "olimp-case.ru",
+ "olipii.com",
+ "olisup.cyou",
+ "oliviadiffuser.store",
+ "oloh.ru",
+ "oloh.store",
+ "olypmall.ru",
+ "olzx.skin",
+ "omail.de",
+ "omail.pro",
+ "omanarts.com",
+ "omarnasrrr.com",
+ "omfg.run",
+ "omgheels.com",
+ "omkiujk.ddnsgeek.com",
+ "omnievents.org",
+ "omnilog.space",
+ "omnisent.cfd",
+ "omnivex.cfd",
+ "omonomon.shop",
+ "omtecha.com",
+ "onbap.com",
+ "one-mail.top",
+ "one-ml.com",
+ "one-sec-mail.site",
+ "one-time.email",
+ "one2mail.info",
+ "oneaircon.xyz",
+ "oneairconrepair.xyz",
+ "oneamcservice.xyz",
+ "oneclicksprout.click",
+ "onecoolrepair.xyz",
+ "onekisspresave.com",
+ "onelettersmail.com",
+ "onemail.host",
+ "onemuou.com",
+ "oneoffemail.com",
+ "oneoffmail.com",
+ "onestopacfix.xyz",
+ "onestopamcmaintenance.xyz",
+ "onestopduct.xyz",
+ "onetm-ml.com",
+ "onetm.jp",
+ "onetopclick.online",
+ "oneummahorg.uk",
+ "onewaymail.com",
+ "onheb.com",
+ "onionred.com",
+ "oniventa.cfd",
+ "onlatedotcom.info",
+ "onldm.net",
+ "online.ms",
+ "onlinecmail.com",
+ "onlineidea.info",
+ "onlyapp.net",
+ "onmailflare.com",
+ "onqin.com",
+ "ontasa.com",
+ "ontyne.biz",
+ "oohioo.com",
+ "ooioo.tech",
+ "oolus.com",
+ "oonies-shoprus.ru",
+ "oooooooo.store",
+ "oopi.org",
+ "oosln.com",
+ "oovk.ru",
+ "oovk.store",
+ "ooxxoo.asia",
+ "ooynib.com",
+ "ooyv.homes",
+ "opayq.com",
+ "opelkun.net",
+ "opemails.com",
+ "openagent.cfd",
+ "openavz.com",
+ "openclawskill.shop",
+ "opendns.ro",
+ "openedclaw.top",
+ "openmail.pro",
+ "opentrash.com",
+ "openwrt.qzz.io",
+ "opihhushdjn.cyou",
+ "opmmedia.ga",
+ "opog.hair",
+ "opog.skin",
+ "oposite.org",
+ "opp24.com",
+ "opportunity-africa.com",
+ "optimaweb.me",
+ "optivex.cfd",
+ "opwebw.com",
+ "oqgd.skin",
+ "oqmy.hair",
+ "oranek.com",
+ "oravantis.cfd",
+ "oravell.cfd",
+ "orbit25k.io.vn",
+ "orbitwhisper.buzz",
+ "orbitxhive.com",
+ "orbixacoa.com",
+ "orceqjqw.top",
+ "ordinaryamerican.net",
+ "ordite.com",
+ "oreidresume.com",
+ "orgmail.pro",
+ "orgmbx.cc",
+ "orimassage.com",
+ "oroki.de",
+ "oronny.com",
+ "orsbap.com",
+ "ortogenmail.com",
+ "orwpaztm.cfd",
+ "oryxhvacmaintenance.xyz",
+ "oryxmaint.xyz",
+ "oryxmaintenance.xyz",
+ "osaxadinhos.com.br",
+ "oshietechan.link",
+ "oshjr-24.xyz",
+ "osifixtech.com.mx",
+ "oskarstalbergcitygenerator.com",
+ "ospirun.com",
+ "ostahie.com",
+ "ostinmail.com",
+ "osxofulk.com",
+ "otherinbox.com",
+ "otmail.jp",
+ "otpku.com",
+ "ottsathi.store",
+ "ouchcloud.com",
+ "ouk.me",
+ "oupul8b1k.bond",
+ "oupul8b1k.cyou",
+ "ourklips.com",
+ "ourl.me",
+ "ourpreviewdomain.com",
+ "outdaremusicdevon.org",
+ "outlawspam.com",
+ "outlook.edu.pl",
+ "outlookapp.tech",
+ "outlooks.bond",
+ "outmail.win",
+ "ovbest.com",
+ "ovomail.co",
+ "ovpn.to",
+ "owdrcro.giize.com",
+ "owleyes.ch",
+ "owlpic.com",
+ "ownsyou.de",
+ "owo-mailteam.bond",
+ "owo-mailteam.cyou",
+ "owovo.site",
+ "owube.com",
+ "oxbridgeholdings.net",
+ "oxfo.edu.pl",
+ "oxmail.homes",
+ "oxopoha.com",
+ "oxvq.hair",
+ "oyisam.my",
+ "oynsuxe.ooguy.com",
+ "oyoa.hair",
+ "oyoa.skin",
+ "ozatvn.com",
+ "ozjp.skin",
+ "ozm.fr",
+ "ozsaip.com",
+ "ozvmail.com",
+ "ozyl.de",
+ "p-aac.top",
+ "p-banlis.ru",
+ "p-kk2.icu",
+ "p-ttz.top",
+ "p-y.cc",
+ "p0dl9ik.giize.com",
+ "p2bvz.cyou",
+ "p33.org",
+ "p3t8dyv.mywire.org",
+ "p6vvvok.mywire.org",
+ "p71ce1m.com",
+ "p94y7ff.camdvr.org",
+ "pa9e.com",
+ "pachilly.com",
+ "packiu.com",
+ "padue.app",
+ "pagamenti.tk",
+ "paharpurmim.ga",
+ "pakadebu.ga",
+ "palaze.cc",
+ "paldonanamansilolo.online",
+ "paliospiti.com",
+ "palmjebelaliservices.com",
+ "palmjumeirahservices.com",
+ "pamaweb.com",
+ "pamil.fr.nf",
+ "pancakemail.com",
+ "pandorasboxproductions.org",
+ "panlix.cloud",
+ "paperlanternly.cyou",
+ "papierkorb.me",
+ "paplease.com",
+ "para2019.ru",
+ "paratrabajar.xyz",
+ "parisdaily.fr",
+ "parlimentpetitioner.tk",
+ "parsitv.com",
+ "parthekio.live",
+ "parthxxtoasts.site",
+ "passeone.com",
+ "passeone.xyz",
+ "past-line.com",
+ "pastebitch.com",
+ "pastipremium.store",
+ "pastryofistanbul.com",
+ "patientlecadet.online",
+ "patity.com",
+ "patonce.com",
+ "patternplume.cfd",
+ "pavilionx2.com",
+ "pawobby.cfd",
+ "paxeluni.com",
+ "paxlys.com",
+ "paylaar.com",
+ "payperex2.com",
+ "payspun.com",
+ "pazard.com",
+ "pazuric.com",
+ "pbhak.dev",
+ "pbis.beer",
+ "pbrg4fb.ooguy.com",
+ "pcaa.lol",
+ "pcbb.lol",
+ "pcdo.hair",
+ "pckage.com",
+ "pdf-cutter.com",
+ "pdftinker.me",
+ "pdmy.skin",
+ "pdood.com",
+ "pe.hu",
+ "peachlumin.beauty",
+ "peakac.xyz",
+ "peakairconfix.xyz",
+ "peakengineeringdubai.xyz",
+ "peakinbox.net",
+ "peakinstallations24x7.xyz",
+ "peaksystemsdubai.xyz",
+ "pearlairconrepair.xyz",
+ "pearlamcrepair.xyz",
+ "pearlrepairs.xyz",
+ "pebblepouch.shop",
+ "pecinan.com",
+ "pecinan.net",
+ "pecinan.org",
+ "pelviro.sbs",
+ "pembrookgroup.net",
+ "penisgoes.in",
+ "penoto.tk",
+ "pepamail.com",
+ "pepbot.com",
+ "percyfx.com",
+ "pertera.com",
+ "peruss.biz.id",
+ "pesagilt.top",
+ "petalglimmer.beauty",
+ "petalhem.cfd",
+ "peterdethier.com",
+ "peterhe-ai.bond",
+ "petloca.com",
+ "petrzilka.net",
+ "pewpewpewpew.pw",
+ "pflege-schoene-haut.de",
+ "pfui.ru",
+ "pgxh.skin",
+ "phamay.com",
+ "phanmembanhang24h.com",
+ "phctool.com",
+ "philipdowney.com",
+ "phimib.com",
+ "phimteen.net",
+ "phmail.site",
+ "phmail.us",
+ "phone-elkey.ru",
+ "photo-impact.eu",
+ "photobrex.com",
+ "photomark.net",
+ "phpto.us",
+ "phucmmo.com",
+ "phuongnhicare.com",
+ "phuotngay.com",
+ "phyones.com",
+ "physcroenmail.com",
+ "physicaladithama.io",
+ "pi.vu",
+ "piaa.me",
+ "picdirect.net",
+ "pickuplanet.com",
+ "picturehostel.com",
+ "picturehostel.fr",
+ "picturehostel.org",
+ "picturehostel.store",
+ "pig.pp.ua",
+ "pigeonprotocol.com",
+ "pii.at",
+ "piki.si",
+ "pimpedupmyspace.com",
+ "pinehill-seattle.org",
+ "pingddns.net",
+ "pingir.com",
+ "pinkgreengenerator.me",
+ "pinnaclemeadow.top",
+ "pinpool.ai",
+ "pipemail.space",
+ "pipmmotube.store",
+ "pippoc.com",
+ "piscesbody.xyz",
+ "pisls.com",
+ "pitaniezdorovie.ru",
+ "pithu.org",
+ "pitiful.pp.ua",
+ "pivo-bar.ru",
+ "pivotylabs.pro",
+ "pixdd.com",
+ "pixelcroft.org",
+ "pixelgagnant.net",
+ "pixelrumble.buzz",
+ "pixiil.com",
+ "pixoledge.net",
+ "pizu.ru",
+ "pizu.store",
+ "pizuhdev.work",
+ "pizzajunk.com",
+ "pjjkp.com",
+ "pkday.eu.cc",
+ "pkdigitalmart.site",
+ "pkrj.hair",
+ "placebomail10.com",
+ "plainhomehub.com",
+ "planworker.pro",
+ "platinumactech.xyz",
+ "platinumchiller.xyz",
+ "playasiafootball.xyz",
+ "playfine.com",
+ "pleasenoham.org",
+ "plenoryx.cfd",
+ "plexfirm.com",
+ "plexflux.app",
+ "plexolan.de",
+ "plhk.ru",
+ "pliz.fr.nf",
+ "ploae.com",
+ "ploki.fr",
+ "ploncy.com",
+ "plowcreek.org",
+ "plugintonature.org.uk",
+ "plw.me",
+ "pmail.site",
+ "pnaz.skin",
+ "pngmender.me",
+ "pngrise.com",
+ "pntvipveo45k.io.vn",
+ "pnvipveo25k.io.vn",
+ "pobbofqd.top",
+ "poc4756bb.camdvr.org",
+ "pochtac.ru",
+ "poehali-otdihat.ru",
+ "pofmagic.com",
+ "pojok.ml",
+ "pokemail.net",
+ "pokemons1.fr.nf",
+ "pokiemobile.com",
+ "polaramccare.xyz",
+ "polarkingxx.ml",
+ "polarmaint.xyz",
+ "politikerclub.de",
+ "polkaroad.net",
+ "polvexar.space",
+ "polyfaust.net",
+ "polysolextcoin.cloud",
+ "pongpong.org",
+ "ponp.be",
+ "pooae.com",
+ "poofy.org",
+ "pookmail.com",
+ "poolemethodists.org.uk",
+ "pooo.ooguy.com",
+ "poopiebutt.club",
+ "popcornfarm7.com",
+ "popcornfly.com",
+ "popesodomy.com",
+ "popgx.com",
+ "poplk.com",
+ "popol.fr.nf",
+ "porchpetal.homes",
+ "porjoton.com",
+ "porsh.net",
+ "porsilapongo.cl",
+ "portaltrendsarena.com",
+ "posdz.com",
+ "positivevoiceteesvalley.co.uk",
+ "posta.store",
+ "postacin.com",
+ "postbx.ru",
+ "postbx.store",
+ "postonline.me",
+ "poubelle-du.net",
+ "poubelle.fr.nf",
+ "poutineyourface.com",
+ "powerdea.me",
+ "powered.name",
+ "powerencry.com",
+ "powerscrews.com",
+ "powlearn.com",
+ "pox2.com",
+ "pozima.rest",
+ "pp-n6.top",
+ "pp-tw.cc",
+ "pp7rvv.com",
+ "pp9vdr0.ddnsfree.com",
+ "ppdf.cc",
+ "ppedu.pp.ua",
+ "ppetw.com",
+ "ppoo.ccwu.cc",
+ "pptrvv.com",
+ "pqoia.com",
+ "pratikmail.com",
+ "pratikmail.net",
+ "pratikmail.org",
+ "prazdnik-37.ru",
+ "prc.cx",
+ "predatorrat.cf",
+ "predatorrat.ga",
+ "predatorrat.gq",
+ "predatorrat.ml",
+ "predatorrat.tk",
+ "premiapp.com",
+ "premieragents.net",
+ "premieramcpro.xyz",
+ "premierhvacservice.xyz",
+ "premierr.site",
+ "premigu.co",
+ "premirum.shop",
+ "premium-mail.fr",
+ "premiumapk.store",
+ "premiumcrome.site",
+ "premiuminternet.site",
+ "premiuminternetvpn.xyz",
+ "premiumonebd.store",
+ "premiumsigma.shop",
+ "premsy.net",
+ "prestige-leadership.org",
+ "prestore.space",
+ "pretreer.com",
+ "pricegh.com",
+ "pricegh.fun",
+ "pride-worldwi.de",
+ "primabananen.net",
+ "primalhigh.click",
+ "primeacmaintenance.xyz",
+ "primeamcpro.xyz",
+ "primebulidv1.uk",
+ "primehvacpro.xyz",
+ "primejetnet.com",
+ "primemaint.xyz",
+ "primeorchard.top",
+ "primered.shop",
+ "primtech.cn",
+ "prin.be",
+ "printz.site",
+ "prisonity.com",
+ "privacy-mail.top",
+ "privacy.net",
+ "privacyshield.cc",
+ "privatdemail.net",
+ "privmail.edu.pl",
+ "privo.edu.pl",
+ "privy-mail.com",
+ "privy-mail.de",
+ "privymail.de",
+ "priyo-mail.com",
+ "priyo.edu.pl",
+ "priyo.ovh",
+ "priyo.site",
+ "priyoemail.site",
+ "priyomail.in",
+ "priyomail.net",
+ "priyomail.top",
+ "priyomail.uk",
+ "priyomail.us",
+ "priyor.com",
+ "priyp.com",
+ "prkmail.xyz",
+ "pro-tag.org",
+ "pro5g.com",
+ "procontract365.xyz",
+ "procrackers.com",
+ "productmm.shop",
+ "profast.top",
+ "progarhoud.xyz",
+ "proguidex.com",
+ "prohade.com",
+ "prohisi.store",
+ "projectcl.com",
+ "promailt.com",
+ "promptpebble.click",
+ "promudon.xyz",
+ "promyvia.cfd",
+ "proprietativalcea.ro",
+ "propscore.com",
+ "prorises.com",
+ "prosbarsha.xyz",
+ "prosdeira.xyz",
+ "prosdubaiuae.xyz",
+ "prosemaar.xyz",
+ "prosmirdif.xyz",
+ "prosocial.io.vn",
+ "prossportshub.xyz",
+ "protectsmail.net",
+ "protempmail.com",
+ "protonza.com",
+ "prout.be",
+ "proveity.com",
+ "prowashquick.xyz",
+ "prowashsameday.xyz",
+ "proxy-gateway.net",
+ "proxymail.eu",
+ "proxyparking.com",
+ "prp-ppdt.app",
+ "prtnx.com",
+ "prtshr.com",
+ "prtz.eu",
+ "psai.run.place",
+ "psh.me",
+ "psles.com",
+ "psnator.com",
+ "psoxs.com",
+ "ptct.net",
+ "ptncereio.com",
+ "ptnstudio.vn",
+ "ptsculure.com",
+ "puabook.com",
+ "puffiqo.store",
+ "puglieisi.com",
+ "puji.pro",
+ "pulse25k.io.vn",
+ "pulsix.pw",
+ "pumpoly.com",
+ "punchbagartgallery.org.uk",
+ "punkass.com",
+ "punkproof.com",
+ "puppetmail.de",
+ "purcell.email",
+ "pureamcrepair.xyz",
+ "pureheart.org.uk",
+ "purelogistics.org",
+ "purerepairs.xyz",
+ "purifyzed.org",
+ "purmuttad.com",
+ "pursip.com",
+ "pushcom.store",
+ "pusmail.com",
+ "pussport.com",
+ "put2.net",
+ "puttanamaiala.tk",
+ "putthisinyourspamdatabase.com",
+ "puxa.top",
+ "puzzlebirch.me",
+ "puzzledotter.lol",
+ "pvnmax.space",
+ "pwpwa.com",
+ "pwrby.com",
+ "pwx129.love",
+ "pxlys.com",
+ "pyrosenex.cfd",
+ "pyskillsgame.com",
+ "q06htej.ddnsfree.com",
+ "q0y5ffo.kozow.com",
+ "q1zx2zj.camdvr.org",
+ "q7qipaa.kozow.com",
+ "qabq.com",
+ "qacmjeq.com",
+ "qagk.hair",
+ "qaondqs.loseyourip.com",
+ "qasti.com",
+ "qazaxmj.theworkpc.com",
+ "qbfree.us",
+ "qc.to",
+ "qcai.skin",
+ "qcgmtzhzh.top",
+ "qd.je",
+ "qd28max.eu.cc",
+ "qejjyl.com",
+ "qentic.shop",
+ "qeurtor.com",
+ "qexvhz.com",
+ "qfnt.skin",
+ "qhgs.com",
+ "qi3hav9.mywire.org",
+ "qiang66.ccwu.cc",
+ "qibl.at",
+ "qiott.com",
+ "qipmail.net",
+ "qiq.us",
+ "qisdo.com",
+ "qisoa.com",
+ "qiuvision.com",
+ "qjwlaaa.giize.com",
+ "qkwv.skin",
+ "qkxm.skin",
+ "qlbz.hair",
+ "qlhub.org.uk",
+ "qlto.hair",
+ "qmrbe.com",
+ "qodiq.com",
+ "qoika.com",
+ "qopow.com",
+ "qoq.indevs.in",
+ "qpdm.beer",
+ "qq.my",
+ "qqcomeback.me",
+ "qrmason.me",
+ "qrt8e6v.ddnsfree.com",
+ "qrxs6eh.giize.com",
+ "qrzzq7z.ooguy.com",
+ "qsl.ro",
+ "qsmail.cc.cd",
+ "qsuo.uno",
+ "qtum-ico.com",
+ "quadrafit.com",
+ "quantorynexus.space",
+ "quantumeridian.site",
+ "quarvex.cfd",
+ "queenstonegroup.net",
+ "quentavo.cfd",
+ "questtechsystems.com",
+ "quick-mail.cc",
+ "quickaircontech.xyz",
+ "quickdrop.buzz",
+ "quickemail.info",
+ "quickgesture.click",
+ "quickinbox.com",
+ "quickmail.nl",
+ "quicksend.ch",
+ "quillet.eu",
+ "quinz.me",
+ "quipas.com",
+ "quixentra.cfd",
+ "qukv.skin",
+ "quml.skin",
+ "quorixia.cfd",
+ "ququb.com",
+ "qvcq.skin",
+ "qvmao.com",
+ "qvttclass.us",
+ "qvwi.skin",
+ "qvy.me",
+ "qweasd123.cc.cd",
+ "qwertyhandsome.net",
+ "qwezxc321.cc.cd",
+ "qwickmail.com",
+ "qydxproapi.store",
+ "qytechs.cn",
+ "qzueos.com",
+ "r2money.io.vn",
+ "r4nd0m.de",
+ "r8cri1k.theworkpc.com",
+ "ra3.us",
+ "rabin.ca",
+ "rabiot.reisen",
+ "rabitex.com",
+ "racaho.com",
+ "rackabzar.com",
+ "racovor.com",
+ "radioastronomynet.com",
+ "rael.us",
+ "raetp9.com",
+ "rafiv2ray.xyz",
+ "rainbib.co.uk",
+ "rainbowly.ml",
+ "rajaiblis.com",
+ "raketenmann.de",
+ "raleigh-construction.com",
+ "ramcen.com",
+ "ramenmail.de",
+ "ramin200.site",
+ "ramizan.com",
+ "rancidhome.net",
+ "randol.infos.st",
+ "randomail.io",
+ "randomail.net",
+ "rapidairconservice.xyz",
+ "rapidcleangulf.xyz",
+ "rapidcoolfix.xyz",
+ "rapidcoolmaintenance.xyz",
+ "rapidducting.xyz",
+ "rapidefr.fr.nf",
+ "rapidinstallations365.xyz",
+ "rapplo.com",
+ "rapt.be",
+ "raqid.com",
+ "ravavo.bond",
+ "ravc.beer",
+ "ravensholme.net",
+ "raveqxon.blog",
+ "raveqxon.store",
+ "rawr.foo",
+ "rax.la",
+ "raxtest.com",
+ "rayrayactive.com",
+ "razemail.com",
+ "razeny.com",
+ "razuz.com",
+ "rbb.org",
+ "rbmz.beer",
+ "rbs1.xyz",
+ "rc38643.ddnsfree.com",
+ "rcasd.com",
+ "rccgmanifestinghisglory.co.uk",
+ "rcpt.at",
+ "rdklcrv.xyz",
+ "rdlt0hi.mywire.org",
+ "rdsekaunx.top",
+ "re-gister.com",
+ "reacc.me",
+ "reality-concept.club",
+ "realize.edu.pl",
+ "reallymymail.com",
+ "realmail.co",
+ "realman.mywire.org",
+ "realmka.io",
+ "realquickemail.com",
+ "realseek.xyz",
+ "realtyalerts.ca",
+ "reapsa.com",
+ "reason.edu.pl",
+ "rebates.stream",
+ "receiveee.com",
+ "recipeforfailure.com",
+ "reclameaqui.services",
+ "recode.me",
+ "reconmail.com",
+ "recyclemail.dk",
+ "redbiodiesel.com",
+ "redfeathercrow.com",
+ "redi.fr.nf",
+ "rednotecreatoracademy.art",
+ "redproxies.com",
+ "reestore.site",
+ "refblogs.com",
+ "refk.site",
+ "reftoken.net",
+ "regapts.com",
+ "regbypass.com",
+ "regexquill.me",
+ "regspaces.tk",
+ "regvippro.site",
+ "reimondo.com",
+ "rejectmail.com",
+ "rejo.technology",
+ "rekaer.com",
+ "relayne.top",
+ "reliable-mail.com",
+ "relieve.us.ci",
+ "remahankerupuk.com",
+ "remai.freeddns.org",
+ "remail.cf",
+ "remail.ga",
+ "remarkable.rocks",
+ "remind.edu.pl",
+ "remote.li",
+ "remoteflyer.co.uk",
+ "remraammaintenance.com",
+ "rensumo.top",
+ "rentaen.com",
+ "rentop.asia",
+ "renvotra.cfd",
+ "renydox.com",
+ "reovipvpn.top",
+ "repairannual365.xyz",
+ "repairgarhoud.xyz",
+ "repairsjumeirah.xyz",
+ "repairsolutionsae.xyz",
+ "repairssportshub.xyz",
+ "replyloop.com",
+ "reportfresh.com",
+ "reptilegenetics.com",
+ "resgedvgfed.tk",
+ "resmso.com",
+ "restorationchurchtv.com",
+ "resumeforest.cn",
+ "reusable.email",
+ "reusecomputers.site",
+ "reverendneedham.co.uk",
+ "reviewfood.vn",
+ "revolvingdoorhoax.org",
+ "rewermail.com",
+ "rexabot.com",
+ "rexmoo.ccwu.cc",
+ "rexornge.net",
+ "rexornge.site",
+ "reymaticx.com",
+ "rezalabs.shop",
+ "rezato.com",
+ "rf.gd",
+ "rfc822.org",
+ "rfods.tech",
+ "rfzpnrbpv.top",
+ "rgbeastcomputers.site",
+ "rgwdomb.theworkpc.com",
+ "rhalmshouse.church",
+ "rhalmshouse.co.uk",
+ "rhyta.com",
+ "riazra.bond",
+ "ricardo0911.xyz",
+ "richfinances.pw",
+ "riddermark.de",
+ "riffproductions.co.uk",
+ "rifkian.ga",
+ "rifqysaputra.dev",
+ "rikka0612.site",
+ "rilzz.store",
+ "rimshacooking.site",
+ "rinseart.com",
+ "riposh.shop",
+ "rippb.com",
+ "ripplebrief.buzz",
+ "risinggirl.co.uk",
+ "risingsuntouch.com",
+ "riski.cf",
+ "risu.be",
+ "riwutz-gmail.com",
+ "rjzh8vf.giize.com",
+ "rklips.com",
+ "rkomo.com",
+ "rkzpcx.top",
+ "rm2rf.com",
+ "rma.ec",
+ "rmmr.fun",
+ "rmqkr.net",
+ "rnailinator.com",
+ "rniz667.mywire.org",
+ "rnmut8pudgj0ikregqx6.dynv6.net",
+ "rnvvpn.store",
+ "ro.lt",
+ "robertspcrepair.com",
+ "roborena.com",
+ "robot-mail.com",
+ "robothandcook.cfd",
+ "robustq.com",
+ "rodhazlitt.com",
+ "roguemaster.dev",
+ "roiv.hair",
+ "rollindo.agency",
+ "ronete.com",
+ "ronger.cfd",
+ "ronghsng.buzz",
+ "ronnierage.net",
+ "rootfest.net",
+ "roratu.com",
+ "roroki.cn",
+ "rosebearmylove.ru",
+ "rosella.my.id",
+ "rosequartzglow.beauty",
+ "rotaniliam.com",
+ "rotherhamcorp.net",
+ "rotomails.co.uk",
+ "rotomails.com",
+ "rouflav.com",
+ "route64.de",
+ "routerboardvietnam.com",
+ "rover.info",
+ "rowdydow.com",
+ "rowe-solutions.com",
+ "roweryo.com",
+ "royal.net",
+ "royalairconservice.xyz",
+ "royalcoolair.xyz",
+ "royaldoodles.org",
+ "royalmarket.life",
+ "royalpriesthoodagc.org.uk",
+ "royalrepairs.xyz",
+ "royalthermo.xyz",
+ "royandk.com",
+ "roybl.com",
+ "rppkn.com",
+ "rr.nu",
+ "rshz.hair",
+ "rsvhr.com",
+ "rteet.com",
+ "rtrobinsbequest.org.uk",
+ "rtrtr.com",
+ "rtskiya.xyz",
+ "rua.pp.ua",
+ "rudymail.ml",
+ "ruinique.tech",
+ "rukiaops.me",
+ "rulersonline.com",
+ "rulp.skin",
+ "rumahweb.site",
+ "rumgel.com",
+ "runcubesapps.id",
+ "runi.ca",
+ "runlop6.ooguy.com",
+ "rupayamail.com",
+ "ruru.be",
+ "rustydoor.com",
+ "rustyload.com",
+ "ruu.kr",
+ "ruutukf.com",
+ "rvb.ro",
+ "rvhx.hair",
+ "rvneous.com",
+ "rw1aifc.camdvr.org",
+ "rweebz-mail-1.online",
+ "rwstatus.com",
+ "rwxsxnomo.top",
+ "rxinai.com",
+ "rxs9.cn",
+ "rygel.infos.st",
+ "rynetvpn.site",
+ "ryteto.me",
+ "ryuu.codes",
+ "ryuucn.com",
+ "ryyr.ru",
+ "ryyr.store",
+ "rzbal.xyz",
+ "s-mail.social",
+ "s-mdg.top",
+ "s0ny.net",
+ "s1q59z82q.top",
+ "s33db0x.com",
+ "s3cmt5w.kozow.com",
+ "s3k.net",
+ "s40vwj8.camdvr.org",
+ "s4sfoundation.org.uk",
+ "s8gz4ay.kozow.com",
+ "sabrestlouis.com",
+ "sackboii.com",
+ "sad23321adja.cn",
+ "sada-sd.cc",
+ "saddope.lol",
+ "sadfg.indevs.in",
+ "sadrain.shop",
+ "sads-ads-awe.top",
+ "saeoil.com",
+ "safaat.cf",
+ "safeamcpro.xyz",
+ "safermail.info",
+ "safersignup.de",
+ "safetymail.info",
+ "safetypost.de",
+ "sagametour.com",
+ "saharanightstempe.com",
+ "sahildash.dev",
+ "sailmail.io",
+ "saisonhama.shop",
+ "sajutadollars.com",
+ "sakoonthroughcancer.com",
+ "sakurapink.top",
+ "salakoais.shop",
+ "saleis.live",
+ "salmeow.tk",
+ "sam1.eu.org",
+ "samaltour.site",
+ "samaritanfamilyfoundation.com",
+ "samblogs.nett.to",
+ "samblogs.zone.id",
+ "sampsave.tech",
+ "samsclass.info",
+ "samsung-gmail.com",
+ "sandcars.net",
+ "sandduct.xyz",
+ "sandelf.de",
+ "sandwhichvideo.com",
+ "sanfinder.com",
+ "sanfnge.run",
+ "sanfnges.cc",
+ "sanim.net",
+ "sanstr.com",
+ "saovangtiles.site",
+ "sappisi.com",
+ "sarawakreport.com",
+ "sast.ro",
+ "sasukiez.shop",
+ "satinfern.beauty",
+ "satisfyme.club",
+ "satorix.id",
+ "satukosong.com",
+ "saturrshop.uk",
+ "saungadaid.pro",
+ "sausen.com",
+ "save4now.com",
+ "savemydinar.com",
+ "savests.com",
+ "say0.com",
+ "saynotospams.com",
+ "sayverfox.com",
+ "scatmail.com",
+ "scatterteam.com",
+ "scay.net",
+ "schachrol.com",
+ "schafmail.de",
+ "schemastash.me",
+ "schmeissweg.tk",
+ "schrott-email.de",
+ "scook.cfd",
+ "scottcoffman.net",
+ "scottni.com",
+ "scpulse.com",
+ "scribito.de",
+ "scrsot.com",
+ "sd3.in",
+ "sdbbs.cc",
+ "sdhaiok.eu.cc",
+ "sdi5mon.ooguy.com",
+ "sdj.fr.nf",
+ "sds-awe.top",
+ "sdsfre.blog",
+ "sdvft.com",
+ "sdvgeft.com",
+ "sdvrecft.com",
+ "seamandstory.cfd",
+ "search4gpt.com",
+ "seasonofgrace.org",
+ "seaworld.asia",
+ "sebbcn.net",
+ "secmail.pw",
+ "secretemail.de",
+ "secure-mail.biz",
+ "secure-mail.cc",
+ "secured-link.net",
+ "securehost.com.es",
+ "securitybot.online",
+ "sedekah-mudah.com",
+ "seduck.com",
+ "seedancespace.shop",
+ "seedcorn.foundation",
+ "seedspeed.site",
+ "seekapps.com",
+ "seekjobs4u.com",
+ "sehier.fr",
+ "sejaa.lv",
+ "sekotong.store",
+ "selaciptama.com",
+ "seler25k.io.vn",
+ "selfdestructingmail.com",
+ "selfdestructingmail.org",
+ "selftrak.fit",
+ "selltub.com",
+ "selltub.me",
+ "seman.edu.kg",
+ "semester.bond",
+ "send22u.info",
+ "send4.uk",
+ "sendapp.uk",
+ "sendfree.org",
+ "sendgrid.ovh",
+ "sendingspecialflyers.com",
+ "sendnow.win",
+ "sendo.dedyn.io",
+ "sendo.giize.com",
+ "sendokai.click",
+ "sendos.fr.nf",
+ "sendos.infos.st",
+ "sendspamhere.com",
+ "sengkuni.dedyn.io",
+ "sengkuni.qzz.io",
+ "sengkuni.xx.kg",
+ "senseless-entertainment.com",
+ "sentra-premium.com",
+ "senvas.me",
+ "senyan.fun",
+ "seosnaps.com",
+ "seqerc.com",
+ "serbaprem.store",
+ "serbaserbisoftware.site",
+ "serenamaintenance.com",
+ "serenith.cfd",
+ "server.ms",
+ "servicee.es",
+ "servicenadalsheba.xyz",
+ "services391.com",
+ "servicesalquoz.xyz",
+ "servicesdso.xyz",
+ "servicesmeydan.xyz",
+ "serviceszabeel.xyz",
+ "servicetilal.xyz",
+ "servicetr.me",
+ "sesasdl.top",
+ "setxko.com",
+ "severnday.us",
+ "sevopay.com",
+ "sewink.my.id",
+ "sex520.eu.org",
+ "sexforswingers.com",
+ "sexfrancecute.lol",
+ "sexfrancecute.tattoo",
+ "sexfrancecute.xyz",
+ "sexical.com",
+ "sexyalwasmi.top",
+ "sexymeowers.site",
+ "sexymeowers.space",
+ "sexymeowers.store",
+ "sexymeowers.xyz",
+ "sfacg.org",
+ "sfolkar.com",
+ "sfr09g1.camdvr.org",
+ "sgatra.com",
+ "sgm.ovh",
+ "shaceng.biz.id",
+ "shaceng.my.id",
+ "shadap.org",
+ "shaguaaini.click",
+ "shahabnoor.org",
+ "shalar.net",
+ "shanhaijuli.sbs",
+ "sharebot.net",
+ "sharedmailbox.org",
+ "sharedwillow.sbs",
+ "sharkfaces.com",
+ "sharklasers.com",
+ "sharpairconcare.xyz",
+ "sharpaircool.xyz",
+ "sharpfreon.xyz",
+ "sharpmaint.xyz",
+ "shchiba.uk",
+ "sheerid.top",
+ "sheinup.com",
+ "shengxia.asia",
+ "sherazhaider.com",
+ "sheryli.com",
+ "shhmail.com",
+ "shhuut.org",
+ "shieldedmail.com",
+ "shieldemail.com",
+ "shiftmail.com",
+ "shiita12.com",
+ "shipfromto.com",
+ "shiphazmat.org",
+ "shipping-regulations.com",
+ "shippingterms.org",
+ "shiptudo.com",
+ "shiro-alice.live",
+ "shitaway.tk",
+ "shitmail.de",
+ "shitmail.me",
+ "shitmail.org",
+ "shitty.email",
+ "shizuku-lulu.love",
+ "shkele.cyou",
+ "shmavje.mywire.org",
+ "shmeriously.com",
+ "shopaccmmo.com",
+ "shopbantkclone.com",
+ "shopcreative.cc",
+ "shopempva11.com",
+ "shopld.linkpc.net",
+ "shopprimeaccounts.com",
+ "shopvipduzk666.shop",
+ "shopvipduzk6868.shop",
+ "shopxda.com",
+ "shopy.club",
+ "shortmail.net",
+ "shortweb.live",
+ "shotmail.ru",
+ "showslow.de",
+ "shrib.com",
+ "shuelder.com",
+ "shut.name",
+ "shut.ws",
+ "shxwonew.xyz",
+ "si1ver.xyz",
+ "siap-sepuh.com",
+ "sibafo.asia",
+ "siberpay.com",
+ "siby9w6.loseyourip.com",
+ "sicmg.com",
+ "sidelka-mytischi.ru",
+ "sidement.com",
+ "sidlabs.site",
+ "sidneymichaelpoland.travel",
+ "sidramaintenance.com",
+ "siempre.gratis",
+ "sifan077.cc.cd",
+ "siftportal.ru",
+ "sig.de5.net",
+ "signalxp.com",
+ "signsoflife.org.uk",
+ "sihanoma.store",
+ "sika3.com",
+ "sikux.com",
+ "silenceofthespam.com",
+ "silentmail.ccwu.cc",
+ "siliwangi.ga",
+ "silvaron.cfd",
+ "silveraircool.xyz",
+ "silvercoin.life",
+ "silverleafgroup.net",
+ "sim-simka.ru",
+ "sim-sppg.com",
+ "simaenaga.com",
+ "simoka.sbs",
+ "simplecoolmaintenance.xyz",
+ "simpleducting.xyz",
+ "simplefreon.xyz",
+ "simpleitsecurity.info",
+ "simranaitech.space",
+ "sin.cl",
+ "sinaite.net",
+ "sind-hier.com",
+ "sind-wir.com",
+ "sindhier.com",
+ "sindwir.com",
+ "sinema.ml",
+ "sinfiltro.cl",
+ "singlespride.com",
+ "sinnlos-mail.de",
+ "sino.tw",
+ "sinorto.com",
+ "siperer.online",
+ "siroja.top",
+ "siteinfox.com",
+ "sitemaplark.me",
+ "siteposter.net",
+ "sitwelltownlands.org.uk",
+ "six25.biz",
+ "sixoplus.com",
+ "sizzlemctwizzle.com",
+ "sjuaq.com",
+ "sjusngde.info",
+ "sk-ai.eu.cc",
+ "skeefmail.com",
+ "skingkong.com",
+ "skrak.com",
+ "skrhqfnya.top",
+ "skrx.tk",
+ "sky-inbox.com",
+ "sky-ts.de",
+ "skyemails.app",
+ "skyemails.tech",
+ "skyforte25k.io.vn",
+ "skygazer42.cn",
+ "skygazerhub.com",
+ "skyhope666.icu",
+ "skyhope666.shop",
+ "skyhope666.xyz",
+ "skyhopenb.shop",
+ "skyhopenb.xyz",
+ "skyhopenba.shop",
+ "skyhopetw.xyz",
+ "skyhopewd.xyz",
+ "skyhopewdf.xyz",
+ "skynet.infos.st",
+ "skyrt.de",
+ "skyserver.cyou",
+ "skytopway.com",
+ "slapsfromlastnight.com",
+ "slaskpost.se",
+ "slave-auctions.net",
+ "sleepyplatypus.lol",
+ "slimirin.com",
+ "slippery.email",
+ "slipry.net",
+ "sliverkeigo.fun",
+ "slopsbox.com",
+ "slotbeer.com",
+ "slothmail.net",
+ "slowpokerfrance.eu",
+ "slowpokerfrance.fr",
+ "slowpokerfrance.org",
+ "slowpokerfrance.store",
+ "slushmail.com",
+ "slushpools.cloud",
+ "sluteen.com",
+ "sly.io",
+ "slyouthuk.org",
+ "smailpro.com",
+ "smailwork.com",
+ "smakit.vn",
+ "smallker.tk",
+ "smapfree24.com",
+ "smapfree24.de",
+ "smapfree24.eu",
+ "smapfree24.info",
+ "smapfree24.org",
+ "smartaccare.xyz",
+ "smartamccare.xyz",
+ "smartannualquick.xyz",
+ "smartcooltech.xyz",
+ "smartemailbox.co",
+ "smartfixgulf.xyz",
+ "smartnator.com",
+ "smartsolutionsfast.xyz",
+ "smarttalent.pw",
+ "smarttickethub.com",
+ "smashmail.de",
+ "smell.sbs",
+ "smellfear.com",
+ "smellrear.com",
+ "smellypotato.tk",
+ "smeux.com",
+ "smkanba.com",
+ "smncloud.com",
+ "smpcharity.co.uk",
+ "smpnegeri4-pwt.my.id",
+ "smtp.id.vn",
+ "smtp.io.vn",
+ "smtp99.com",
+ "smwg.info",
+ "smykwb.com",
+ "snakebutt.com",
+ "snakemail.com",
+ "snapbx.com",
+ "snapcanvas.click",
+ "snapmail.cc",
+ "snapmail.site",
+ "snapmail.xyz",
+ "snapwet.com",
+ "sneakmail.de",
+ "snece.com",
+ "sneydgreen-communityhall.co.uk",
+ "snpmail.fun",
+ "sobhahartlandservices.com",
+ "soc123.net",
+ "social-mailer.tk",
+ "socialfurry.org",
+ "socialist.sbs",
+ "sociallymediocre.com",
+ "socialpulse.casa",
+ "socialtrash.online",
+ "soco7.com",
+ "socoori.com",
+ "sofia.re",
+ "sofimail.com",
+ "sofort-mail.de",
+ "sofortmail.de",
+ "sofrge.com",
+ "softbank.id",
+ "softdailyguide.com",
+ "softkey-office.ru",
+ "softlitceg.com",
+ "softpls.asia",
+ "sogetthis.com",
+ "sohai.ml",
+ "sohus.cn",
+ "soioa.com",
+ "soisz.com",
+ "solar-impact.pro",
+ "solarnyx.com",
+ "solarunited.net",
+ "solarunited.org",
+ "solenox.cfd",
+ "solerbe.net",
+ "solidairconpro.xyz",
+ "solimoddin.online",
+ "solkill.store",
+ "solquira.cfd",
+ "solvemail.info",
+ "solventtrap.wiki",
+ "somadethcdk.site",
+ "somelora.com",
+ "somervillepartners.net",
+ "somestoneair.shop",
+ "somoj.com",
+ "son2d.site",
+ "songsan.business",
+ "songsign.com",
+ "soniot.fr",
+ "soniot.info",
+ "soniot.online",
+ "soniot.store",
+ "sonjj.edu.pl",
+ "sonl192.theworkpc.com",
+ "sonny.tk",
+ "sonphuongthinh.com",
+ "sonrusu.com",
+ "sonshi.cf",
+ "soodmail.com",
+ "soodomail.com",
+ "soodonims.com",
+ "soombo.com",
+ "soon.it",
+ "soscandia.org",
+ "soson.shop",
+ "soul-association.com",
+ "southai.bond",
+ "southdubaimaintenance.com",
+ "southeastessexanimal.uk",
+ "soyboy.observer",
+ "sozenit.com",
+ "spacebazzar.ru",
+ "spacehotline.com",
+ "spacework.top",
+ "spacexyz.space",
+ "spacezin.space",
+ "spam-be-gone.com",
+ "spam.care",
+ "spam.ceo",
+ "spam.la",
+ "spam.org.es",
+ "spam.su",
+ "spam4.me",
+ "spamail.de",
+ "spamarrest.com",
+ "spamavert.com",
+ "spambob.com",
+ "spambob.net",
+ "spambob.org",
+ "spambog.com",
+ "spambog.de",
+ "spambog.net",
+ "spambog.ru",
+ "spambooger.com",
+ "spambox.info",
+ "spambox.me",
+ "spambox.org",
+ "spambox.us",
+ "spamcero.com",
+ "spamcon.org",
+ "spamcorptastic.com",
+ "spamcowboy.com",
+ "spamcowboy.net",
+ "spamcowboy.org",
+ "spamday.com",
+ "spamdecoy.net",
+ "spamex.com",
+ "spamfellas.com",
+ "spamfighter.cf",
+ "spamfighter.ga",
+ "spamfighter.gq",
+ "spamfighter.ml",
+ "spamfighter.tk",
+ "spamfree.eu",
+ "spamfree24.com",
+ "spamfree24.de",
+ "spamfree24.eu",
+ "spamfree24.info",
+ "spamfree24.net",
+ "spamfree24.org",
+ "spamgoes.in",
+ "spamherelots.com",
+ "spamhereplease.com",
+ "spamhole.com",
+ "spamify.com",
+ "spaminator.de",
+ "spamkill.info",
+ "spaml.com",
+ "spaml.de",
+ "spamlot.net",
+ "spammer.fail",
+ "spammotel.com",
+ "spammy.host",
+ "spamobox.com",
+ "spamoff.de",
+ "spamsalad.in",
+ "spamsandwich.com",
+ "spamslicer.com",
+ "spamsphere.com",
+ "spamspot.com",
+ "spamstack.net",
+ "spamthis.co.uk",
+ "spamthis.network",
+ "spamthisplease.com",
+ "spamtrail.com",
+ "spamtrap.ro",
+ "spamtroll.net",
+ "spamwc.cf",
+ "spamwc.ga",
+ "spamwc.gq",
+ "spamwc.ml",
+ "sparkletoc.com",
+ "sparxautomationhub.store",
+ "speak2all.com",
+ "speaknow.org.uk",
+ "specialneedscircle.co.uk",
+ "speedgaus.net",
+ "speedlooking.fun",
+ "sperma.cf",
+ "spicysoda.com",
+ "spiedemtion.store",
+ "spikio.com",
+ "spindl-e.com",
+ "spinly.net",
+ "splitparents.com",
+ "spoofmail.de",
+ "sportcornwall.org",
+ "sportrid.com",
+ "sportscitymaintenance.com",
+ "sportsfunlifestyle.org",
+ "spotiy.site",
+ "spotlightdiary.com",
+ "spr.io",
+ "spritzzone.de",
+ "sproutassembly.sbs",
+ "spruzme.com",
+ "spybox.de",
+ "spymail.com",
+ "spymail.one",
+ "squizzy.de",
+ "squizzy.net",
+ "srame.com",
+ "sroff.com",
+ "sry.li",
+ "srzi.makeup",
+ "ss0qw9a.ddnsfree.com",
+ "ssanphone.me",
+ "ssi-bsn.infos.st",
+ "sslglance.me",
+ "ssoia.com",
+ "sssig.one",
+ "sssvcv.uno",
+ "stableharvest.bond",
+ "stacys.mom",
+ "stanfordujjain.com",
+ "star-fish.org.uk",
+ "starbalance.site",
+ "starcheck.in",
+ "stardust-2.store",
+ "starlight-breaker.net",
+ "starlight.store",
+ "starmail.net",
+ "starnlink.com",
+ "starpower.space",
+ "startfu.com",
+ "startkeys.com",
+ "statdvr.com",
+ "stathost.net",
+ "statiix.com",
+ "stayhome.li",
+ "stc-88.com",
+ "steadyhabithub.com",
+ "steam-area.ru",
+ "steam-coin.com",
+ "steambot.net",
+ "steel4work.io.vn",
+ "stepsofhope.co.uk",
+ "sterlingpointco.com",
+ "stexsy.com",
+ "stinkefinger.net",
+ "stomatogomail.ru",
+ "stoneground.shop",
+ "stonesinwater.com",
+ "stop-my-spam.cf",
+ "stop-my-spam.com",
+ "stop-my-spam.ga",
+ "stop-my-spam.ml",
+ "stop-my-spam.pp.ua",
+ "stop-my-spam.tk",
+ "stopspam.app",
+ "storebanme.com",
+ "storegmail.net",
+ "storiesonthestreet.org",
+ "storiqax.top",
+ "storj99.com",
+ "storj99.top",
+ "storybuddies.online",
+ "stpaulsgrammaralumniusa.org",
+ "stravico.cfd",
+ "strayhood.org",
+ "streamingku.live",
+ "streamtv.biz.id",
+ "streamwolf.store",
+ "streetwisemail.com",
+ "stromox.com",
+ "strongairconpro.xyz",
+ "stronghvactech.xyz",
+ "strongmaintenance.xyz",
+ "strongthermo.xyz",
+ "stuckmail.com",
+ "student.nyc.mn",
+ "studio.jo3.org",
+ "studio.publicvm.com",
+ "studyhub.edu.pl",
+ "stuffmail.de",
+ "stufmail.com",
+ "stumpfwerk.com",
+ "stylist-volos.ru",
+ "su.cloudns.biz",
+ "suarj.com",
+ "submic.com",
+ "submitreports.com",
+ "substoretools.com",
+ "substoretools.site",
+ "substoretools.store",
+ "suburbanthug.com",
+ "suckmyd.com",
+ "sudern.de",
+ "sueshaw.com",
+ "suexamplesb.com",
+ "suft.autos",
+ "suftwari.com",
+ "sugarloafstudios.net",
+ "suhasmedam.com",
+ "suioe.com",
+ "sumberakun.com",
+ "summitamc.xyz",
+ "summitcabin.top",
+ "summitcare24x7.xyz",
+ "summitcare365.xyz",
+ "summitcoolcare.xyz",
+ "summitcoolservice.xyz",
+ "summitfacility.xyz",
+ "summitrepairquick.xyz",
+ "summitworksgulf.xyz",
+ "sunlitcircle.cyou",
+ "sunmoonlight.shop",
+ "sunnysill.homes",
+ "sunstarmoon.shop",
+ "suntuy.com",
+ "super-auswahl.de",
+ "super.lgbt",
+ "superblohey.com",
+ "superbowlstarttime.org",
+ "supergreatmail.com",
+ "supergrok.site",
+ "supermailer.jp",
+ "superplatyna.com",
+ "superrito.com",
+ "supersave.net",
+ "superstachel.de",
+ "superti4r.dev",
+ "superyp.com",
+ "supplementsdiary.com",
+ "supplementwiki.org",
+ "supportorganization.online",
+ "supporttc.com",
+ "surbitonnewlifebaptist.com",
+ "sureaircon.xyz",
+ "surecoolrepair.xyz",
+ "suremail.info",
+ "suryain.app",
+ "sushisalmon.online",
+ "sustainablecitymaintenance.com",
+ "sute.jp",
+ "svetims.com",
+ "svgmend.me",
+ "svip520.cn",
+ "svip888888888.com",
+ "svipcun.eu.org",
+ "svk.jp",
+ "svlg.hair",
+ "svvf.hair",
+ "svxr.org",
+ "swagpapa.com",
+ "swap.name.ng",
+ "sweetberry.online",
+ "sweetpotato.ml",
+ "sweetxxx.de",
+ "swi4jut.ddnsfree.com",
+ "swift-mail.net",
+ "swift10minutemail.com",
+ "swiftcroft.org",
+ "swifthvacmaintenance.xyz",
+ "swiftmaintenancegulf.xyz",
+ "swiftwashdxb.xyz",
+ "swissokalmo.org.uk",
+ "swsdz.com",
+ "sx227.top",
+ "sx235.top",
+ "sx697.top",
+ "sx856.top",
+ "sx872.top",
+ "sx882.top",
+ "sx992.top",
+ "sydney.edu.pl",
+ "syecna9.ooguy.com",
+ "syinxun.com",
+ "sylentha.cfd",
+ "sylnetdns.site",
+ "sylnetgcp.site",
+ "sylnetgcp.top",
+ "sylnetx1.top",
+ "sylvannet.com",
+ "symphonyresume.com",
+ "synarca.com",
+ "syncax.com",
+ "synovaris.cfd",
+ "syntheradynamics.site",
+ "syosetu.gq",
+ "sysout.cloud",
+ "sytchampton.directory",
+ "sytchamptoncc.co.uk",
+ "syujob.accountants",
+ "sz345p7.loseyourip.com",
+ "szerz.com",
+ "szxcfagah.online",
+ "t15ippy.giize.com",
+ "t1mo.net",
+ "t2d9n0j.ooguy.com",
+ "t2t.email",
+ "t6s7.top",
+ "t7sqtao.giize.com",
+ "t8xieb2.kozow.com",
+ "tabmender.me",
+ "tacomail.de",
+ "tafmail.com",
+ "tafnsxo.ooguy.com",
+ "tafoi.gr",
+ "tagara.infos.st",
+ "taglead.com",
+ "tagmymedia.com",
+ "tagyourself.com",
+ "taikhoankiro.pro",
+ "taimb.com",
+ "taischaves.com",
+ "tako.skin",
+ "talemarketing.com",
+ "talkinator.com",
+ "talkmatrix.study",
+ "talkup.sbs",
+ "talmetry.com",
+ "tamthomca.click",
+ "tamttts.com",
+ "tandlplith.se",
+ "tanlanav.com",
+ "tanpony.com",
+ "tanukis.org",
+ "tanvo.online",
+ "taobudao.com",
+ "taohucom.store",
+ "taojingrun.online",
+ "taomail.web.id",
+ "tapchicuoihoi.com",
+ "taphear.com",
+ "tapi.re",
+ "tapparade.click",
+ "tarasbest.xyz",
+ "tartinemoi.com",
+ "tarzanmail.cf",
+ "taseek.cc.cd",
+ "taseek.top",
+ "tastmemail.com",
+ "tastrg.com",
+ "tatadidi.com",
+ "tatsu.uk",
+ "taugr.com",
+ "taukah.com",
+ "taxibmt.com",
+ "taxibmt.net",
+ "taymonera.de",
+ "tb-on-line.net",
+ "tbgroupconsultants.com",
+ "tbr.fr.nf",
+ "tbterqwzv.top",
+ "tcwlm.com",
+ "tcwlx.com",
+ "tdtda.com",
+ "teambunnyfoundation.org.uk",
+ "teamnewmexico.org",
+ "teamtitan.co",
+ "tech2grow.io.vn",
+ "tech69.com",
+ "techblast.ch",
+ "techcitywalk.xyz",
+ "techcyber.shop",
+ "techdubaicreek.xyz",
+ "techemail.com",
+ "techgroup.me",
+ "techjaddaf.xyz",
+ "techlhj.email",
+ "technoproxy.ru",
+ "techsdamachills.xyz",
+ "techsdifc.xyz",
+ "techsdubai.xyz",
+ "techsfestivalcity.xyz",
+ "techsgarhoud.xyz",
+ "techstecom.xyz",
+ "techstilal.xyz",
+ "techtecom.xyz",
+ "techusa.org",
+ "teerest.com",
+ "teewars.org",
+ "tefl.ro",
+ "tehillahpraise.uk",
+ "teihu.com",
+ "telecomix.pl",
+ "teleg.eu",
+ "telegmail.com",
+ "teleworm.com",
+ "teleworm.us",
+ "teliedge.com",
+ "teligmail.site",
+ "telimail.online",
+ "tellos.xyz",
+ "telos.lat",
+ "telvetto.com",
+ "temailz.com",
+ "tembusselalu.biz.id",
+ "tembusselalu.my.id",
+ "teml.net",
+ "temp-email.de5.net",
+ "temp-link.net",
+ "temp-mail-free.dedyn.io",
+ "temp-mail-free.dynv6.net",
+ "temp-mail.best",
+ "temp-mail.cfd",
+ "temp-mail.com",
+ "temp-mail.de",
+ "temp-mail.edu.rs",
+ "temp-mail.freeddns.org",
+ "temp-mail.gg",
+ "temp-mail.lol",
+ "temp-mail.now",
+ "temp-mail.org",
+ "temp-mail.pp.ua",
+ "temp-mail.ru",
+ "temp-mailer.dynv6.net",
+ "temp-mails.com",
+ "temp.skymeshdynamics.com",
+ "temp1month.icu",
+ "tempail.com",
+ "tempalias.com",
+ "tempblockchain.com",
+ "tempe-mail.com",
+ "tempebossok.my.id",
+ "tempemail.biz",
+ "tempemail.co.za",
+ "tempemail.com",
+ "tempemail.de",
+ "tempemail.net",
+ "tempemail.nyc.mn",
+ "tempemailgen.com",
+ "tempemaill.com",
+ "tempemailo.org",
+ "tempestme.com",
+ "tempinbox.co.uk",
+ "tempinbox.com",
+ "tempmail.at",
+ "tempmail.au",
+ "tempmail.best",
+ "tempmail.cc",
+ "tempmail.cn",
+ "tempmail.co",
+ "tempmail.de",
+ "tempmail.email",
+ "tempmail.eu",
+ "tempmail.id.vn",
+ "tempmail.ing",
+ "tempmail.io.vn",
+ "tempmail.it",
+ "tempmail.ninja",
+ "tempmail.plus",
+ "tempmail.pp.ua",
+ "tempmail.us",
+ "tempmail.vip",
+ "tempmail.world",
+ "tempmail.ws",
+ "tempmail101.com",
+ "tempmail2.com",
+ "tempmail5.com",
+ "tempmaildemo.com",
+ "tempmailer.com",
+ "tempmailer.de",
+ "tempmailer.net",
+ "tempmailfree.net",
+ "tempmailnow.io",
+ "tempmailo.com",
+ "tempmailr.com",
+ "tempmails.net",
+ "tempmailto.com",
+ "tempmailyo.org",
+ "tempomail.fr",
+ "tempomail.org",
+ "tempomailo.site",
+ "temporalemail.org",
+ "temporam.com",
+ "temporam.online",
+ "temporam.xin",
+ "temporam.xyz",
+ "temporarily.de",
+ "temporarioemail.com.br",
+ "temporarly.com",
+ "temporary-mail.net",
+ "temporaryemail.dpdns.org",
+ "temporaryemail.net",
+ "temporaryemail.us",
+ "temporaryforwarding.com",
+ "temporaryinbox.com",
+ "temporarymailaddress.com",
+ "tempp-mails.com",
+ "temppppo.store",
+ "tempr.email",
+ "tempsky.com",
+ "temptami.com",
+ "tempthe.net",
+ "tempymail.com",
+ "tenhub.uk",
+ "tensi.org",
+ "tenull.com",
+ "ternaklele.ga",
+ "terre.infos.st",
+ "test-infos.fr.nf",
+ "test1byali.cfd",
+ "testore.co",
+ "testudine.com",
+ "tetestebu.online",
+ "tevstart.com",
+ "texify.online",
+ "textpivot.me",
+ "tfbnw.asia",
+ "tfbnw.id.vn",
+ "tfbnw.in",
+ "tfbnw.info",
+ "tfbnw.io.vn",
+ "tfbnw.tech",
+ "tfbnw.xyz",
+ "tfcn.beer",
+ "tffv4a3.kozow.com",
+ "tgduck.com",
+ "tgmaiss.xyz",
+ "tgnm.hair",
+ "tgoj.skin",
+ "thaihp.net",
+ "thaitudang.xyz",
+ "thalarex.cfd",
+ "thameschamberorchestra.co.uk",
+ "thanhnhontienbao.com",
+ "thanksnospam.info",
+ "thankyou2010.com",
+ "thaotri.com",
+ "thatim.info",
+ "thc.st",
+ "thdfyrg.top",
+ "the23app.com",
+ "theaviors.com",
+ "thebearshark.com",
+ "thebest73.shop",
+ "theboys.cyou",
+ "thecarinformation.com",
+ "thechildrensfocus.com",
+ "thecity.biz",
+ "thecloudindex.com",
+ "thecwf.co.uk",
+ "thediamants.org",
+ "thedirhq.info",
+ "thedorsetcarershub.org",
+ "theducting.xyz",
+ "theeyeoftruth.com",
+ "thefixit.xyz",
+ "thegrampians.net",
+ "thehjhvj.ink",
+ "thejenkins.net",
+ "thejoker5.com",
+ "theking.id",
+ "thelakesmaintenance.com",
+ "thelifecentre.org.uk",
+ "thelightningmail.net",
+ "thelimescommunitygarden.org",
+ "thelimestones.com",
+ "themailer.de",
+ "thembones.com.au",
+ "themccarneyfoundation.co.uk",
+ "themegreview.com",
+ "themostemail.com",
+ "thereddoors.online",
+ "theroyalweb.club",
+ "thescrappermovie.com",
+ "these.cc",
+ "theshahnazfoundation.com",
+ "thespamfather.com",
+ "thespringsmaintenance.com",
+ "theteastory.info",
+ "thetechnext.net",
+ "thevalleymaintenance.com",
+ "thevillamaintenance.com",
+ "theweifamily.icu",
+ "thex.ro",
+ "thg.cc.cd",
+ "thichanthit.com",
+ "thichmmo.com",
+ "thiefness.com",
+ "thietbivanphong.asia",
+ "thirifara.com",
+ "thisisnotmyrealemail.com",
+ "thismail.net",
+ "thispy.top",
+ "thisurl.website",
+ "thnikka.com",
+ "thoas.ru",
+ "thodianamdu.com",
+ "thoitrangnam2024.shop",
+ "thomasblackerby.org.uk",
+ "thor.my.id",
+ "thraml.com",
+ "threads.io.vn",
+ "thrma.com",
+ "throam.com",
+ "thrott.com",
+ "throwam.com",
+ "throwaway.dynv6.net",
+ "throwaway.io",
+ "throwawayemailaddress.com",
+ "throwawaymail.com",
+ "throwawaymail.pp.ua",
+ "throya.com",
+ "thrubay.com",
+ "thshyo.org",
+ "thueotp.net",
+ "thulinh.net",
+ "thunderbolt.science",
+ "thunkinator.org",
+ "thuongxg.com",
+ "thurleighchurchestate.church",
+ "thxmate.com",
+ "tianblog.top",
+ "tianmi.me",
+ "tianstudy.space",
+ "tiapz.com",
+ "tic.ec",
+ "tidaksuka.cfd",
+ "tidewick.org",
+ "tidissajiiu.com",
+ "tidylifehub.com",
+ "tiendadezapasok.com",
+ "tiffincrane.com",
+ "tigrisstem.org",
+ "tijdelijke.email",
+ "tikanony.com",
+ "tikmail.org",
+ "tiksofi.uk",
+ "tiktakgrab.com",
+ "tiktakgrab.shop",
+ "tiktakgrabber.com",
+ "tiktokngon.com",
+ "tiktokus.us",
+ "tilalalghafmaintenance.com",
+ "tilien.com",
+ "tilumo.cfd",
+ "tilvex.click",
+ "timbourkescholarshipfund.org",
+ "timgiarevn.com",
+ "timkassouf.com",
+ "tinhoconline.com",
+ "tinoza.org",
+ "tinpho.com",
+ "tinyshortcuts.click",
+ "tinytimer.org",
+ "tinyurl24.com",
+ "tipsb.com",
+ "tirasya.pro",
+ "titangamezz.com",
+ "titenpa.com",
+ "tittbit.in",
+ "tiv.cc",
+ "tivendra.cfd",
+ "tivertonhospitalleagueoffriends.co.uk",
+ "tivo.camdvr.org",
+ "tizi.com",
+ "tjhn.beer",
+ "tkfb24h.com",
+ "tkitc.de",
+ "tlktok.us",
+ "tlpn.org",
+ "tmail.com",
+ "tmail.edu.rs",
+ "tmail.gg",
+ "tmail.io",
+ "tmail.link",
+ "tmail.lt",
+ "tmail.mx",
+ "tmail.ws",
+ "tmail1.org",
+ "tmail2.org",
+ "tmail3.com",
+ "tmail3.org",
+ "tmail4.org",
+ "tmail5.org",
+ "tmail6.com",
+ "tmail9.com",
+ "tmaile.net",
+ "tmailinator.com",
+ "tmailor.com",
+ "tmailor.net",
+ "tmails.net",
+ "tmmbt.net",
+ "tmpbox.net",
+ "tmpemails.com",
+ "tmpeml.com",
+ "tmpeml.info",
+ "tmpjr.me",
+ "tmpkit.com",
+ "tmpmail.net",
+ "tmpmail.org",
+ "tmpmailtor.com",
+ "tmpnator.live",
+ "tmpx.sa.com",
+ "tmxttvmail.com",
+ "tnbeta.com",
+ "toaik.com",
+ "toastunicorn.lol",
+ "tocuda.cloud",
+ "toddsbighug.com",
+ "tofeat.com",
+ "togetherbrook.sbs",
+ "tohru.org",
+ "toiea.com",
+ "tokem.co",
+ "token.ro",
+ "token0v0.com",
+ "tokenmail.de",
+ "tokfly.org",
+ "tokmail.fun",
+ "tokmail.net",
+ "tokobibit.co",
+ "tokyo.edu.pl",
+ "tokyo112.top",
+ "tokyoflarex.tokyo",
+ "tomhouse.shop",
+ "tommyfommy.site",
+ "tomvnet.uk",
+ "tonaeto.com",
+ "tongle.ccwu.cc",
+ "toni.io.vn",
+ "tonne.to",
+ "tonyfarm.indevs.in",
+ "tonymanso.com",
+ "too.li",
+ "toolbox.ovh",
+ "toolnator.plus",
+ "toolourles.tech",
+ "tools-capcut.com",
+ "toolsequipment.cyou",
+ "toomail.biz",
+ "toon.ml",
+ "top-shop-tovar.ru",
+ "top101.de",
+ "top1mail.ru",
+ "top1post.ru",
+ "topacservice.xyz",
+ "topclassacservice.xyz",
+ "topclassduct.xyz",
+ "topcleangulf.xyz",
+ "topcoolmaintenance.xyz",
+ "topdatamaster.com",
+ "topinrock.cf",
+ "topmail2.com",
+ "topmail2.net",
+ "topofertasdehoy.com",
+ "topranklist.de",
+ "topren.cyou",
+ "topren.top",
+ "toprumours.com",
+ "toptechdubai.xyz",
+ "topupgg.app",
+ "topvu.net",
+ "torkakak.site",
+ "tormail.org",
+ "torrent411.fr.nf",
+ "torvila.sbs",
+ "tospage.com",
+ "toss.pw",
+ "tosunkaya.com",
+ "totallynotfake.net",
+ "totalvista.com",
+ "totesmail.com",
+ "totoan.info",
+ "totococo.fr.nf",
+ "toupiao7003.top",
+ "tourcc.com",
+ "towerac.xyz",
+ "towerfacility.xyz",
+ "towerhubdubai.xyz",
+ "townsquaremaintenance.com",
+ "tozya.com",
+ "tp-qa-mail.com",
+ "tppp.one",
+ "tppp.online",
+ "tpwlb.com",
+ "tqoai.com",
+ "tqosi.com",
+ "trackden.com",
+ "tradermail.info",
+ "tradingview.my.id",
+ "tradr.space",
+ "trainthem2fish.co.uk",
+ "tramynguyen.net",
+ "tranceversal.com",
+ "trandigabapoe.shop",
+ "trangiabao.click",
+ "trangzim.uk",
+ "tranhtai.com",
+ "transaksikita.tech",
+ "transferpulsa.net",
+ "translateid.com",
+ "traodoinick.com",
+ "traogiai.com",
+ "trap-mail.de",
+ "trash-amil.com",
+ "trash-mail.at",
+ "trash-mail.cf",
+ "trash-mail.com",
+ "trash-mail.de",
+ "trash-mail.ga",
+ "trash-mail.gq",
+ "trash-mail.ml",
+ "trash-mail.tk",
+ "trash-mail.webredirect.org",
+ "trash-me.com",
+ "trash2009.com",
+ "trash2010.com",
+ "trash2011.com",
+ "trashcanmail.com",
+ "trashdevil.com",
+ "trashdevil.de",
+ "trashemail.de",
+ "trashemails.de",
+ "trashinbox.com",
+ "trashmail.at",
+ "trashmail.com",
+ "trashmail.de",
+ "trashmail.fr",
+ "trashmail.gq",
+ "trashmail.hu",
+ "trashmail.io",
+ "trashmail.me",
+ "trashmail.net",
+ "trashmail.org",
+ "trashmail.se",
+ "trashmail.ws",
+ "trashmailer.com",
+ "trashmailgenerator.de",
+ "trashmails.com",
+ "trashymail.com",
+ "trashymail.net",
+ "trasz.com",
+ "travelfriend.online",
+ "travelistaworld.com",
+ "travelovelinka.club",
+ "travionex.cfd",
+ "trayna.com",
+ "trbvm.com",
+ "trbvn.com",
+ "trbvo.com",
+ "treesforderby.org",
+ "trend-maker.ru",
+ "trendcricket.buzz",
+ "treso.me",
+ "treterter.shop",
+ "trevourus.com",
+ "trfu.to",
+ "trgfu.com",
+ "trgovinanaveliko.info",
+ "trialmail.de",
+ "trickmail.net",
+ "trieuhao.site",
+ "trillianpro.com",
+ "trinovia.cfd",
+ "triots.com",
+ "trixtrux1.ru",
+ "trojanvmesswrt.dpdns.org",
+ "trolebrotmail.com",
+ "trollproject.com",
+ "tropicalbass.info",
+ "tropovenamail.com",
+ "trovantis.cfd",
+ "troveniq.cfd",
+ "truecoolcare.xyz",
+ "trumingovn.online",
+ "trunghocphothong.io.vn",
+ "trungmetax.com",
+ "trungtamtoeic.com",
+ "trustcheck.cc",
+ "trustdomainvn.us",
+ "trustedadmin2.shop",
+ "trustedadmindns.site",
+ "trusteddns.online",
+ "trustguru.vip",
+ "trusticloud.us",
+ "trustmailold.us",
+ "truthfinderlogin.com",
+ "tryalert.com",
+ "tryninja.io",
+ "trythe.net",
+ "tryzoe.com",
+ "tscasalnt.top",
+ "tsderp.com",
+ "tsdpt.co.uk",
+ "tshdn.cfd",
+ "tshirtsavvy.com",
+ "tsmtp.org",
+ "tstrust.org.uk",
+ "ttirv.org",
+ "ttpo89japan.com",
+ "ttszuo.xyz",
+ "tualias.com",
+ "tuamaeaquelaursa.com",
+ "tubebox.us",
+ "tubeemail.com",
+ "tuethong.shop",
+ "tuku26012023.xyz",
+ "tulw6ae.ooguy.com",
+ "tumroc.net",
+ "tunthuta.com",
+ "tuofs.com",
+ "tupmail.com",
+ "turkeyth.com",
+ "turnimon.com",
+ "turoid.com",
+ "turual.com",
+ "turuma.com",
+ "tutuapp.bid",
+ "tuyenchau.click",
+ "tuyenchau.online",
+ "tuyenchau.site",
+ "tuyenhuy.click",
+ "tuyenhuy.online",
+ "tuyenhuy.site",
+ "tvchd.com",
+ "tverya.com",
+ "twck.hair",
+ "tweet.fr.nf",
+ "twinmail.de",
+ "twitch.work",
+ "twitchythoughtcreations.in",
+ "twitter.io.vn",
+ "twkly.ml",
+ "twocowmail.net",
+ "twoweirdtricks.com",
+ "twskyhope.top",
+ "twskyhope.xyz",
+ "twskyhopenb.shop",
+ "twzhhq.online",
+ "tx1ggv4.theworkpc.com",
+ "txcct.com",
+ "txen.de",
+ "txqx.skin",
+ "txtadvertise.com",
+ "tybz.hair",
+ "tyhe.ro",
+ "tyldd.com",
+ "tympe.net",
+ "tyoless.org.uk",
+ "tzrj.pro",
+ "u2v7nnn.mywire.org",
+ "uacro.com",
+ "uaecleanexpress.xyz",
+ "uaerepairquick.xyz",
+ "uaeserviceae.xyz",
+ "uber-mail.com",
+ "uberip.com",
+ "ubinert.com",
+ "ubismail.net",
+ "ubm.md",
+ "ucche.us",
+ "ucupdong.ml",
+ "uddinfoundation.org",
+ "udingclin.com",
+ "uemail99.com",
+ "ufacturing.com",
+ "ufokeuabmail.com",
+ "ug.wtf",
+ "uggsrock.com",
+ "uguuchantele.com",
+ "uhe2.com",
+ "uhhu.ru",
+ "uiemail.com",
+ "uiu.us",
+ "ujijima1129.gq",
+ "uk.to",
+ "ukev.org",
+ "uki.io.vn",
+ "ukm.ovh",
+ "ukshop538yt.uk",
+ "ukwv.skin",
+ "ulm-dsl.de",
+ "ulteriq.cfd",
+ "ultra.fyi",
+ "ultrada.ru",
+ "ultrafive.online",
+ "ultralabs24.store",
+ "ultralux.io.vn",
+ "ultrasix.online",
+ "ultraunitquick.xyz",
+ "ultravex.cfd",
+ "ultraz1.io.vn",
+ "ultrmigos.online",
+ "uma3.be",
+ "umail.net",
+ "umil.net",
+ "umiudi.cn",
+ "ummsuqeimmaintenance.com",
+ "umombiss.tk",
+ "undeadbank.com",
+ "undercliffholdings.net",
+ "underseagolf.com",
+ "undo.it",
+ "unfairship.com",
+ "unicodeworld.com",
+ "unids.com",
+ "unimain.tech",
+ "unimark.org",
+ "uniromax.com",
+ "unit7lahaina.com",
+ "unitedcharitiesosm.org.uk",
+ "universall.me",
+ "unlikeyth.com",
+ "unmail.ru",
+ "unratito.com",
+ "uofzcpg9ftykob.asia",
+ "uooos.com",
+ "uorak.com",
+ "upc.infos.st",
+ "upliftnow.com",
+ "uplipht.com",
+ "uploadnolimit.com",
+ "upozowac.info",
+ "upphim.net",
+ "upsnab.net",
+ "uqfb.hair",
+ "uqqlrdi.top",
+ "urbancoolpro.xyz",
+ "urbanmaintenance.xyz",
+ "urbanvalore.info",
+ "urfunktion.se",
+ "urgentmail.ovh",
+ "urhen.com",
+ "uroid.com",
+ "urvs.skin",
+ "urzy.skin",
+ "us-x.top",
+ "us.af",
+ "us.to",
+ "usa.cc",
+ "usa.edu.pl",
+ "usaishop.shop",
+ "usako.net",
+ "usbc.be",
+ "usdtbeta.com",
+ "used-product.fr",
+ "usedus.me",
+ "usefulbriefing.com",
+ "uselesswebsites.net",
+ "ushijima1129.cf",
+ "ushijima1129.ga",
+ "ushijima1129.gq",
+ "ushijima1129.ml",
+ "ushijima1129.tk",
+ "usm.ovh",
+ "uspsp.top",
+ "usrl71x.camdvr.org",
+ "ustorp.com",
+ "usus.accesscam.org",
+ "usus.camdvr.org",
+ "usus.casacam.net",
+ "usus.ddnsfree.com",
+ "ususus.dynv6.net",
+ "utiket.us",
+ "utkarsh.tech",
+ "uu.gl",
+ "uu2.ovh",
+ "uue.edu.pl",
+ "uuf.me",
+ "uuii.in",
+ "uvaul.us.ci",
+ "uvicone.in",
+ "uwork4.us",
+ "uyhip.com",
+ "uyqs.skin",
+ "uz8.net",
+ "v-mail.xyz",
+ "v1jhjrc.giize.com",
+ "v2v.click",
+ "v2v.email",
+ "v2v.world",
+ "v6.army",
+ "v6.navy",
+ "v6.rocks",
+ "v6omlz6.giize.com",
+ "vaasfc4.tk",
+ "vaati.org",
+ "vae.indevs.in",
+ "vafyxh.com",
+ "valanides.com",
+ "valemail.net",
+ "valhalladev.com",
+ "valueacrepair.xyz",
+ "valueairconrepair.xyz",
+ "valueamcdubai.xyz",
+ "valuehandyman.xyz",
+ "valuehvac.xyz",
+ "valueineveryone.co.uk",
+ "valuerepairs.xyz",
+ "vankin.de",
+ "vasteron.com",
+ "vatos.gg",
+ "vaupk.org",
+ "vbgvd.com",
+ "vbv.cards",
+ "vcois.com",
+ "vctel.com",
+ "vda.ro",
+ "vddaz.com",
+ "vdig.com",
+ "vdzc.skin",
+ "veanlo.com",
+ "veb37.com",
+ "vectorbrasil.app",
+ "velatrix.cfd",
+ "veletis.bond",
+ "velloxist.site",
+ "velmoor.org",
+ "velocitronix.online",
+ "veltexline.com",
+ "velvetpetalia.beauty",
+ "velvetstitchery.cfd",
+ "vemomail.win",
+ "venariq.cfd",
+ "venompen.com",
+ "ventrovia.cfd",
+ "veo.kr",
+ "veo325k.io.vn",
+ "veo325kcredit.io.vn",
+ "veo33.io.vn",
+ "veo3ultra.sbs",
+ "veomatrix25k.io.vn",
+ "veoverse25k.io.vn",
+ "ver0.cf",
+ "ver0.ga",
+ "ver0.gq",
+ "ver0.ml",
+ "ver0.tk",
+ "vercelli.cf",
+ "vercelli.ga",
+ "vercelli.gq",
+ "vercelli.ml",
+ "vercelspace.shop",
+ "verdejo.com",
+ "verel.online",
+ "verifycenter.net",
+ "verityemail.site",
+ "vermutlich.net",
+ "vernonbourne.uk",
+ "veronicamira.info",
+ "vertexinbox.com",
+ "vertexium.net",
+ "veruvercomail.com",
+ "verybigdick.cyou",
+ "veryday.ch",
+ "veryday.eu",
+ "veryday.info",
+ "veryrealemail.com",
+ "ves.ink",
+ "vesa.pw",
+ "vesdev01.shop",
+ "vetra.cyou",
+ "vevs.de",
+ "vexalor.cfd",
+ "vexaluno.xyz",
+ "vexi.my",
+ "vextora.click",
+ "vfdhks.eu.cc",
+ "vfre4w3.dynv6.net",
+ "vfwladiesauxin.org",
+ "vgfautd.mywire.org",
+ "vggh.beer",
+ "vgyi.hair",
+ "vi1ilpa.mywire.org",
+ "via.tokyo.jp",
+ "viavuive.net",
+ "vibertees.com",
+ "vibzi.net",
+ "vickaentb.tk",
+ "victime.ninja",
+ "victoriaalison.com",
+ "victoriantwins.com",
+ "victoryheightsmaintenance.com",
+ "vidchart.com",
+ "videoigdownload.com",
+ "videotoptop.com",
+ "viditag.com",
+ "vidwobox.com",
+ "vidxlr.me",
+ "viemarter.online",
+ "viettravel.io.vn",
+ "vietxf.com",
+ "viewbuster.ru",
+ "viewcastmedia.com",
+ "viewcastmedia.net",
+ "viewcastmedia.org",
+ "viewmuse.com",
+ "vigilantkeep.net",
+ "vigoneo.com",
+ "vikingsonly.com",
+ "villaamcdubai.xyz",
+ "villaamcrepair.xyz",
+ "villaclimate.xyz",
+ "villanovaservices.com",
+ "villasdesk.xyz",
+ "villaspro.xyz",
+ "villaxoa.site",
+ "vinaemail.com",
+ "vinakoop.com",
+ "vinakop.com",
+ "vinatissue.org",
+ "vinernet.com",
+ "vinhdo.shop",
+ "vintomaper.com",
+ "viole.cfd",
+ "violet0923.online",
+ "vipepe.com",
+ "vipmail.name",
+ "vipmail.pw",
+ "vipmba.cc",
+ "vips.pics",
+ "vipxm.net",
+ "viralplays.com",
+ "virelio.cfd",
+ "virgilian.com",
+ "virginsrus.xyz",
+ "virtual-hipster.com",
+ "virtualemail.info",
+ "virtualminerals.com",
+ "visal007.tk",
+ "visal168.cf",
+ "visal168.ga",
+ "visal168.gq",
+ "visal168.ml",
+ "visal168.tk",
+ "visignal.com",
+ "visualdep.run.place",
+ "visualnest.work",
+ "visuals.linkpc.net",
+ "vitahicks.com",
+ "vitaspherelife.com",
+ "vitrineproducoes.com",
+ "viv2.com",
+ "vividtipzone.com",
+ "vixletdev.com",
+ "vixtricks.com",
+ "vjbl.skin",
+ "vjoid.ru",
+ "vjoid.store",
+ "vjuum.com",
+ "vkbb.ru",
+ "vkbb.store",
+ "vkbt.ru",
+ "vkbt.store",
+ "vkcbt.ru",
+ "vkcbt.store",
+ "vkcode.ru",
+ "vkfu.ru",
+ "vkfu.store",
+ "vkjashdf.eu.cc",
+ "vkpr.store",
+ "vkr1.com",
+ "vkrr.ru",
+ "vkrr.store",
+ "vladhub.buzz",
+ "vlemi.com",
+ "vlrregulatory.com",
+ "vmailing.info",
+ "vmani.com",
+ "vmbx.skin",
+ "vmm.indevs.in",
+ "vmpanda.com",
+ "vn-one.com",
+ "vncctv.org",
+ "vnedu.me",
+ "vnfjsh.eu.cc",
+ "vnqi.skin",
+ "vntechhub.online",
+ "vnvmail.com",
+ "vnxdxpf.loseyourip.com",
+ "vobau.net",
+ "voerpemww.click",
+ "voicesforchangeinc.org",
+ "voidbay.com",
+ "voidmail.one",
+ "voidpop.win",
+ "volaj.com",
+ "volku.org",
+ "voltaer.com",
+ "voltr.click",
+ "voltravia.cfd",
+ "vomoto.com",
+ "vorga.org",
+ "vortalis.cfd",
+ "voryxen.com",
+ "vothanhtuan.shop",
+ "votiputox.org",
+ "voxelcore.com",
+ "voxinh.net",
+ "vpbz.skin",
+ "vpii.beer",
+ "vpkv.skin",
+ "vpn.st",
+ "vpn64.de",
+ "vpnseat.com",
+ "vps30.com",
+ "vps79.com",
+ "vps911.net",
+ "vqdz.hair",
+ "vqdz.skin",
+ "vradportal.com",
+ "vremonte24-store.ru",
+ "vrjxk.com",
+ "vrmtr.com",
+ "vsdsd23gsdf.shop",
+ "vsimcard.com",
+ "vsmailpro.com",
+ "vssms.com",
+ "vtmpj.net",
+ "vtxmail.us",
+ "vuatrochoi.nl",
+ "vubby.com",
+ "vuiy.pw",
+ "vuket.org",
+ "vulca.sbs",
+ "vulcanpioneerjers.org",
+ "vupn.me",
+ "vusra.com",
+ "vutrugay.org",
+ "vuv.indevs.in",
+ "vvatxiy.com",
+ "vwagamlk.top",
+ "vwhins.com",
+ "vxsolar.com",
+ "vystella.cfd",
+ "vztc.com",
+ "w-asertun.ru",
+ "w154ed2.giize.com",
+ "w3internet.co.uk",
+ "wacold.com",
+ "waguriagent.codes",
+ "waisfoundation.com",
+ "waitbeqa.com",
+ "waivey.com",
+ "wakingupesther.com",
+ "walala.org",
+ "walkmail.net",
+ "walkmail.ru",
+ "wallm.com",
+ "wanai.biz.id",
+ "wanaofamouscar.com",
+ "wandouyu.run",
+ "wanko.be",
+ "wansnode-dice.site",
+ "wanting2work.org.uk",
+ "wanva.shop",
+ "wargravepreschool.uk",
+ "warlus.asso.st",
+ "warmwindow.homes",
+ "waroengku.cc",
+ "waroengmail.com",
+ "waroengpremium.com",
+ "warrity25k.io.vn",
+ "warsanmaintenance.com",
+ "warunkto.com",
+ "watch-harry-potter.com",
+ "watchever.biz",
+ "watchfull.net",
+ "watchironman3onlinefreefullmovie.com",
+ "waterisgone.com",
+ "waterpalace.pro",
+ "watersoftenersystemcost.com",
+ "watrf.com",
+ "watsawang.com",
+ "waventra.cfd",
+ "waw.indevs.in",
+ "wazabi.club",
+ "wbdev.tech",
+ "wbml.net",
+ "wbslxy.cc.cd",
+ "wdhu.skin",
+ "wdrd.beer",
+ "wearkeymail.site",
+ "wearvault.biz.id",
+ "weatherbx.xyz",
+ "weaxymt.loseyourip.com",
+ "web-contact.info",
+ "web-ideal.fr",
+ "web-inc.net",
+ "web-library.net",
+ "web-mail.pp.ua",
+ "web2mailco.com",
+ "webcamness.com",
+ "webcenterfairbanks.dedyn.io",
+ "webclub.infos.st",
+ "webcontact-france.eu",
+ "webemail.me",
+ "webhook.site",
+ "webinarmoa.com",
+ "webm4il.info",
+ "webmail24.top",
+ "webmailus.com",
+ "webofip.com",
+ "webscash.com",
+ "webstore.fr.nf",
+ "webtrip.ch",
+ "webui.cc.cd",
+ "webuser.in",
+ "wecanmakehit.win",
+ "wecp.ru",
+ "wecp.store",
+ "wee.my",
+ "weekfly.com",
+ "weekzs.icu",
+ "weeline.fun",
+ "wef.gr",
+ "weg-werf-email.de",
+ "wegoo.dpdns.org",
+ "wegwerf-email-addressen.de",
+ "wegwerf-email-adressen.de",
+ "wegwerf-email.at",
+ "wegwerf-email.de",
+ "wegwerf-email.net",
+ "wegwerf-emails.de",
+ "wegwerfadresse.de",
+ "wegwerfemail.com",
+ "wegwerfemail.de",
+ "wegwerfemail.info",
+ "wegwerfemail.net",
+ "wegwerfemail.org",
+ "wegwerfemailadresse.com",
+ "wegwerfmail.de",
+ "wegwerfmail.info",
+ "wegwerfmail.net",
+ "wegwerfmail.org",
+ "wegwerpmailadres.nl",
+ "wegwrfmail.de",
+ "wegwrfmail.net",
+ "wegwrfmail.org",
+ "weiruipingji.com",
+ "weizixu.com",
+ "wejedvv.giize.com",
+ "wekawa.com",
+ "welikecookies.com",
+ "wellsfargocomcardholders.com",
+ "wemel.top",
+ "wenkuu.com",
+ "wentcity.com",
+ "wenyuanshe.com",
+ "wep.email",
+ "were-w.top",
+ "wetrainbayarea.com",
+ "wetrainbayarea.org",
+ "weyword.cfd",
+ "wfgdfhj.tk",
+ "wfpe.autos",
+ "wg0.com",
+ "wh4f.org",
+ "whaaaaaaaaaat.com",
+ "whatagarbage.com",
+ "whatemi.de",
+ "whatever.za.net",
+ "whatiaas.com",
+ "whatifanalytics.com",
+ "whatpaas.com",
+ "whatsaas.com",
+ "whf222.top",
+ "whiffles.org",
+ "whirlygirlink.org",
+ "whispermail.org",
+ "whiteaircond.xyz",
+ "whiteairconfix.xyz",
+ "whiteamcservice.xyz",
+ "whitechiller.xyz",
+ "whitehousecalculator.com",
+ "whitehvacrepair.xyz",
+ "whitemistral.pro",
+ "whob31o.camdvr.org",
+ "whoopza.org",
+ "whoopza.store",
+ "whopy.com",
+ "whyspam.me",
+ "wibblesmith.com",
+ "wicfolhumanservices.org",
+ "wickmail.net",
+ "widaryanto.info",
+ "widget.gg",
+ "wiemei.com",
+ "wierie.tk",
+ "wifimaple.com",
+ "wifioak.com",
+ "wikfee.com",
+ "wikidocuslava.ru",
+ "wildan.tech",
+ "wildlinenlab.cfd",
+ "wilemail.com",
+ "willhackforfood.biz",
+ "willselfdestruct.com",
+ "wimsg.com",
+ "windsurfcom.shop",
+ "winemaven.info",
+ "wingsai.online",
+ "winocs.com",
+ "wins.com.br",
+ "wintersgf.store",
+ "wintersmail.site",
+ "wir-sind.com",
+ "wireconnected.com",
+ "wishy.fr",
+ "wivstore.com",
+ "wjah.hair",
+ "wjah.skin",
+ "wlist.ro",
+ "wlsom.com",
+ "wmail.cf",
+ "wmail.club",
+ "wmct.info",
+ "wmksuhd.cn",
+ "wnbaldwy.com",
+ "wohbc.co.uk",
+ "wokcy.com",
+ "wolfmail.ml",
+ "wolfsmail.tk",
+ "wollan.info",
+ "womp-wo.mp",
+ "woofgl.email",
+ "woofidog.fr.nf",
+ "woopros.com",
+ "workhorsefilms.com",
+ "workingtall.com",
+ "worldcoins.top",
+ "worldcricket.org.uk",
+ "worldlylife.store",
+ "worldspace.link",
+ "woshiemoman.cloud-ip.cc",
+ "wovoemj1119.icu",
+ "wpdork.com",
+ "wpg.im",
+ "wpnf.skin",
+ "wralawfirm.com",
+ "wrgee.com",
+ "writeme.us",
+ "wronghead.com",
+ "wrqj.top",
+ "wrxx.beer",
+ "ws.gy",
+ "wshu.net",
+ "wsvpsft.xyz",
+ "wsxnxce.online",
+ "wsym.de",
+ "wsypc.com",
+ "wtjinkintrust.co.uk",
+ "wtsl.hair",
+ "wudet.men",
+ "wudiai.shop",
+ "wuespdj.xyz",
+ "wupics.com",
+ "wusongdahu.cc.cd",
+ "wuuvo.com",
+ "wuzak.com",
+ "wuzup.net",
+ "wuzupmail.net",
+ "wvdg.skin",
+ "wvyt851.ddnsfree.com",
+ "wwc8.com",
+ "wwdskboiv.top",
+ "wwefd.top",
+ "wwej.cc.cd",
+ "wwjmp.com",
+ "wwtykkm.bond",
+ "wwvec.uno",
+ "wwvk.ru",
+ "wwvk.store",
+ "wwwnew.eu",
+ "wxcv.fr.nf",
+ "wxf07.shop",
+ "wxhp.hair",
+ "wxnw.net",
+ "wxxu.hair",
+ "wyoxafp.com",
+ "wywnxa.com",
+ "wzjpj.com",
+ "wzjyyds.com",
+ "wzofit.com",
+ "x-clone.asia",
+ "x-lab.net",
+ "x0bdu6n.mywire.org",
+ "x0q.net",
+ "x1ix.com",
+ "x24.com",
+ "x80.dpdns.org",
+ "x80la.shop",
+ "x8nxh3g.theworkpc.com",
+ "x9ad1.my",
+ "xagloo.co",
+ "xagloo.com",
+ "xandoria.cfd",
+ "xapimail.top",
+ "xazymarcie.space",
+ "xbaby69.top",
+ "xbrok.com",
+ "xcf.pet",
+ "xclone.asia",
+ "xcode.ro",
+ "xcodes.net",
+ "xcompress.com",
+ "xcoxc.com",
+ "xcp3iiv.ooguy.com",
+ "xcpy.com",
+ "xcsllajs.eu.cc",
+ "xcucngon.site",
+ "xcvuaylik.top",
+ "xcvv.xyz",
+ "xczv.hair",
+ "xdfrms.fun",
+ "xdnss.cc",
+ "xdpxdp.online",
+ "xehop.org",
+ "xelio.sbs",
+ "xelu.space",
+ "xemaps.com",
+ "xemne.com",
+ "xen-bypass.com",
+ "xenloria.cfd",
+ "xeno.gay",
+ "xenta.cfd",
+ "xents.com",
+ "xeovo.ru",
+ "xepa.ru",
+ "xfavaj.com",
+ "xfft.beer",
+ "xgcpking.space",
+ "xgmail.bond",
+ "xgng.skin",
+ "xhke.hair",
+ "xhke.skin",
+ "xhotmail.cyou",
+ "xi2.cc",
+ "xiajspof.eu.cc",
+ "xiangshan.eu.cc",
+ "xiaoche.ggff.net",
+ "xiaoguaishou.icu",
+ "xiaohuangyu.ccwu.cc",
+ "xiaoting.cc",
+ "xib.icu",
+ "xidealx.com",
+ "xiiqtg.com",
+ "xiiqtg.vip",
+ "xikemail.com",
+ "ximenor.site",
+ "xingmeisofa.com",
+ "xirentha.cfd",
+ "xitler.top",
+ "xiwinnie.icu",
+ "xjau.homes",
+ "xjoi.com",
+ "xkgm.hair",
+ "xktu.hair",
+ "xkx.me",
+ "xkxkud.com",
+ "xl.cx",
+ "xlcsh.icu",
+ "xmailer.be",
+ "xmailtm.com",
+ "xmaily.com",
+ "xmisom.help",
+ "xmlab.icu",
+ "xmyvgkvw.top",
+ "xn--5nx.cc",
+ "xn--9kq967o.com",
+ "xn--ai-ry2ck37oorv.com",
+ "xn--d-bga.net",
+ "xn--di5au2k.shop",
+ "xn--ihq4pool8g32cwxiiqcovaa9159jhvah03g.top",
+ "xn--jxsa73o.eu.org",
+ "xn--o38h.abrdns.com",
+ "xn--rhqv96g.tv",
+ "xn--yaho-sqa.com",
+ "xn-xp8h.bond",
+ "xo00wda.loseyourip.com",
+ "xojxe.com",
+ "xolic.me",
+ "xopmail.fun",
+ "xost.us",
+ "xoxo3.website",
+ "xoxox.cc",
+ "xperiae5.com",
+ "xpremium.pro",
+ "xpromail.xyz",
+ "xqmm.hair",
+ "xqqgegsd.eu.cc",
+ "xrap.de",
+ "xshop.bond",
+ "xsioap.eu.cc",
+ "xsnipersquad.com",
+ "xsnipersquad.site",
+ "xsnipersquad.space",
+ "xstyled.net",
+ "xswl.xyz",
+ "xt81.help",
+ "xtzg.beer",
+ "xunika.uk",
+ "xvx.us",
+ "xww.ro",
+ "xxhamsterxx.ga",
+ "xxi2.com",
+ "xxl.st",
+ "xxlocanto.us",
+ "xxmailedu.dpdns.org",
+ "xxolocanto.us",
+ "xxqx3802.com",
+ "xxvd.net",
+ "xxvk.ru",
+ "xxvk.store",
+ "xxxhi.cc",
+ "xxymail.me",
+ "xy9ce.tk",
+ "xylanta.cfd",
+ "xylar.ru",
+ "xylar.store",
+ "xyxy.app",
+ "xyzfree.net",
+ "xzcoot.com",
+ "xzsok.com",
+ "y0fwzc2.mywire.org",
+ "y0p8gli.loseyourip.com",
+ "y1dk5vk.giize.com",
+ "y4plv.cyou",
+ "y6jxjln.kozow.com",
+ "yabai-oppai.tk",
+ "yabes.ovh",
+ "yahmail.top",
+ "yahooproduct.net",
+ "yahoox.cyou",
+ "yaloo.fr.nf",
+ "yamail.win",
+ "yanet.me",
+ "yangzazhong.top",
+ "yangzhai.xyz",
+ "yangzhong-sfd.cc",
+ "yanhd-jkd.top",
+ "yannmail.win",
+ "yanxuan.eu.cc",
+ "yapped.net",
+ "yaqp.com",
+ "yarnpedia.ga",
+ "yasuo.sbs",
+ "yb9ye8c.kozow.com",
+ "ybidbccmv.top",
+ "yc08.cn",
+ "ycare.de",
+ "ycglobalmovement.com",
+ "ycn.ro",
+ "ydhfks.shop",
+ "ydns.eu",
+ "ydscontingencia.com",
+ "ye.vc",
+ "yearlyamctower.xyz",
+ "yecp.ru",
+ "yecp.store",
+ "yedi.org",
+ "yeezus.ru",
+ "yellofdf2.click",
+ "yenphandinhe.com",
+ "yep.it",
+ "yermail.net",
+ "yeummo.io.vn",
+ "yfhsdb.eu.cc",
+ "yfnc.skin",
+ "ygbelira.me",
+ "ygebjd8-ktyd.top",
+ "ygpa.autos",
+ "yhg.biz",
+ "yieldwillow.bond",
+ "yihacihuy.top",
+ "yilue.eu.org",
+ "ying-ge.com",
+ "yingzi321.shop",
+ "yiuogmq.loseyourip.com",
+ "yjlwcbrf.top",
+ "yl66.cfd",
+ "ymonthl.com",
+ "ynagjie-66.cc",
+ "ynmrealty.com",
+ "ynnord.top",
+ "yodwg.sbs",
+ "yodx.ro",
+ "yogamaven.com",
+ "yoggm.com",
+ "yogirt.com",
+ "yomail.edu.pl",
+ "yomail.info",
+ "yomand.store",
+ "yondrix.cfd",
+ "yongdan-hstn.best",
+ "yoo.ro",
+ "yopm.zone.id",
+ "yopmail.com",
+ "yopmail.fr",
+ "yopmail.gq",
+ "yopmail.kro.kr",
+ "yopmail.loseyourip.com",
+ "yopmail.net",
+ "yopmail.ooguy.com",
+ "yopmail.pp.ua",
+ "yopmail.theworkpc.com",
+ "yopmail.webredirect.org",
+ "yoptruc.fr.nf",
+ "yordanmail.cf",
+ "yorushika.one",
+ "yotmail.fr.nf",
+ "you-spam.com",
+ "yougotgoated.com",
+ "youmail.ga",
+ "youmailr.com",
+ "youneedmore.info",
+ "youpymail.com",
+ "your5.ru",
+ "your5.store",
+ "yourdomain.com",
+ "youremail.cf",
+ "yourewronghereswhy.com",
+ "yourlms.biz",
+ "yours.tools",
+ "yourspamgoesto.space",
+ "yourtube.ml",
+ "yourwiseguide.com",
+ "youtube-com-watch-jtpdc8khnpi.accesscam.org",
+ "youtube-com-watch-jtpdc8khnpi.bond",
+ "youtube-com-watch-jtpdc8khnpi.cyou",
+ "youtube-com-watch-jtpdc8khnpi.dedyn.io",
+ "youtube-com-watch-jtpdc8khnpi.freeddns.org",
+ "youtube-com-watch-jtpdc8khnpi.gleeze.com",
+ "youtube-com-watch-jtpdc8khnpi.loseyourip.com",
+ "youtube-com-watch-jtpdc8khnpi.mywire.org",
+ "youtube-com-watch-jtpdc8khnpi.ooguy.com",
+ "youtube-com-watch-jtpdc8khnpi.theworkpc.com",
+ "youtube-com-watch-jtpdc8khnpi.webredirect.org",
+ "youxiang.dev",
+ "ypyb.hair",
+ "yroid.com",
+ "yrppwsekv.top",
+ "ysfuddr.top",
+ "ysgdjd.fun",
+ "yshdnhh.click",
+ "ysngdtw55n.top",
+ "yspend.com",
+ "ysuhd.art",
+ "yszmy.cn",
+ "ytkj.us.ci",
+ "ytnhy.com",
+ "ytoof.app",
+ "ytpayy.com",
+ "yugasandrika.com",
+ "yui.it",
+ "yujiehanjiao.cc",
+ "yuki.ren",
+ "yummymeow.eu",
+ "yummymeow.fr",
+ "yummymeow.org",
+ "yummymeow.store",
+ "yun.pics",
+ "yunxiangpnv.lol",
+ "yuoia.com",
+ "yurevia.cfd",
+ "yurovovault.shop",
+ "yuurok.com",
+ "ywiueryweqrwerqsdf.us.ci",
+ "ywzmb.top",
+ "yxdad.ru",
+ "yxdad.store",
+ "yxkm.skin",
+ "yxx1.ccwu.cc",
+ "yxxala.ccwu.cc",
+ "yxxawa.ccwu.cc",
+ "yxxawa.top",
+ "yxzx.net",
+ "yydsplus.top",
+ "yykzv53.theworkpc.com",
+ "yyolf.net",
+ "yyqzc8v.giize.com",
+ "yzcalo.com",
+ "yzm.de",
+ "z-o-e-v-a.ru",
+ "z0d.eu",
+ "z1p.biz",
+ "z86.ru",
+ "z8ebbq2.camdvr.org",
+ "zai.asia",
+ "zain.com.co",
+ "zain.site",
+ "zainmax.net",
+ "zaktouni.fr",
+ "zalesie.net.pl",
+ "zantrax.com",
+ "zapmail.one",
+ "zarabotokdoma11.ru",
+ "zarkbin.store",
+ "zasod.com",
+ "zauxpbp.cn",
+ "zaym-zaym.ru",
+ "zcdpbx.top",
+ "zcjzy.cc.cd",
+ "zcovz.ru",
+ "zcovz.store",
+ "zcrcd.com",
+ "zdenka.net",
+ "zdge.hair",
+ "ze.tc",
+ "zebins.com",
+ "zebins.eu",
+ "zehnminuten.de",
+ "zehnminutenmail.de",
+ "zeldaa.ccwu.cc",
+ "zellatrix.cfd",
+ "zellio.yachts",
+ "zemzar.net",
+ "zenbada.com",
+ "zenbyul.com",
+ "zenithac.xyz",
+ "zenithaccare.xyz",
+ "zenithaircool.xyz",
+ "zenrepair365.xyz",
+ "zensurpirat.de",
+ "zenthara.cfd",
+ "zenthira.cfd",
+ "zenthranet.com",
+ "zenworksfast.xyz",
+ "zephyracpro.xyz",
+ "zephyrairconpro.xyz",
+ "zephyramcpro.xyz",
+ "zephyrhvac.xyz",
+ "zephyros.cfd",
+ "zepp.dk",
+ "zeroai.sbs",
+ "zeronerbacomail.com",
+ "zeropolly.com",
+ "zetmail.com",
+ "zevionyx.com",
+ "zexic.cyou",
+ "zfymail.com",
+ "zgg0.help",
+ "zhaji.eu.org",
+ "zhanhuokma2.wang",
+ "zhaoqian.ninja",
+ "zhaoyuanedu.cn",
+ "zhcne.com",
+ "zhengwz.dpdns.org",
+ "zhewei88.com",
+ "zhizhiwei.fun",
+ "zhjjq.tech",
+ "zhorachu.com",
+ "zhousj.eu.org",
+ "ziell.my.id",
+ "zigzagpulse.buzz",
+ "zik.dj",
+ "zikzak.site",
+ "zimf.hair",
+ "zipcad.com",
+ "zipcatfish.com",
+ "ziping.me",
+ "zipmail.one",
+ "zipo1.gq",
+ "zippymail.info",
+ "zipsendtest.com",
+ "zira.my",
+ "ziragold.com",
+ "zivox.sbs",
+ "ziyoupi.asia",
+ "zizo7.com",
+ "zkfdwz.shop",
+ "zkfr.hair",
+ "zlqkuzx.ddnsfree.com",
+ "zlzu.skin",
+ "zmail.cam",
+ "zmnai.top",
+ "zmypxprb.sbs",
+ "znext.bond",
+ "zoaxe.com",
+ "zoemail.com",
+ "zoemail.net",
+ "zoemail.org",
+ "zoetropes.org",
+ "zohobillings.com",
+ "zolamin.com",
+ "zolmiu.cfd",
+ "zombie-hive.com",
+ "zomg.info",
+ "zoomintens.com",
+ "zorg.fr.nf",
+ "zouz.fr.nf",
+ "zovion.com",
+ "zozozo123.com",
+ "zpva4pd.mywire.org",
+ "zqjq.hair",
+ "zqwn.skin",
+ "zrpu.hair",
+ "zrufe.com",
+ "zsero.com",
+ "zsmj.cc.cd",
+ "zsts.top",
+ "zudpck.com",
+ "zuldev.live",
+ "zumnime.me",
+ "zumpul.com",
+ "zutomayo.best",
+ "zv68.com",
+ "zvvzuv.com",
+ "zw5pkjm.camdvr.org",
+ "zx81.ovh",
+ "zxb8bds.loseyourip.com",
+ "zxcv.com",
+ "zxcvbn123.cc.cd",
+ "zxcvbnm.com",
+ "zxhyzlwcp.top",
+ "zxqzin.cn",
+ "zxrey.club",
+ "zymuying.com",
+ "zyns.com",
+ "zypherin.cfd",
+ "zyranova.cfd",
+ "zyvenix.cfd",
+ "zzaidah.cloud",
+ "zzeu.us",
+ "zzi.us",
+ "zzrgg.com",
+ "zzz.com",
+ "zzzzzzzzzzzzzz-1090.dynv6.net",
+ "zzzzzzzzzzzzzz-2092.dynv6.net",
+ "zzzzzzzzzzzzzz-277.dynv6.net",
+ "zzzzzzzzzzzzzz-3294.dynv6.net",
+ "zzzzzzzzzzzzzz-3330.dynv6.net",
+ "zzzzzzzzzzzzzz-3535.dynv6.net",
+ "zzzzzzzzzzzzzz-3928.dynv6.net",
+ "zzzzzzzzzzzzzz-3940.dynv6.net",
+ "zzzzzzzzzzzzzz-4283.dynv6.net",
+ "zzzzzzzzzzzzzz-4904.dynv6.net",
+ "zzzzzzzzzzzzzz-5055.dynv6.net",
+ "zzzzzzzzzzzzzz-5246.dynv6.net",
+ "zzzzzzzzzzzzzz-546.dynv6.net",
+ "zzzzzzzzzzzzzz-739.dynv6.net",
+ "zzzzzzzzzzzzzz-7619.dynv6.net",
+ "zzzzzzzzzzzzzz-7911.dynv6.net",
+ "zzzzzzzzzzzzzz-8050.dynv6.net",
+ "zzzzzzzzzzzzzz-8079.dynv6.net",
+ "zzzzzzzzzzzzzz-8874.dynv6.net",
+ "zzzzzzzzzzzzzz-969.dynv6.net",
+ "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.loseyourip.com",
+ "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.ooguy.com"
+]
diff --git a/apps/sim/lib/messaging/email/disposable.test.ts b/apps/sim/lib/messaging/email/disposable.test.ts
new file mode 100644
index 00000000000..bcf6bf867cc
--- /dev/null
+++ b/apps/sim/lib/messaging/email/disposable.test.ts
@@ -0,0 +1,49 @@
+import { parse } from 'tldts'
+import { describe, expect, it } from 'vitest'
+import { isDisposableEmail } from '@/lib/messaging/email/disposable'
+import disposableDomains from '@/lib/messaging/email/disposable-email-domains.json'
+
+describe('isDisposableEmail', () => {
+ it.each([
+ ['a listed provider', 'jane@mailinator.com'],
+ ['a subdomain of a listed provider', 'jane@inbox.mailinator.com'],
+ ['a listed provider in mixed case', 'Jane@MailInator.COM'],
+ ['a fully qualified domain with a trailing dot', 'jane@mailinator.com.'],
+ ['a quoted local part that contains @', '"jane@work.com"@mailinator.com'],
+ ])('refuses %s', async (_case, email) => {
+ expect(await isDisposableEmail(email)).toBe(true)
+ })
+
+ it.each([
+ ['a company under a public suffix', 'jane@company.msk.ru'],
+ ['a company under a private-section suffix', 'jane@acme.it.com'],
+ ['a dynamic-DNS host under a shared suffix', 'jane@office.dyndns.org'],
+ ['a hosting company the aggressive list carried', 'jane@railway.com'],
+ ['a regional ISP the curated list carried', 'jane@21cn.com'],
+ ['a forwarding relay alias', 'jane@maskmy.id'],
+ ['an Apple private relay alias', 'x1y2@privaterelay.appleid.com'],
+ ['a mainstream provider', 'jane@gmail.com'],
+ ])('allows %s', async (_case, email) => {
+ expect(await isDisposableEmail(email)).toBe(false)
+ })
+
+ it.each([null, undefined, '', 'jane', 'jane@', 'jane@.'])(
+ 'allows the unparsable %j',
+ async (email) => {
+ expect(await isDisposableEmail(email)).toBe(false)
+ }
+ )
+})
+
+describe('disposable-email-domains.json', () => {
+ /**
+ * The matcher also checks every parent of the email's domain, so a public suffix in the list
+ * (`msk.ru`, `dyndns.org`) would refuse every organization registered under it.
+ */
+ it('carries no public suffix', () => {
+ const suffixes = disposableDomains.filter(
+ (entry) => parse(entry, { allowPrivateDomains: true }).domain === null
+ )
+ expect(suffixes).toEqual([])
+ })
+})
diff --git a/apps/sim/lib/messaging/email/disposable.ts b/apps/sim/lib/messaging/email/disposable.ts
new file mode 100644
index 00000000000..dd9797e1a86
--- /dev/null
+++ b/apps/sim/lib/messaging/email/disposable.ts
@@ -0,0 +1,68 @@
+import { createLogger } from '@sim/logger'
+
+const logger = createLogger('DisposableEmail')
+
+let disposableDomains: Promise> | undefined
+
+/**
+ * Loaded on first use rather than at import, so every server module that reaches the auth config
+ * does not parse the list at startup to serve a check that only account creation runs. A failed
+ * load is not cached, so the next signup retries it.
+ */
+function loadDisposableDomains(): Promise> {
+ disposableDomains ??= import('@/lib/messaging/email/disposable-email-domains.json').then(
+ ({ default: domains }) => new Set(domains),
+ (error: unknown) => {
+ disposableDomains = undefined
+ throw error
+ }
+ )
+ return disposableDomains
+}
+
+/**
+ * The lowercased domain of an address: the part after its last `@` (a quoted local part may
+ * contain one), without the trailing dot of a fully qualified name. Empty when there is none.
+ */
+export function emailDomain(email: string): string {
+ return email
+ .slice(email.lastIndexOf('@') + 1)
+ .trim()
+ .toLowerCase()
+ .replace(/\.$/, '')
+}
+
+/**
+ * True when the email's domain, or any parent of it, is a known disposable or temporary inbox
+ * provider (Mailinator, temp-mail services, …). The server-side gate on self-service account
+ * creation. `quickValidateEmail` keeps its own short synchronous list because it also runs in
+ * browser forms, where this list cannot ship.
+ *
+ * Fails open: if the list cannot load, the check logs and allows the signup, because refusing
+ * every legitimate signup is worse than admitting a few throwaway ones.
+ *
+ * `disposable-email-domains.json` is generated by `scripts/generate-disposable-email-domains.ts`
+ * from the curated CC0 blocklist at
+ * https://github.com/disposable-email-domains/disposable-email-domains (the list PyPI uses), minus
+ * every public suffix and the script's reviewed exemptions (forwarding relays, real providers).
+ * The suffix filter is what makes the parent-domain match safe: a listed `msk.ru` would refuse
+ * every organization registered under it.
+ */
+export async function isDisposableEmail(email: string | null | undefined): Promise {
+ if (!email?.includes('@')) return false
+ const domain = emailDomain(email)
+ if (!domain) return false
+
+ const labels = domain.split('.')
+ const candidates = labels.slice(0, -1).map((_, i) => labels.slice(i).join('.'))
+
+ let domains: ReadonlySet
+ try {
+ domains = await loadDisposableDomains()
+ } catch (error) {
+ logger.error('Disposable email list failed to load; allowing the signup', { error })
+ return false
+ }
+
+ return candidates.some((candidate) => domains.has(candidate))
+}
diff --git a/apps/sim/lib/messaging/email/validation.ts b/apps/sim/lib/messaging/email/validation.ts
index e3fe4cd72e5..84125cd2cac 100644
--- a/apps/sim/lib/messaging/email/validation.ts
+++ b/apps/sim/lib/messaging/email/validation.ts
@@ -43,7 +43,7 @@ const DISPOSABLE_DOMAINS = new Set([
/**
* Checks if email is from a known disposable email provider
*/
-function isDisposableEmail(email: string): boolean {
+function isCommonDisposableEmail(email: string): boolean {
const domain = email.split('@')[1]?.toLowerCase()
return domain ? DISPOSABLE_DOMAINS.has(domain) : false
}
@@ -91,7 +91,7 @@ export function quickValidateEmail(email: string): EmailValidationResult {
}
}
- checks.disposable = !isDisposableEmail(email)
+ checks.disposable = !isCommonDisposableEmail(email)
if (!checks.disposable) {
return {
isValid: false,
diff --git a/apps/sim/lib/mothership/agent-cli/saved-run-read.live.test.ts b/apps/sim/lib/mothership/agent-cli/saved-run-read.live.test.ts
index a835b935e38..f92196fcedc 100644
--- a/apps/sim/lib/mothership/agent-cli/saved-run-read.live.test.ts
+++ b/apps/sim/lib/mothership/agent-cli/saved-run-read.live.test.ts
@@ -62,9 +62,10 @@ import {
workspaceFiles,
} from '@sim/db/schema'
import type { PermissionType } from '@sim/platform-authz/workspace'
-import { authMockFns, envFlagsMock, setEnvFlags } from '@sim/testing'
+import { authMockFns, envFlagsMock, setEnv, setEnvFlags } from '@sim/testing'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
+import { generateRandomHex } from '@sim/utils/random'
import { eq, is, SQL, sql } from 'drizzle-orm'
import { getTableConfig } from 'drizzle-orm/pg-core'
import { NextRequest } from 'next/server'
@@ -133,11 +134,18 @@ import {
} from '@/lib/mothership/chat/process-contents'
import { finalizeAssistantTurn } from '@/lib/mothership/chat/terminal-state'
import { createRegistryContentGate } from '@/lib/mothership/content-delivery/registry-gate'
-import { WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY } from '@/lib/mothership/generated/protocol'
+import {
+ WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
+ WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
+} from '@/lib/mothership/generated/protocol'
import { prepareInboxAttachments } from '@/lib/mothership/inbox/attachments'
import { claimRunController } from '@/lib/mothership/request/lifecycle/controller-ownership'
import { runCopilotLifecycle } from '@/lib/mothership/request/lifecycle/run'
-import { isToolCallStreamEvent } from '@/lib/mothership/request/session'
+import {
+ isToolCallStreamEvent,
+ registerActiveStream,
+ unregisterActiveStream,
+} from '@/lib/mothership/request/session'
import { maybeWriteOutputToFile } from '@/lib/mothership/request/tools/files'
import { maybeWriteOutputToTable } from '@/lib/mothership/request/tools/tables'
import type { ExecutionContext } from '@/lib/mothership/request/types'
@@ -154,6 +162,7 @@ import { calculateNextRunTime, getScheduleTimeValues } from '@/lib/workflows/sch
import { fileOperations } from '@/lib/workspace-files/application/operations'
import { readWorkspaceFileArtifact } from '@/lib/workspace-files/application/read-workspace-file-artifact'
import { readWorkspaceFileText } from '@/lib/workspace-files/application/read-workspace-file-text'
+import { POST as abortChatRoute } from '@/app/api/copilot/chat/abort/route'
import {
POST as addChatResourceRoute,
DELETE as removeChatResourceRoute,
@@ -230,7 +239,16 @@ vi.mock('@/lib/mothership/inbox/agentmail-client', () => ({
vi.mock('@sim/logger', async () => {
const { loggerMock, createMockLogger } = await import('@sim/testing')
const logger = createMockLogger()
- logger.error.mockImplementation((...args: unknown[]) => fixture.errors.push(args))
+ // Errors keep their message and top frames, which JSON evidence would otherwise drop as `{}`.
+ const loggable = (value: unknown): unknown =>
+ value instanceof Error
+ ? { message: value.message, stack: value.stack?.split('\n').slice(0, 6) }
+ : Array.isArray(value)
+ ? value.map(loggable)
+ : value !== null && typeof value === 'object'
+ ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, loggable(item)]))
+ : value
+ logger.error.mockImplementation((...args: unknown[]) => fixture.errors.push(args.map(loggable)))
logger.withMetadata.mockReturnValue(logger)
logger.child.mockReturnValue(logger)
return { ...loggerMock, createLogger: () => logger }
@@ -3601,6 +3619,10 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
['undeclared', 260, 0],
// The model's own --wait-timeout 5 lapses: an exit 4 the worker hands the model.
['own-budget', 30, 0],
+ // run_workflow on a Sim that answers a tool's run still running after the frame's wait.
+ ['tool', 300, 200],
+ // The user stops the chat while the worker waits on a detached run: the run stops too.
+ ['declared-stop', 300, 300],
] as const)(
'draft test run past a wait slice reaches the model settled (capability %s)',
async (capability, waitA, waitB) => {
@@ -3670,6 +3692,9 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
fixture.saved.set(draftId, { blocks, edges, loops: {}, parallels: {} })
// A paid plan's synchronous limit, so the run outlasts several slices.
fixture.executionTimeoutMs = 50 * 60_000
+ // A workflow tool call seals its inputs' provenance, which takes a real AES key.
+ const encryptionKey = env.ENCRYPTION_KEY
+ setEnv({ ENCRYPTION_KEY: generateRandomHex(64) })
const child = spawn('bun', ['tools/probes/src/draft-run-slices.ts'], {
cwd: workerRoot,
@@ -3686,6 +3711,7 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
MSHIP_PROBE_RUN_FLAGS: JSON.stringify(
capability === 'own-budget' ? ['--wait-timeout', '5'] : []
),
+ MSHIP_PROBE_TOOL: capability === 'tool' ? 'run_workflow' : '',
},
stdio: ['pipe', 'pipe', 'pipe'],
})
@@ -3734,8 +3760,51 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
workspaceId,
type: 'mothership',
title: `Long draft ${capability}`,
+ // The Stop route stops the chat's current stream.
+ ...(capability === 'declared-stop' ? { conversationId: messageId } : {}),
})
const startedAt = Date.now()
+ /** When the declared-stop case stopped the chat, once the worker waited on a detached run. */
+ let stoppedAt: number | undefined
+ // Registered as the chat route registers its turn (lifecycle/start.ts), so a Stop
+ // aborts it; the route also aborts it on an observed abort marker.
+ const turn = new AbortController()
+ registerActiveStream(messageId, turn)
+ const stopper =
+ capability === 'declared-stop'
+ ? (async () => {
+ // Bounded: a run that never reaches a run wait fails the case below.
+ for (let read = 0; read < 300; read++) {
+ await sleep(2_000)
+ const [current] = await db
+ .select({ id: copilotRuns.id })
+ .from(copilotRuns)
+ .where(eq(copilotRuns.streamId, messageId))
+ if (!current) continue
+ const waiting = await db
+ .select({ args: copilotAsyncToolCalls.args })
+ .from(copilotAsyncToolCalls)
+ .where(eq(copilotAsyncToolCalls.runId, current.id))
+ if (!JSON.stringify(waiting).includes('"runs","wait"')) continue
+ await sleep(5_000)
+ authMockFns.mockGetSession.mockResolvedValueOnce({
+ user: { id: 'run-reader' },
+ session: { id: 'stop-session' },
+ })
+ stoppedAt = Date.now()
+ // The Stop button's request: it stops the run, not just the stream.
+ const response = await abortChatRoute(
+ new NextRequest('https://sim.test/api/copilot/chat/abort', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ chatId, streamId: messageId, workspaceId }),
+ })
+ )
+ return response.status
+ }
+ return undefined
+ })()
+ : undefined
const result = await runCopilotLifecycle(
{
message: 'Test-run the draft and tell me its output.',
@@ -3745,9 +3814,16 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
chatId,
messageId,
// What lib/mothership/chat/payload.ts declares on every chat turn.
- ...(capability !== 'undeclared'
- ? { clientCapabilities: [WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY] }
- : {}),
+ ...(capability === 'tool'
+ ? {
+ clientCapabilities: [
+ WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
+ WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
+ ],
+ }
+ : capability !== 'undeclared'
+ ? { clientCapabilities: [WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY] }
+ : {}),
},
{
userId: 'run-reader',
@@ -3758,12 +3834,32 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
interactive: false,
clientToolPickupExpected: false,
flushAfterEvent: false,
- abortSignal: AbortSignal.timeout(20 * 60_000),
+ // Wired as the chat route wires its controller (lifecycle/start.ts): an observed
+ // Stop aborts the turn.
+ abortSignal: AbortSignal.any([turn.signal, AbortSignal.timeout(20 * 60_000)]),
+ onAbortObserved: (reason) => {
+ if (!turn.signal.aborted) turn.abort(reason)
+ },
executionContext: {
userId: 'run-reader',
workspaceId,
workflowId: draftId,
chatId,
+ // As the chat route marks every turn's tool execution and bills it
+ // (lib/mothership/chat/post.ts); a workflow tool runs in-process under both.
+ copilotToolExecution: true,
+ billingAttribution: {
+ actorUserId: 'run-reader',
+ workspaceId,
+ organizationId: null,
+ billedAccountUserId: 'run-reader',
+ billingEntity: { type: 'user', id: 'run-reader' },
+ billingPeriod: {
+ start: '2026-09-01T00:00:00.000Z',
+ end: '2026-10-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+ },
userPermission: 'write',
resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([], {
userId: 'run-reader',
@@ -3773,6 +3869,8 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
}
)
const endedAt = Date.now()
+ unregisterActiveStream(messageId, turn)
+ const stopStatus = await stopper
const [run] = await db
.select()
.from(copilotRuns)
@@ -3783,8 +3881,38 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
.from(copilotAsyncToolCalls)
.where(eq(copilotAsyncToolCalls.runId, run.id))
: []
+ /** The detached run's own status after a Stop, read until it leaves `running`. */
+ let stoppedRun: { executionId?: string; status?: string; endedAt?: string } = {}
+ if (capability === 'declared-stop') {
+ const receipt = calls.find(
+ (call) =>
+ (call.args as { request?: { invocation?: { argv?: string[] } } }).request
+ ?.invocation?.argv?.[1] === 'run'
+ )?.result as { stdout?: string } | undefined
+ const executionId = String(JSON.parse(receipt?.stdout ?? '{}').runId ?? '')
+ for (let read = 0; read < 30; read++) {
+ const [log] = await db
+ .select({
+ status: workflowExecutionLogs.status,
+ endedAt: workflowExecutionLogs.endedAt,
+ })
+ .from(workflowExecutionLogs)
+ .where(eq(workflowExecutionLogs.executionId, executionId))
+ stoppedRun = {
+ executionId,
+ status: log?.status,
+ endedAt: log?.endedAt?.toISOString(),
+ }
+ if (log && log.status !== 'running') break
+ await sleep(2_000)
+ }
+ }
const evidence = {
capability,
+ stopStatus,
+ stoppedAt: stoppedAt ? new Date(stoppedAt).toISOString() : undefined,
+ stoppedRun,
+ errors: fixture.errors,
waits: [waitA, waitB],
startedAt: new Date(startedAt).toISOString(),
totalSeconds: (endedAt - startedAt) / 1000,
@@ -3798,6 +3926,9 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
toolName: call.toolName,
args: call.args,
status: call.status,
+ createdAt: call.createdAt?.toISOString(),
+ result: call.toolName === 'sim_cli' ? undefined : call.result,
+ error: call.error,
exitCode: (call.result as { exitCode?: unknown } | null)?.exitCode,
stdout: (call.result as { stdout?: unknown } | null)?.stdout,
startedAt: call.executionStartedAt?.toISOString(),
@@ -3818,13 +3949,24 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
`${process.env.MSHIP_SLICES_REPORT_PATH}.${capability}.json`,
JSON.stringify(evidence, null, 2)
)
+ if (capability === 'declared-stop') {
+ expect(stopStatus).toBe(200)
+ // The turn ended at the Stop, not when the run would have settled.
+ expect(endedAt - (stoppedAt ?? endedAt)).toBeLessThan(60_000)
+ // The run the worker was waiting on stopped with the chat, as a held run does.
+ expect(stoppedRun.status, JSON.stringify(evidence)).toBe('cancelled')
+ return
+ }
expect(
result.success,
JSON.stringify({ evidence, errors: fixture.errors, diagnostics })
).toBe(true)
expect(run?.status).toBe('complete')
const report = JSON.parse(result.content)
- const seen = String(report.output.value)
+ const seen =
+ typeof report.output.value === 'string'
+ ? report.output.value
+ : JSON.stringify(report.output.value)
if (capability === 'own-budget') {
expect(calls).toHaveLength(1)
expect(
@@ -3840,10 +3982,6 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
}
// Sim #9469: a still-running (exit 4) slice settles as done, not failed.
for (const call of calls) expect(call.status, call.toolName).not.toBe('failed')
- // The model saw the settled run, not a detached receipt.
- expect(seen).toContain('"exitCode":0')
- expect(seen).toContain('"status":"completed"')
- expect(seen).toContain('after-waits')
const waitSeconds = waitA + waitB
const argvOf = (call: (typeof calls)[number]) =>
(call.args as { request: { invocation: { argv: string[] } } }).request.invocation.argv
@@ -3851,6 +3989,56 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
call.result as { exitCode: number; stdout: string }
const heldSeconds = (call: (typeof calls)[number]) =>
(Number(call.executionSettledAt) - Number(call.executionStartedAt)) / 1000
+ if (capability === 'tool') {
+ const [toolCall, ...slices] = calls
+ expect(toolCall.toolName).toBe('run_workflow')
+ // The tool answered its run still running by the run's id, after one wait.
+ const running = toolCall.result as { status?: string; executionId?: string }
+ expect(running).toMatchObject({ status: 'running', workflowId: draftId })
+ const executionId = String(running.executionId)
+ const answeredAfter = (Number(slices[0].createdAt) - Number(toolCall.createdAt)) / 1000
+ expect(answeredAfter).toBeGreaterThan(239)
+ expect(answeredAfter).toBeLessThan(250)
+ const toolWaits = slices.filter(
+ (call) => argvOf(call).slice(0, 3).join(' ') === 'workflows runs wait'
+ )
+ expect(toolWaits.length).toBeGreaterThan(0)
+ for (const [index, call] of toolWaits.entries()) {
+ expect(argvOf(call)).toEqual([
+ 'workflows',
+ 'runs',
+ 'wait',
+ executionId,
+ '--workflow',
+ draftId,
+ '--wait-timeout',
+ '240',
+ ])
+ expect(outcome(call).exitCode).toBe(index === toolWaits.length - 1 ? 0 : 4)
+ expect(heldSeconds(call)).toBeLessThan(250)
+ }
+ const readBack = slices.at(-1)
+ if (!readBack) throw new Error('The run was never read back')
+ expect(argvOf(readBack)).toEqual([
+ 'workflows',
+ 'runs',
+ 'get',
+ executionId,
+ '--workflow',
+ draftId,
+ '--include-output',
+ ])
+ expect(slices).toHaveLength(toolWaits.length + 1)
+ // The model saw the settled run with its output, not the still-running answer.
+ expect(seen).toContain('"status":"completed"')
+ expect(seen).toContain('after-waits')
+ expect(endedAt - startedAt).toBeGreaterThan(waitSeconds * 1000)
+ return
+ }
+ // The model saw the settled run, not a detached receipt.
+ expect(seen).toContain('"exitCode":0')
+ expect(seen).toContain('"status":"completed"')
+ expect(seen).toContain('after-waits')
const runs = calls.filter((call) => argvOf(call)[1] === 'run')
const waits = calls.filter(
(call) => argvOf(call).slice(0, 3).join(' ') === 'workflows runs wait'
@@ -3919,6 +4107,7 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
expect(endedAt - startedAt).toBeGreaterThan(waitSeconds * 1000)
} finally {
clearTimeout(startupTimeout)
+ setEnv({ ENCRYPTION_KEY: encryptionKey })
fixture.executionTimeoutMs = 20_000
child.kill()
await exited
diff --git a/apps/sim/lib/mothership/async-runs/repository.test.ts b/apps/sim/lib/mothership/async-runs/repository.test.ts
index 9ef7c75bdde..c2abba93430 100644
--- a/apps/sim/lib/mothership/async-runs/repository.test.ts
+++ b/apps/sim/lib/mothership/async-runs/repository.test.ts
@@ -14,6 +14,7 @@ import {
closeStreamToolAdmission,
createRunSegment,
detachAsyncToolCall,
+ detachedWorkflowExecutionId,
getClaimedWorkflowExecutionId,
getUnsettledStreamSandboxProcesses,
markAsyncToolRunning,
@@ -458,3 +459,63 @@ describe('async tool repository single-row semantics', () => {
).rejects.toThrow('Async tool call belongs to another run')
})
})
+
+describe('the workflow run a settled call left running', () => {
+ const stored = {
+ argv: null,
+ exitCode: null,
+ stdout: null,
+ status: null,
+ executionId: null,
+ }
+ const cli = (argv: string[], exitCode: number, record: Record) => ({
+ ...stored,
+ executableName: 'sim_cli',
+ argv,
+ exitCode: String(exitCode),
+ stdout: JSON.stringify(record),
+ })
+ const tool = (executableName: string, status: string | null, executionId: string) => ({
+ ...stored,
+ executableName,
+ status,
+ executionId,
+ })
+ const running = { runId: 'run-1', workflowId: 'wf-1', status: 'running' }
+
+ it('is a workflows run the CLI answered still running', () => {
+ expect(
+ detachedWorkflowExecutionId(
+ cli(['workflows', 'run', 'wf-1', '--wait-timeout', '240'], 4, running)
+ )
+ ).toBe('run-1')
+ })
+
+ it('is a workflows run behind global flags', () => {
+ expect(
+ detachedWorkflowExecutionId(
+ cli(['--output', 'json', 'workflows', 'run', 'wf-1', '--wait-timeout', '240'], 4, running)
+ )
+ ).toBe('run-1')
+ })
+
+ it('is a workflow tool answered still running', () => {
+ expect(detachedWorkflowExecutionId(tool('run_workflow', 'running', 'exec-1'))).toBe('exec-1')
+ })
+
+ it.each([
+ [
+ 'a run that settled',
+ cli(['workflows', 'run', 'wf-1'], 0, { ...running, status: 'completed' }),
+ ],
+ // Waiting on a run proves nothing about who started it; Stop must not cancel it.
+ ['a wait on a run', cli(['workflows', 'runs', 'wait', 'run-1'], 4, running)],
+ [
+ 'an unreadable answer',
+ { ...cli(['workflows', 'run', 'wf-1'], 4, running), stdout: 'not json' },
+ ],
+ ['a workflow tool that settled', tool('run_block', null, 'exec-1')],
+ ])('is not %s', (_label, call) => {
+ expect(detachedWorkflowExecutionId(call)).toBeUndefined()
+ })
+})
diff --git a/apps/sim/lib/mothership/async-runs/repository.ts b/apps/sim/lib/mothership/async-runs/repository.ts
index 6383705b7b1..0b6a8c50894 100644
--- a/apps/sim/lib/mothership/async-runs/repository.ts
+++ b/apps/sim/lib/mothership/async-runs/repository.ts
@@ -11,6 +11,7 @@ import {
copilotRequestStops,
copilotRuns,
user,
+ workflowExecutionLogs,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { filterUndefined, toRecordOrNull } from '@sim/utils/object'
@@ -79,6 +80,10 @@ import {
NO_SECRET_AUTHORITY,
type TurnSecretAuthority,
} from '@/lib/mothership/secret-mount-policy'
+import {
+ isWorkflowToolName,
+ WORKFLOW_TOOL_NAMES,
+} from '@/lib/mothership/tools/client-executed-tools'
import {
type DesktopToolClaimOwner,
isDesktopToolCall,
@@ -261,6 +266,8 @@ export interface RunStopTarget {
/** The client workflows and sandbox commands a stopped run started, read once its admission closed. */
export interface StoppedRunWork {
clientWorkflowExecutionIds: string[]
+ /** Runs its calls started and left running past the call (`getDetachedWorkflowExecutions`). */
+ detachedWorkflowExecutionIds: string[]
sandboxProcesses: StoppedSandboxProcess[]
}
@@ -361,6 +368,7 @@ export async function stopLiveRunsTx(
chatId,
desktopDeviceId,
clientWorkflowExecutionIds: await getUnsettledClientWorkflowExecutions(streamId, userId, tx),
+ detachedWorkflowExecutionIds: await getDetachedWorkflowExecutions(streamId, userId, tx),
sandboxProcesses: await getUnsettledStreamSandboxProcesses(streamId, userId, tx),
})
}
@@ -1750,6 +1758,7 @@ export async function prepareWorkbenchAccess(
settledAt: copilotAsyncToolCalls.executionSettledAt,
revokedAt: copilotAsyncToolCalls.executionRevokedAt,
toolCallId: copilotAsyncToolCalls.toolCallId,
+ executableName: copilotAsyncToolCalls.executableName,
processes: copilotAsyncToolCalls.sandboxProcesses,
})
.from(copilotAsyncToolCalls)
@@ -1817,7 +1826,13 @@ export async function prepareWorkbenchAccess(
if (processes.some((process) => process.sessionKey !== input.sessionKey))
throw new Error('Recorded workbench ownership does not match this chat')
return {
- handlersPending: previous.some((row) => !row.settledAt && !row.revokedAt),
+ // A workflow tool's run never touches the workbench, and one answered running outlives its turn.
+ handlersPending: previous.some(
+ (row) =>
+ !row.settledAt &&
+ !row.revokedAt &&
+ !(row.executableName && isWorkflowToolName(row.executableName))
+ ),
processes,
}
})
@@ -1922,6 +1937,108 @@ export async function getUnsettledClientWorkflowExecutions(
)
}
+/** The fields of a stored call that say whether it left a workflow run going, read as text. */
+interface DetachedRunCall {
+ executableName: string | null
+ /** `args.request.invocation.argv` of a `sim_cli` call. */
+ argv: unknown
+ exitCode: string | null
+ stdout: string | null
+ status: string | null
+ executionId: string | null
+}
+
+/**
+ * The workflow execution a settled call started and left running: a `workflows run` the CLI
+ * answered still running (exit 4 and its run record), or a workflow tool answered still running
+ * (`WorkflowToolRunningResult`). A run the call only waited on (`workflows runs wait`) is not
+ * one: another run started it.
+ */
+export function detachedWorkflowExecutionId(call: DetachedRunCall): string | undefined {
+ if (call.executableName === 'sim_cli') {
+ const argv = call.argv
+ // The command path, wherever global flags (`--output json`) put it, never `workflows runs`.
+ const end = Array.isArray(argv) && argv.includes('--') ? argv.indexOf('--') : undefined
+ const tokens = Array.isArray(argv) ? argv.slice(0, end) : []
+ if (!tokens.some((token, index) => token === 'workflows' && tokens[index + 1] === 'run'))
+ return undefined
+ if (call.exitCode !== '4' || call.stdout === null) return undefined
+ try {
+ const record = toRecordOrNull(JSON.parse(call.stdout))
+ return record?.status === 'running' && typeof record.runId === 'string'
+ ? record.runId
+ : undefined
+ } catch {
+ return undefined
+ }
+ }
+ return call.status === 'running' && call.executionId !== null ? call.executionId : undefined
+}
+
+/**
+ * Workflow executions this run's calls started and left running past the call, and that are still
+ * running: what a Stop cancels beyond the calls still open, since a detached run takes no call's
+ * abort signal. A run whose log has settled or paused is left alone: a paused run waits on a
+ * person, not on this turn, and the durable cancel flag would fail its resume under the same
+ * execution id. A run with no log yet may still be starting, so it is cancelled.
+ */
+export async function getDetachedWorkflowExecutions(
+ streamId: string,
+ userId: string,
+ executor: DbOrTx = db
+) {
+ return await withDbSpan(
+ TraceSpan.CopilotAsyncRunsGetRunSegment,
+ 'SELECT',
+ 'copilot_async_tool_calls',
+ { [TraceAttr.StreamId]: streamId, [TraceAttr.UserId]: userId },
+ async () => {
+ const { args, result } = copilotAsyncToolCalls
+ // Only what `detachedWorkflowExecutionId` reads; an exit-4 `sim_cli` stdout is its run record.
+ const rows: DetachedRunCall[] = await executor
+ .select({
+ executableName: copilotAsyncToolCalls.executableName,
+ argv: sql`${args} #> '{request,invocation,argv}'`,
+ exitCode: sql`${result} ->> 'exitCode'`,
+ stdout: sql`${result} ->> 'stdout'`,
+ status: sql`${result} ->> 'status'`,
+ executionId: sql`${result} ->> 'executionId'`,
+ })
+ .from(copilotAsyncToolCalls)
+ .innerJoin(copilotRuns, eq(copilotRuns.id, copilotAsyncToolCalls.runId))
+ .where(
+ and(
+ eq(copilotRuns.streamId, streamId),
+ eq(copilotRuns.userId, userId),
+ or(
+ and(
+ eq(copilotAsyncToolCalls.executableName, 'sim_cli'),
+ sql`${result} ->> 'exitCode' = '4'`
+ ),
+ and(
+ inArray(copilotAsyncToolCalls.executableName, [...WORKFLOW_TOOL_NAMES]),
+ sql`${result} ->> 'status' = 'running'`
+ )
+ )
+ )
+ )
+ const detached = [...new Set(rows.flatMap((row) => detachedWorkflowExecutionId(row) ?? []))]
+ if (detached.length === 0) return []
+ const settled = await executor
+ .select({ executionId: workflowExecutionLogs.executionId })
+ .from(workflowExecutionLogs)
+ .where(
+ and(
+ inArray(workflowExecutionLogs.executionId, detached),
+ ne(workflowExecutionLogs.status, 'running')
+ )
+ )
+ const notRunning = new Set(settled.map((row) => row.executionId))
+ return detached.filter((executionId) => !notRunning.has(executionId))
+ }
+ )
+}
+
export function getClaimedWorkflowExecutionId(claimedBy: string | null | undefined) {
if (!claimedBy?.startsWith(WORKFLOW_EXECUTION_CLAIM_PREFIX)) return undefined
const executionId = claimedBy.slice(WORKFLOW_EXECUTION_CLAIM_PREFIX.length)
diff --git a/apps/sim/lib/mothership/chat/application/read-recall-chats.test.ts b/apps/sim/lib/mothership/chat/application/read-recall-chats.test.ts
index e6a8af0d347..de81d1ea242 100644
--- a/apps/sim/lib/mothership/chat/application/read-recall-chats.test.ts
+++ b/apps/sim/lib/mothership/chat/application/read-recall-chats.test.ts
@@ -196,11 +196,15 @@ describe("a chat's other chats", () => {
readRecallChats.execute({ principal: organizationPrincipal(), input })
).rejects.toMatchObject({ code: 'forbidden' })
resetDbChainMock()
- // A workflow's copilot chat is no home chat, so it reads no home chats back.
+ // A workflow's copilot chat is no home chat, so it reads no home chats back, and says so
+ // rather than reporting its own chat missing.
queueWorkspaceChat('agent', 'copilot')
await expect(
readRecallChats.execute({ principal: workspacePrincipal(), input })
- ).rejects.toMatchObject({ code: 'not_found' })
+ ).rejects.toMatchObject({
+ code: 'forbidden',
+ message: "Reading other chats is available in home chats, not in a workflow's copilot chat",
+ })
resetDbChainMock()
queueOrganizationChat()
authBanMockFns.mockGetActivelyBannedUserIds.mockResolvedValue(['actor'])
diff --git a/apps/sim/lib/mothership/chat/application/read-recall-chats.ts b/apps/sim/lib/mothership/chat/application/read-recall-chats.ts
index 04f1ba9bacd..8ab8bdae7dc 100644
--- a/apps/sim/lib/mothership/chat/application/read-recall-chats.ts
+++ b/apps/sim/lib/mothership/chat/application/read-recall-chats.ts
@@ -100,7 +100,12 @@ export const readRecallChats = defineAuthorizedChatUseCase({
},
},
async execute({ principal, context, input }): Promise {
- if (context.type !== 'mothership') throw new OrchestrationError('not_found', 'Chat not found')
+ if (context.type !== 'mothership') {
+ throw new OrchestrationError(
+ 'forbidden',
+ "Reading other chats is available in home chats, not in a workflow's copilot chat"
+ )
+ }
if (context.mode !== 'agent' && context.mode !== 'plan') {
throw new OrchestrationError('forbidden', 'Reading other chats requires an agent chat')
}
diff --git a/apps/sim/lib/mothership/chat/payload.test.ts b/apps/sim/lib/mothership/chat/payload.test.ts
index c82da7f3b93..b300f46a5d3 100644
--- a/apps/sim/lib/mothership/chat/payload.test.ts
+++ b/apps/sim/lib/mothership/chat/payload.test.ts
@@ -34,6 +34,7 @@ import {
ChatPayloadSchema,
TOOL_DEADLINE_CAPABILITY,
WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
+ WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
} from '@/lib/mothership/generated/protocol'
import { searchUsersV2Tool } from '@/tools/github/search_users'
import { gmailListLabelsV2Tool } from '@/tools/gmail/list_labels'
@@ -430,6 +431,7 @@ describe('buildCopilotRequestPayload', () => {
'workflow-tool-pickup',
TOOL_DEADLINE_CAPABILITY,
WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
+ WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
])
}
)
@@ -514,6 +516,7 @@ describe('buildCopilotRequestPayload', () => {
expect(payload.clientCapabilities).toEqual([
TOOL_DEADLINE_CAPABILITY,
WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
+ WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
])
if (owner === 'organization') expect(payload).not.toHaveProperty('integrationTools')
}
@@ -854,6 +857,7 @@ describe('Assistant payload', () => {
expect(payload.clientCapabilities).toEqual([
TOOL_DEADLINE_CAPABILITY,
WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
+ WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
])
expect(payload.mode).toBe('assistant')
expect(payload.assistantSearch).toEqual({ source: 'slack', documentIds: ['document-1'] })
diff --git a/apps/sim/lib/mothership/chat/payload.ts b/apps/sim/lib/mothership/chat/payload.ts
index 33b758e3afb..a6cace00771 100644
--- a/apps/sim/lib/mothership/chat/payload.ts
+++ b/apps/sim/lib/mothership/chat/payload.ts
@@ -38,6 +38,7 @@ import {
type ModelSelection,
TOOL_DEADLINE_CAPABILITY,
WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
+ WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
} from '@/lib/mothership/generated/protocol'
import type { VfsSnapshotV1 } from '@/lib/mothership/generated/vfs-snapshot-v1'
import { getToolEntry } from '@/lib/mothership/tool-executor/router'
@@ -562,13 +563,15 @@ export async function buildCopilotRequestPayload(params: BuildPayloadParams): Pr
: {}),
// The mounted chat view executes client-routed workflow tools (run panel UX), so the
// UI declares that capability explicitly; headless callers omit it and the server runs
- // those tools immediately. The deadline and run-wait tokens are this server's own: it
- // bounds every handed call by the deadline its frame names, and its CLI lets a
- // synchronous `workflows run` outlast `--wait-timeout`.
+ // those tools immediately. The deadline and wait tokens are this server's own: it
+ // bounds every handed call by the deadline its frame names, its CLI lets a synchronous
+ // `workflows run` outlast `--wait-timeout`, and a workflow tool's run outlasts the
+ // `waitTimeoutMs` its frame names.
clientCapabilities: [
...(isAssistant ? [] : ['workflow-tool-pickup']),
TOOL_DEADLINE_CAPABILITY,
WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY,
+ WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY,
],
}
}
diff --git a/apps/sim/lib/mothership/generated/memory-notes-access.ts b/apps/sim/lib/mothership/generated/memory-notes-access.ts
index b1c414e4986..94361b84b4f 100644
--- a/apps/sim/lib/mothership/generated/memory-notes-access.ts
+++ b/apps/sim/lib/mothership/generated/memory-notes-access.ts
@@ -53,6 +53,14 @@ export const MemoryCleanupInput = z.strictObject({
z.strictObject({ kind: z.literal("private_owner"), userId: z.string().min(1).max(200) }),
z.strictObject({ kind: z.literal("workspace"), workspaceId: z.uuid() }),
z.strictObject({ kind: z.literal("project"), projectId: z.uuid() }),
+ // Erase private memory held in an organization: all of it when the organization is
+ // deleted, one person's when they leave it (D106).
+ z.strictObject({ kind: z.literal("organization"), organizationId: z.string().min(1).max(200) }),
+ z.strictObject({
+ kind: z.literal("organization_member"),
+ organizationId: z.string().min(1).max(200),
+ userId: z.string().min(1).max(200),
+ }),
]),
});
export type MemoryCleanupInput = z.infer;
diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts
index 2b252886dde..64b473165f1 100644
--- a/apps/sim/lib/mothership/generated/protocol.ts
+++ b/apps/sim/lib/mothership/generated/protocol.ts
@@ -297,6 +297,14 @@ export interface StreamToolReplay {
*/
export interface StreamToolDeadline {
deadlineMs?: number | undefined;
+ /**
+ * For a workflow tool (`run_workflow`, `run_workflow_until_block`, `run_from_block`,
+ * `run_block`) on a Sim that declares {@link WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY}: how long
+ * Sim waits for the call's run to settle once it starts waiting on it. A run still going then
+ * keeps going and the call settles with a {@link WorkflowToolRunningResult}. Absent = held
+ * until the run settles.
+ */
+ waitTimeoutMs?: number | undefined;
}
/**
@@ -322,6 +330,24 @@ export const WORKSPACE_FILE_COPY_CAPABILITY = "workspace-file-copy";
*/
export const WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY = "workflow-run-wait-timeout";
+/**
+ * The `clientCapabilities` token a Sim sends when it honors `StreamToolDeadline.waitTimeoutMs`
+ * on a workflow tool call: a run that outlasts it continues and the call settles with a
+ * {@link WorkflowToolRunningResult}, so the worker waits on the run in slices.
+ */
+export const WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY = "workflow-tool-wait-timeout";
+
+/**
+ * A workflow tool call's output when its run outlasted the call's `waitTimeoutMs`: the run is
+ * still going under `executionId`, readable as a run of `workflowId` (`workflows runs wait`).
+ */
+export interface WorkflowToolRunningResult {
+ status: "running";
+ executionId: string;
+ workflowId: string;
+ message?: string | undefined;
+}
+
/** POST /api/mothership — the chat request sim sends. */
export interface ChatRequest extends StreamResponseReceipt {
fileOwnerProtocolVersion?: 1 | undefined;
@@ -367,8 +393,9 @@ export interface ChatRequest extends StreamResponseReceipt {
* grace windows and run tools server-side immediately. ABSENT = older/unknown caller —
* dispatch keeps its conservative grace (deploy-skew safe: a stale tab that predates
* this field still gets waited on). Known capability: "workflow-tool-pickup". Sim also
- * declares its own {@link TOOL_DEADLINE_CAPABILITY}, {@link WORKSPACE_FILE_COPY_CAPABILITY} and
- * {@link WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY} here.
+ * declares its own {@link TOOL_DEADLINE_CAPABILITY}, {@link WORKSPACE_FILE_COPY_CAPABILITY},
+ * {@link WORKFLOW_RUN_WAIT_TIMEOUT_CAPABILITY} and {@link WORKFLOW_TOOL_WAIT_TIMEOUT_CAPABILITY}
+ * here.
*/
clientCapabilities?: string[] | undefined;
/**
diff --git a/apps/sim/lib/mothership/memories/offboarding.integration.ts b/apps/sim/lib/mothership/memories/offboarding.integration.ts
new file mode 100644
index 00000000000..8b5bf8a8f5e
--- /dev/null
+++ b/apps/sim/lib/mothership/memories/offboarding.integration.ts
@@ -0,0 +1,145 @@
+import { db } from '@sim/db'
+import { member, organization, outboxEvent, permissions, user, workspace } from '@sim/db/schema'
+import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
+import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
+import { generateId } from '@sim/utils/id'
+import { and, eq, inArray, sql } from 'drizzle-orm'
+import { afterAll, beforeAll, expect, it } from 'vitest'
+
+readTestDatabaseUrl()
+
+const ownerId = generateId()
+const memberId = generateId()
+const externalId = generateId()
+const organizationId = generateId()
+const membershipId = generateId()
+const workspaceId = generateId()
+let runtime: {
+ membership: typeof import('@/lib/billing/organizations/membership')
+ resources: typeof import('@/lib/organizations/resource-cleanup')
+ event: typeof import('@/lib/mothership/memories/cleanup-event')
+}
+
+beforeAll(async () => {
+ // The cleanup outbox reads its worker credential and destination when it loads.
+ process.env.SIM_AGENT_API_URL = 'http://127.0.0.1:9'
+ process.env.COPILOT_API_KEY = 'memory-offboarding-fixture-key'
+ const [membership, resources, event] = await Promise.all([
+ import('@/lib/billing/organizations/membership'),
+ import('@/lib/organizations/resource-cleanup'),
+ import('@/lib/mothership/memories/cleanup-event'),
+ ])
+ runtime = { membership, resources, event }
+ await db.insert(user).values(
+ [ownerId, memberId, externalId].map((id) => ({
+ id,
+ name: 'Offboarding fixture',
+ email: `${id}@memory.test`,
+ emailVerified: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }))
+ )
+ await db
+ .insert(organization)
+ .values({ id: organizationId, name: 'Offboarding', slug: organizationId })
+ await db.insert(member).values([
+ { id: generateId(), organizationId, userId: ownerId, role: 'owner', createdAt: new Date() },
+ { id: membershipId, organizationId, userId: memberId, role: 'member', createdAt: new Date() },
+ ])
+ // The external collaborator works in one of the organization's workspaces without membership.
+ await insertWorkspaceFixture(db, {
+ id: workspaceId,
+ name: 'Offboarding',
+ ownerId,
+ billedAccountUserId: ownerId,
+ organizationId,
+ workspaceMode: 'organization',
+ })
+ await db.insert(permissions).values({
+ id: generateId(),
+ userId: externalId,
+ entityType: 'workspace',
+ entityId: workspaceId,
+ permissionType: 'write',
+ })
+}, 60_000)
+
+afterAll(async () => {
+ await db
+ .delete(outboxEvent)
+ .where(
+ and(
+ eq(outboxEvent.eventType, runtime.event.MEMORY_CLEANUP_EVENT),
+ sql`${outboxEvent.payload}->'request'->'target'->>'organizationId' = ${organizationId}`
+ )
+ )
+ await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId))
+ await db.delete(organization).where(eq(organization.id, organizationId))
+ await db.delete(user).where(inArray(user.id, [ownerId, memberId, externalId]))
+})
+
+/** The memory erasures queued for this organization, as `kind:userId` (or `kind`). */
+async function queued(): Promise {
+ const rows = await db
+ .select({ payload: outboxEvent.payload })
+ .from(outboxEvent)
+ .where(
+ and(
+ eq(outboxEvent.eventType, runtime.event.MEMORY_CLEANUP_EVENT),
+ sql`${outboxEvent.payload}->'request'->'target'->>'organizationId' = ${organizationId}`
+ )
+ )
+ return rows
+ .map(({ payload }) => {
+ const target = (payload as { request: { target: { kind: string; userId?: string } } }).request
+ .target
+ return target.userId ? `${target.kind}:${target.userId}` : target.kind
+ })
+ .sort()
+}
+
+it('queues private memory erasure with each committed departure and organization deletion, never on rollback', async () => {
+ // A removal whose actor lacks authority rolls back with nothing queued.
+ await expect(
+ runtime.membership.removeUserFromOrganization({
+ organizationId,
+ userId: memberId,
+ memberId: membershipId,
+ actorUserId: externalId,
+ skipBillingLogic: true,
+ onError: 'throw',
+ })
+ ).rejects.toThrow()
+ expect(await queued()).toEqual([])
+
+ await runtime.membership.removeUserFromOrganization({
+ organizationId,
+ userId: memberId,
+ memberId: membershipId,
+ skipBillingLogic: true,
+ onError: 'throw',
+ })
+ expect(
+ await runtime.membership.removeExternalUserFromOrganizationWorkspaces({
+ organizationId,
+ userId: externalId,
+ })
+ ).toMatchObject({ success: true, workspaceAccessRevoked: 1 })
+ expect(await queued()).toEqual(
+ [`organization_member:${externalId}`, `organization_member:${memberId}`].sort()
+ )
+
+ // Organization deletion queues the erasure in the delete transaction: a rollback drops it.
+ await expect(
+ db.transaction(async (tx) => {
+ await runtime.resources.enqueueOrganizationResourceCleanup(tx, organizationId)
+ throw new Error('rollback fixture')
+ })
+ ).rejects.toThrow('rollback fixture')
+ expect(await queued()).not.toContain('organization')
+ await db.transaction(async (tx) => {
+ await runtime.resources.enqueueOrganizationResourceCleanup(tx, organizationId)
+ })
+ expect(await queued()).toContain('organization')
+}, 30_000)
diff --git a/apps/sim/lib/mothership/request/application/stop-delivery.ts b/apps/sim/lib/mothership/request/application/stop-delivery.ts
index 76215fae5d5..cf43f5584ab 100644
--- a/apps/sim/lib/mothership/request/application/stop-delivery.ts
+++ b/apps/sim/lib/mothership/request/application/stop-delivery.ts
@@ -5,6 +5,7 @@ import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
import { settleStoppedRunWithoutController } from '@/lib/mothership/async-runs/orphaned-runs'
import {
+ getDetachedWorkflowExecutions,
getUnsettledClientWorkflowExecutions,
getUnsettledStreamSandboxProcesses,
type RunStopTarget,
@@ -34,30 +35,39 @@ interface RunStopDeliveryOptions {
}
/**
- * The client workflows and sandbox commands a stopped run started, read once its admission
- * closed. Each read is best-effort: one that fails stops nothing it would have found.
+ * The client workflows, detached workflow runs and sandbox commands a stopped run started, read
+ * once its admission closed. Each read is best-effort: one that fails stops nothing it would have
+ * found.
*/
export async function readStoppedRunWork(
streamId: string,
userId: string
): Promise {
- const [clientWorkflowExecutionIds, sandboxProcesses] = await Promise.all([
- getUnsettledClientWorkflowExecutions(streamId, userId).catch((error) => {
- logger.warn('Stopped stream workflow ownership could not be read', {
- streamId,
- error: getErrorMessage(error),
- })
- return []
- }),
- getUnsettledStreamSandboxProcesses(streamId, userId).catch((error) => {
- logger.warn('Stopped stream sandbox ownership could not be read', {
- streamId,
- error: getErrorMessage(error),
- })
- return []
- }),
- ])
- return { clientWorkflowExecutionIds, sandboxProcesses }
+ const [clientWorkflowExecutionIds, detachedWorkflowExecutionIds, sandboxProcesses] =
+ await Promise.all([
+ getUnsettledClientWorkflowExecutions(streamId, userId).catch((error) => {
+ logger.warn('Stopped stream workflow ownership could not be read', {
+ streamId,
+ error: getErrorMessage(error),
+ })
+ return []
+ }),
+ getDetachedWorkflowExecutions(streamId, userId).catch((error) => {
+ logger.warn('Stopped stream detached workflow runs could not be read', {
+ streamId,
+ error: getErrorMessage(error),
+ })
+ return []
+ }),
+ getUnsettledStreamSandboxProcesses(streamId, userId).catch((error) => {
+ logger.warn('Stopped stream sandbox ownership could not be read', {
+ streamId,
+ error: getErrorMessage(error),
+ })
+ return []
+ }),
+ ])
+ return { clientWorkflowExecutionIds, detachedWorkflowExecutionIds, sandboxProcesses }
}
/**
@@ -97,14 +107,21 @@ export async function deliverRunStop(
error: getErrorMessage(error),
})
}
- const cancelClientWorkflows = async () =>
- Promise.all(
- (await work).clientWorkflowExecutionIds.map(async (executionId) => {
- /** The execution route reserved these IDs before recording ownership; no client result is trusted. */
- abortManualExecution(executionId)
- await markExecutionCancelled(executionId)
- })
+ const cancelClientWorkflows = async () => {
+ const { clientWorkflowExecutionIds, detachedWorkflowExecutionIds } = await work
+ /**
+ * The execution route reserved the client IDs before recording ownership; no client result is
+ * trusted. A detached run took no call's abort signal, so only its own cancel reaches it.
+ */
+ return Promise.all(
+ [...new Set([...clientWorkflowExecutionIds, ...detachedWorkflowExecutionIds])].map(
+ async (executionId) => {
+ abortManualExecution(executionId)
+ await markExecutionCancelled(executionId)
+ }
+ )
)
+ }
const [settled, worker] = await Promise.all([
waitForPendingChatStream(chatId, 8000, streamId),
workerStop,
diff --git a/apps/sim/lib/mothership/request/handlers/handlers.test.ts b/apps/sim/lib/mothership/request/handlers/handlers.test.ts
index 89127e9d8e0..3d1ab77e7d2 100644
--- a/apps/sim/lib/mothership/request/handlers/handlers.test.ts
+++ b/apps/sim/lib/mothership/request/handlers/handlers.test.ts
@@ -732,6 +732,35 @@ describe('sse-handlers tool lifecycle', () => {
expect(context.toolCalls.get('takeover-1')?.deadlineAt).toBeLessThan(before + 60_000)
})
+ it('hands a workflow tool the wait its frame names, and no other tool', async () => {
+ executeTool.mockResolvedValue({ success: true, output: {} })
+ const frame = (toolCallId: string, toolName: string, waitTimeoutMs: unknown) => {
+ const event = deadlineFrame(900_000, {
+ toolCallId,
+ toolName,
+ arguments: { workflowId: 'workflow-1' },
+ })
+ return { ...event, payload: { ...event.payload, waitTimeoutMs } } as StreamEvent
+ }
+ await sseHandlers.tool(frame('run-1', 'run_workflow', 240_000), context, execContext, {
+ interactive: false,
+ })
+ await sseHandlers.tool(frame('audio-2', 'generate_audio', 240_000), context, execContext, {
+ interactive: false,
+ })
+ await sseHandlers.tool(frame('run-2', 'run_workflow', -5), context, execContext, {
+ interactive: false,
+ })
+ await Promise.all(context.pendingToolPromises.values())
+ const handed = (toolCallId: string) =>
+ executeTool.mock.calls.find(
+ ([, , toolContext]) => toolContext?.toolCallId === toolCallId
+ )?.[2]
+ expect(handed('run-1')?.workflowRunWaitMs).toBe(240_000)
+ expect(handed('audio-2')).not.toHaveProperty('workflowRunWaitMs')
+ expect(handed('run-2')).not.toHaveProperty('workflowRunWaitMs')
+ })
+
/** Runs a desktop-local read whose frame names `deadlineMs`, recording the emitted events. */
async function runLocalRead(deadlineMs: number) {
const onEvent = vi.fn()
diff --git a/apps/sim/lib/mothership/request/handlers/tool.ts b/apps/sim/lib/mothership/request/handlers/tool.ts
index 6955e09ba01..3ec10080dae 100644
--- a/apps/sim/lib/mothership/request/handlers/tool.ts
+++ b/apps/sim/lib/mothership/request/handlers/tool.ts
@@ -44,6 +44,7 @@ import { sealClientToolContext } from '@/lib/mothership/request/tools/client-com
import {
capToToolDeadline,
frameToolDeadlineAt,
+ frameWorkflowWaitMs,
TOOL_DEADLINE_BEFORE_START_MESSAGE,
TOOL_DEADLINE_EXCEEDED_MESSAGE,
toDeadlineDate,
@@ -742,6 +743,8 @@ async function handleCallPhase(
if (parentToolCallId) toolCall.parentToolCallId = parentToolCallId
if (data.execName) toolCall.execName = data.execName
toolCall.deadlineAt ??= frameToolDeadlineAt(data.deadlineMs, Date.now())
+ if (isWorkflowToolName(toolName))
+ toolCall.waitTimeoutMs ??= frameWorkflowWaitMs(data.waitTimeoutMs)
const readPath = typeof args?.path === 'string' ? args.path : undefined
if (toolName === 'read' && readPath?.startsWith('internal/')) return
@@ -1110,6 +1113,9 @@ async function dispatchToolExecution(
options.clientToolPickupExpected === false
? 0
: COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS,
+ ...(toolCall.waitTimeoutMs !== undefined
+ ? { waitTimeoutMs: toolCall.waitTimeoutMs }
+ : {}),
abortSignal: options.abortSignal,
registry: execContext.resolvedSecretTraceRegistry,
runOnServer: (boundExecutionId) => {
diff --git a/apps/sim/lib/mothership/request/tools/client.test.ts b/apps/sim/lib/mothership/request/tools/client.test.ts
index 7ba230faee4..ff20d09a7b9 100644
--- a/apps/sim/lib/mothership/request/tools/client.test.ts
+++ b/apps/sim/lib/mothership/request/tools/client.test.ts
@@ -729,7 +729,8 @@ describe('workflow client tool completion', () => {
expect(completion).toEqual({
status: 'background',
message: 'Workflow execution is continuing in the background.',
- data: { workflowId: 'workflow-1', executionId: 'execution-1' },
+ // A WorkflowToolRunningResult: the worker waits on the run by these ids.
+ data: { workflowId: 'workflow-1', executionId: 'execution-1', status: 'running' },
})
expect(registry.isComplete()).toBe(true)
expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled()
diff --git a/apps/sim/lib/mothership/request/tools/deadline.ts b/apps/sim/lib/mothership/request/tools/deadline.ts
index 4a013afdcc8..3df01bea69a 100644
--- a/apps/sim/lib/mothership/request/tools/deadline.ts
+++ b/apps/sim/lib/mothership/request/tools/deadline.ts
@@ -20,6 +20,20 @@ export function frameToolDeadlineAt(deadlineMs: unknown, receivedAt: number): nu
return receivedAt + deadlineMs - TOOL_DEADLINE_SETTLE_MARGIN_MS
}
+/**
+ * How long a workflow tool call waits for its run before settling still running, from the
+ * frame's `waitTimeoutMs`. Undefined when the frame names none or a malformed value: the call
+ * holds until its run settles.
+ */
+export function frameWorkflowWaitMs(waitTimeoutMs: unknown): number | undefined {
+ return typeof waitTimeoutMs === 'number' &&
+ Number.isSafeInteger(waitTimeoutMs) &&
+ waitTimeoutMs > 0 &&
+ waitTimeoutMs <= MAX_FRAME_DEADLINE_MS
+ ? waitTimeoutMs
+ : undefined
+}
+
/** What the model learns when a call's deadline passed before the call could start. */
export const TOOL_DEADLINE_BEFORE_START_MESSAGE =
'This tool call ran out of its time limit before it could start, so it did not run. Nothing changed; it is safe to try again with a smaller request.'
diff --git a/apps/sim/lib/mothership/request/tools/executor.ts b/apps/sim/lib/mothership/request/tools/executor.ts
index c3fba27e047..4ea8030b5ee 100644
--- a/apps/sim/lib/mothership/request/tools/executor.ts
+++ b/apps/sim/lib/mothership/request/tools/executor.ts
@@ -369,13 +369,17 @@ class ToolExecutionTimeoutError extends Error {
/** Builds the per-call context from the turn-scoped execution context. */
export function buildToolExecutionContext(
- toolCall: Pick,
+ toolCall: Pick<
+ ToolCallState,
+ 'id' | 'parentToolCallId' | 'params' | 'targetWorkspaceId' | 'waitTimeoutMs'
+ >,
execContext: ExecutionContext
): ExecutionContext {
return {
...execContext,
toolCallId: toolCall.id,
...(toolCall.targetWorkspaceId ? { targetWorkspaceId: toolCall.targetWorkspaceId } : {}),
+ ...(toolCall.waitTimeoutMs !== undefined ? { workflowRunWaitMs: toolCall.waitTimeoutMs } : {}),
resolvedSecretTraceRegistry: execContext.resolvedSecretTraceRegistry?.forkForInputPaths([]),
...(toolCall.parentToolCallId ? { parentToolCallId: toolCall.parentToolCallId } : {}),
}
diff --git a/apps/sim/lib/mothership/request/tools/workflow-client-fallback.ts b/apps/sim/lib/mothership/request/tools/workflow-client-fallback.ts
index 0c346b84418..3503ab696d0 100644
--- a/apps/sim/lib/mothership/request/tools/workflow-client-fallback.ts
+++ b/apps/sim/lib/mothership/request/tools/workflow-client-fallback.ts
@@ -1,16 +1,30 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
-import { sleep } from '@sim/utils/helpers'
+import { interruptibleSleep, sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import type {
AsyncCompletionSignal,
AsyncTerminalCompletionSnapshot,
} from '@/lib/mothership/async-runs/lifecycle'
-import { claimWorkflowToolExecution } from '@/lib/mothership/async-runs/repository'
+import {
+ ASYNC_TOOL_CONFIRMATION_STATUS,
+ ASYNC_TOOL_STATUS,
+} from '@/lib/mothership/async-runs/lifecycle'
+import {
+ claimWorkflowToolExecution,
+ getAsyncToolCall,
+ getClaimedWorkflowExecutionId,
+} from '@/lib/mothership/async-runs/repository'
import { CopilotDegradedReason } from '@/lib/mothership/generated/trace-attribute-values-v1'
import { recordDegraded } from '@/lib/mothership/request/metrics'
import { waitForWorkflowToolCompletion } from '@/lib/mothership/request/tools/client'
+import { settleClientToolCall } from '@/lib/mothership/request/tools/client-settlement.server'
import { toolStartWindowClosed } from '@/lib/mothership/request/tools/deadline'
+import {
+ createStructuralWorkflowToolCompletionData,
+ getWorkflowToolCompletionMessage,
+} from '@/lib/mothership/tools/workflow-tools'
+import { getWorkflowExecutionLogStatus } from '@/lib/workflows/executor/execution-state'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
const logger = createLogger('CopilotWorkflowClientFallback')
@@ -36,12 +50,75 @@ interface RaceWorkflowToolClientPickupParams {
/** When the call's deadline passes: past it the server does not take the call over. */
deadlineAt?: number
graceMs: number
+ /**
+ * How long to wait for a browser's run before settling the call still running (the frame's
+ * `waitTimeoutMs`); the browser's run goes on. Undefined waits until it settles.
+ */
+ waitTimeoutMs?: number
abortSignal?: AbortSignal
registry?: ResolvedSecretTraceRegistry
/** Runs the tool in-process; only invoked after the execution claim is won. */
runOnServer: (boundExecutionId: string) => Promise
}
+/** How often a lapsed wait re-reads the call for the browser's claim. */
+const CLIENT_CLAIM_POLL_MS = 5000
+
+/**
+ * Once `waitTimeoutMs` has passed, settles a browser-run workflow call as running in the
+ * background, the same detach a browser sends on `pagehide`: its waiter answers the call with
+ * the run's ids and the browser's run goes on, bound to the call, so a Stop still cancels it.
+ * A call no browser has claimed yet, or whose run has no log yet, is re-read until it does or the
+ * wait ends.
+ */
+async function detachClientRunAfterWait(params: {
+ toolCallId: string
+ workflowId?: string
+ waitTimeoutMs: number
+ signal: AbortSignal
+}): Promise {
+ const { toolCallId, workflowId, signal } = params
+ if (!workflowId) return
+ let delayMs = params.waitTimeoutMs
+ while (!signal.aborted) {
+ await interruptibleSleep(delayMs, signal)
+ delayMs = CLIENT_CLAIM_POLL_MS
+ if (signal.aborted) return
+ // A failed read is retried on the next poll.
+ const row = await getAsyncToolCall(toolCallId).catch(() => undefined)
+ if (row === undefined) continue
+ if (!row) return
+ // A call awaiting approval or pickup is re-read; one that settled or detached ends the watch.
+ if (row.status === ASYNC_TOOL_STATUS.pending) continue
+ if (row.status !== ASYNC_TOOL_STATUS.running) return
+ const executionId = getClaimedWorkflowExecutionId(row.claimedBy)
+ if (!executionId || row.clientWorkflowExecutionId !== executionId) continue
+ // The claim precedes the run's log: answer running only once the run is readable by its id
+ // and still going. A run that already ended is reported by its own settlement.
+ const logged = await getWorkflowExecutionLogStatus(executionId, workflowId).catch(
+ () => undefined
+ )
+ if (logged !== 'running') continue
+ const status = ASYNC_TOOL_CONFIRMATION_STATUS.background
+ const settled = await settleClientToolCall({
+ toolCallId,
+ status,
+ message: getWorkflowToolCompletionMessage(status),
+ data: createStructuralWorkflowToolCompletionData(status, workflowId, executionId),
+ executionId,
+ guard: { kind: 'open' },
+ }).catch((error) => {
+ logger.warn('Could not settle a lapsed browser workflow wait as running; retrying', {
+ toolCallId,
+ error: toError(error).message,
+ })
+ return undefined
+ })
+ // A write that failed is retried on the next poll; one another transition beat ends the watch.
+ if (settled !== undefined) return
+ }
+}
+
/**
* Wait for a browser to run a workflow tool call, and run it here if none does.
*
@@ -81,6 +158,23 @@ export async function raceWorkflowToolClientPickup(
abortSignal: clientWaitSignal,
registry,
})
+ if (params.waitTimeoutMs !== undefined) {
+ const stopDetach = new AbortController()
+ const stop = () => stopDetach.abort()
+ void clientWait.then(stop, stop)
+ detachClientRunAfterWait({
+ toolCallId,
+ workflowId,
+ waitTimeoutMs: params.waitTimeoutMs,
+ signal: AbortSignal.any([clientWaitSignal, stopDetach.signal]),
+ }).catch((error) => {
+ logger.warn('Could not settle a lapsed browser workflow wait as running', {
+ toolCallId,
+ workflowId,
+ error: toError(error).message,
+ })
+ })
+ }
// A caller-supplied timeout shorter than the grace must win, or the grace
// would outlive the wait it is supposed to bound.
diff --git a/apps/sim/lib/mothership/request/tools/workflow-client-settlement.integration.ts b/apps/sim/lib/mothership/request/tools/workflow-client-settlement.integration.ts
index f5406b717c3..5f35f02f68b 100644
--- a/apps/sim/lib/mothership/request/tools/workflow-client-settlement.integration.ts
+++ b/apps/sim/lib/mothership/request/tools/workflow-client-settlement.integration.ts
@@ -33,17 +33,23 @@ import { eq, inArray } from 'drizzle-orm'
import { closeRedisConnection, getRedisClient } from '@/lib/core/config/redis'
import { SIM_TOOL_EXECUTION_VERSION } from '@/lib/mothership/async-runs/lifecycle'
import {
+ claimToolExecution,
claimWorkflowToolExecution,
completeAsyncToolCall,
detachAsyncToolCall,
+ getDetachedWorkflowExecutions,
+ prepareWorkbenchAccess,
settleClientWorkflowToolExecution,
+ upsertAsyncToolCall,
} from '@/lib/mothership/async-runs/repository'
import { getToolConfirmation } from '@/lib/mothership/persistence/tool-confirm'
import { waitForWorkflowToolCompletion } from '@/lib/mothership/request/tools/client'
+import { raceWorkflowToolClientPickup } from '@/lib/mothership/request/tools/workflow-client-fallback'
import {
reportQueuedClientWorkflowTool,
reportSettledClientWorkflowTool,
} from '@/lib/mothership/request/tools/workflow-client-settlement'
+import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
/** Longer than the waiter's durable poll, far shorter than the hour it used to park for. */
@@ -88,6 +94,7 @@ describe.runIf(Boolean(redisUrl))('settled client-claimed workflow tools', () =>
const workflowId = generateId()
const chatId = generateId()
const runId = generateId()
+ const streamId = generateId()
const snapshotIds: string[] = []
beforeAll(async () => {
@@ -128,7 +135,7 @@ describe.runIf(Boolean(redisUrl))('settled client-claimed workflow tools', () =>
chatId,
userId,
workspaceId,
- streamId: generateId(),
+ streamId,
toolExecutionVersion: SIM_TOOL_EXECUTION_VERSION,
status: 'paused_waiting_for_tool',
requestContext: { source: 'headless_lifecycle' },
@@ -153,7 +160,7 @@ describe.runIf(Boolean(redisUrl))('settled client-claimed workflow tools', () =>
async function executionLog(
toolCallId: string,
executionId: string,
- status: 'completed' | 'failed' | 'cancelled' | 'pending'
+ status: 'completed' | 'failed' | 'cancelled' | 'pending' | 'running'
) {
const snapshotId = generateId()
snapshotIds.push(snapshotId)
@@ -490,4 +497,219 @@ describe.runIf(Boolean(redisUrl))('settled client-claimed workflow tools', () =>
})
expect(await publishedConfirmation(toolCallId)).toBeNull()
})
+
+ it("settles a browser run that outlasts the worker's wait as running, bound to the call", async () => {
+ const { toolCallId, executionId } = await claimed()
+ // The execute route's log of the browser's run, still going: what the worker waits on.
+ await executionLog(toolCallId, executionId, 'running')
+ const runOnServer = vi.fn()
+
+ const outcome = await raceWorkflowToolClientPickup({
+ toolCallId,
+ workflowId,
+ timeoutMs: WAIT_MS,
+ graceMs: WAIT_MS,
+ waitTimeoutMs: 500,
+ runOnServer,
+ })
+
+ // The worker gets the run's ids and waits on it in slices; the browser's run goes on.
+ expect(outcome).toMatchObject({
+ winner: 'client',
+ completion: {
+ status: 'background',
+ data: { workflowId, executionId, status: 'running' },
+ },
+ })
+ expect(runOnServer).not.toHaveBeenCalled()
+ expect(await publishedConfirmation(toolCallId)).toMatchObject({
+ status: 'background',
+ executionId,
+ })
+ // Still bound to the call and unsettled, so a Stop's open-client-run read still cancels it.
+ expect(await toolRow(toolCallId)).toMatchObject({
+ status: 'delivered',
+ claimedBy: `workflow:${executionId}`,
+ clientWorkflowExecutionId: executionId,
+ executionSettledAt: null,
+ })
+ })
+
+ it("names the runs this run's calls started and left running, for a Stop to cancel", async () => {
+ const insertCall = async (executableName: string, args: unknown, result: unknown) => {
+ const toolCallId = generateId()
+ await db.insert(copilotAsyncToolCalls).values({
+ runId,
+ toolCallId,
+ toolName: executableName,
+ executableName,
+ args: args as Record,
+ result: result as Record,
+ status: 'completed',
+ })
+ return toolCallId
+ }
+ const record = (runIdValue: string, status: string) =>
+ JSON.stringify({ runId: runIdValue, workflowId, status })
+ const detachedCli = generateId()
+ await executionLog(
+ await insertCall(
+ 'sim_cli',
+ { request: { invocation: { kind: 'cli', argv: ['workflows', 'run', workflowId] } } },
+ { exitCode: 4, stdout: record(detachedCli, 'running'), stderr: '' }
+ ),
+ detachedCli,
+ 'running'
+ )
+ // A wait on a run proves nothing about who started it.
+ await insertCall(
+ 'sim_cli',
+ { request: { invocation: { kind: 'cli', argv: ['workflows', 'runs', 'wait', 'other'] } } },
+ { exitCode: 4, stdout: record(generateId(), 'running'), stderr: '' }
+ )
+ const detachedTool = generateId()
+ await executionLog(
+ await insertCall(
+ 'run_workflow',
+ { workflowId },
+ { status: 'running', executionId: detachedTool, workflowId }
+ ),
+ detachedTool,
+ 'running'
+ )
+ // Answered running but not yet logged: it may still be starting, and a Stop must reach it.
+ const startingTool = generateId()
+ await insertCall(
+ 'run_from_block',
+ { workflowId },
+ { status: 'running', executionId: startingTool, workflowId }
+ )
+ await insertCall('run_block', { workflowId }, { success: true, executionId: generateId() })
+
+ expect((await getDetachedWorkflowExecutions(streamId, userId)).sort()).toEqual(
+ [detachedCli, detachedTool, startingTool].sort()
+ )
+ })
+
+ it.each([
+ ['pending (paused at a human approval)', 'pending'],
+ ['completed', 'completed'],
+ ['failed', 'failed'],
+ ['cancelled', 'cancelled'],
+ ] as const)(
+ 'leaves a detached run whose log is %s for a Stop to skip',
+ async (_label, logStatus) => {
+ const detachedCli = generateId()
+ const detachedTool = generateId()
+ const cliCall = generateId()
+ const toolCall = generateId()
+ await db.insert(copilotAsyncToolCalls).values([
+ {
+ runId,
+ toolCallId: cliCall,
+ toolName: 'sim_cli',
+ executableName: 'sim_cli',
+ args: {
+ request: { invocation: { kind: 'cli', argv: ['workflows', 'run', workflowId] } },
+ },
+ result: {
+ exitCode: 4,
+ stdout: JSON.stringify({ runId: detachedCli, workflowId, status: 'running' }),
+ stderr: '',
+ },
+ status: 'completed',
+ },
+ {
+ runId,
+ toolCallId: toolCall,
+ toolName: 'run_workflow',
+ executableName: 'run_workflow',
+ args: { workflowId },
+ result: { status: 'running', executionId: detachedTool, workflowId },
+ status: 'completed',
+ },
+ ])
+ // The run went on past its call and then settled, or paused at a human approval: a paused
+ // run's resume subscribes under the same id, so a cancel flag would fail it.
+ await executionLog(cliCall, detachedCli, logStatus)
+ await executionLog(toolCall, detachedTool, logStatus)
+
+ const cancellable = await getDetachedWorkflowExecutions(streamId, userId)
+ expect(cancellable).not.toContain(detachedCli)
+ expect(cancellable).not.toContain(detachedTool)
+ }
+ )
+
+ /**
+ * An earlier turn of a new chat whose `earlierTool` call is still executing under a live lease,
+ * as a workflow tool answered still running is while its run goes on, and a later turn whose
+ * own Sim call asks for the chat's workbench.
+ */
+ async function laterTurnWorkbenchAccess(earlierTool: string) {
+ const turnChatId = generateId()
+ await db.insert(copilotChats).values({
+ id: turnChatId,
+ userId,
+ workspaceId,
+ type: 'mothership',
+ conversationId: generateId(),
+ })
+ try {
+ const turn = async (startedAt: Date, toolName: string) => {
+ const turnRunId = generateId()
+ await db.insert(copilotRuns).values({
+ id: turnRunId,
+ executionId: generateId(),
+ chatId: turnChatId,
+ userId,
+ workspaceId,
+ streamId: generateId(),
+ toolExecutionVersion: SIM_TOOL_EXECUTION_VERSION,
+ status: 'active',
+ startedAt,
+ requestContext: { source: 'headless_lifecycle' },
+ })
+ const owner = {
+ toolCallId: generateId(),
+ runId: turnRunId,
+ userId,
+ ownerToken: generateId(),
+ }
+ await upsertAsyncToolCall({ runId: turnRunId, toolCallId: owner.toolCallId, toolName })
+ expect(await claimToolExecution(owner)).toMatchObject({ outcome: 'claimed' })
+ return owner
+ }
+ const earlier = await turn(new Date(Date.now() - 60_000), earlierTool)
+ await completeAsyncToolCall({
+ toolCallId: earlier.toolCallId,
+ status: 'completed',
+ result: { status: 'running', executionId: generateId(), workflowId },
+ })
+ // The earlier turn answered and ended; its call's execution still holds the run.
+ await db
+ .update(copilotRuns)
+ .set({ status: 'complete', toolAdmissionClosedAt: new Date() })
+ .where(eq(copilotRuns.id, earlier.runId))
+ const later = await turn(new Date(), 'function_execute')
+ return await prepareWorkbenchAccess({
+ ...later,
+ sessionKey: chatSandboxSessionKey(turnChatId),
+ })
+ } finally {
+ await db.delete(copilotChats).where(eq(copilotChats.id, turnChatId))
+ }
+ }
+
+ it("opens the workbench to a later turn while an earlier turn's workflow tool run goes on", async () => {
+ expect(await laterTurnWorkbenchAccess('run_workflow')).toEqual({
+ handlersPending: false,
+ processes: [],
+ })
+ })
+
+ it("keeps the workbench closed while an earlier turn's other tool is still executing", async () => {
+ expect(await laterTurnWorkbenchAccess('function_execute')).toMatchObject({
+ handlersPending: true,
+ })
+ })
})
diff --git a/apps/sim/lib/mothership/request/types.ts b/apps/sim/lib/mothership/request/types.ts
index d96ed2ea5b8..6792e5b560e 100644
--- a/apps/sim/lib/mothership/request/types.ts
+++ b/apps/sim/lib/mothership/request/types.ts
@@ -63,6 +63,11 @@ export interface ToolCallState {
* the worker named none.
*/
deadlineAt?: number
+ /**
+ * For a workflow tool, how long to wait for its run to settle before settling the call still
+ * running (the frame's `waitTimeoutMs`). Undefined holds the call until the run settles.
+ */
+ waitTimeoutMs?: number
/**
* For a subagent-scoped tool call, the invoking subagent's channel id (its
* outer tool_use id, = event.scope.parentToolCallId). Captured at dispatch so
diff --git a/apps/sim/lib/mothership/tool-executor/types.ts b/apps/sim/lib/mothership/tool-executor/types.ts
index d0f5c40d4a1..ab6e610b131 100644
--- a/apps/sim/lib/mothership/tool-executor/types.ts
+++ b/apps/sim/lib/mothership/tool-executor/types.ts
@@ -35,6 +35,11 @@ export interface ToolExecutionContext {
* into the principal by `requireTrustedCopilotExecutionContext`.
*/
boundWorkflowExecutionId?: string
+ /**
+ * How long a workflow tool waits for its run before answering it still running and letting it
+ * continue (the call frame's `waitTimeoutMs`). Undefined waits until the run settles.
+ */
+ workflowRunWaitMs?: number
billingAttribution?: BillingAttributionSnapshot
copilotToolExecution?: boolean
/** In-process authoring admission; never supplied by model arguments or checkpoint JSON. */
diff --git a/apps/sim/lib/mothership/tools/client-executed-tools.ts b/apps/sim/lib/mothership/tools/client-executed-tools.ts
index a0cc8af4382..8d4fe8555ca 100644
--- a/apps/sim/lib/mothership/tools/client-executed-tools.ts
+++ b/apps/sim/lib/mothership/tools/client-executed-tools.ts
@@ -1,14 +1,15 @@
import { isDesktopToolCall } from '@/lib/mothership/tools/desktop-tools'
-const WORKFLOW_TOOL_NAMES = new Set([
+/** The tools that run a workflow, in the browser or on the server. */
+export const WORKFLOW_TOOL_NAMES = [
'run_workflow',
'run_workflow_until_block',
'run_block',
'run_from_block',
-])
+] as const
export function isWorkflowToolName(name: string): boolean {
- return WORKFLOW_TOOL_NAMES.has(name)
+ return (WORKFLOW_TOOL_NAMES as readonly string[]).includes(name)
}
/**
diff --git a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts
index 658e3900d07..d06672353ef 100644
--- a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts
+++ b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts
@@ -1,7 +1,8 @@
import { executeWorkflowMock } from '@sim/testing/mocks/execute-workflow.mock'
import { telemetryMock } from '@sim/testing/mocks/telemetry.mock'
import { workflowsOrchestrationMock } from '@sim/testing/mocks/workflows-orchestration.mock'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { observeSandboxExecution } from '@/lib/execution/remote-sandbox/observer'
import { WorkflowRunAlreadyTerminalError } from '@/lib/execution/workflow-run-already-terminal-error'
import type { ExecutionContext } from '@/lib/mothership/request/types'
import { executeTool } from '@/lib/mothership/tool-executor/executor'
@@ -14,9 +15,14 @@ const { mocks } = vi.hoisted(() => ({
executeWorkflowUseCase: vi.fn(),
hasExecutionResult: vi.fn(),
readAttemptedExecutionId: vi.fn(),
+ logStatus: vi.fn(),
},
}))
+vi.mock('@/lib/workflows/executor/execution-state', () => ({
+ getWorkflowExecutionLogStatus: mocks.logStatus,
+}))
+
vi.mock('@/lib/mothership/application/execute-workflow-use-case', () => ({
executeCopilotWorkflowUseCase: mocks.executeWorkflowUseCase,
messageForCopilotWorkflowError: (error: unknown, fallback = 'Workflow operation failed') =>
@@ -427,3 +433,185 @@ describe('workflow mutation Copilot adapters', () => {
expect((await run()).effect).toEqual(effect)
})
})
+
+describe('a workflow tool run past its wait', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ mocks.hasExecutionResult.mockReturnValue(false)
+ mocks.readAttemptedExecutionId.mockReturnValue(undefined)
+ })
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ /** A run the test settles itself, and the lifecycle it was started with. */
+ function pendingRun(dispatch = true) {
+ let settle: (value: unknown) => void = () => {}
+ const started = new Promise((resolve) => {
+ settle = resolve
+ })
+ mocks.executeWorkflowUseCase.mockReturnValueOnce(started)
+ // The run's log exists once it started; a run still in preflight has none.
+ mocks.logStatus.mockResolvedValue(dispatch ? 'running' : undefined)
+ return {
+ settle,
+ lifecycle: () =>
+ mocks.executeWorkflowUseCase.mock.calls.at(-1)?.[2].lifecycle as {
+ executionId?: string
+ },
+ }
+ }
+
+ it('answers still running by the id it gave the run, and holds the run until it settles', async () => {
+ const run = pendingRun()
+ const held: Promise