diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 54659595287..4c816306b99 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -129,6 +129,24 @@ jobs: if-no-files-found: warn retention-days: 14 + - name: Verify Google document reads over real HTTP + if: matrix.provision == 'push' + working-directory: apps/sim + env: + NEXT_PUBLIC_APP_URL: http://127.0.0.1:3040 + NEXT_PUBLIC_FORCE_HOSTED: 'false' + SEARCH_GOOGLE_CONTENT_REPORT_PATH: ${{ runner.temp }}/search-google-content.json + run: bun scripts/test-search-google-content-e2e.ts + + - name: Upload Google content acceptance report + if: failure() && matrix.provision == 'push' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: search-google-content + path: ${{ runner.temp }}/search-google-content.json + if-no-files-found: ignore + retention-days: 7 + - name: Verify SCIM and administration over real HTTP working-directory: apps/sim env: diff --git a/apps/desktop/e2e/session-cookies.spec.ts b/apps/desktop/e2e/session-cookies.spec.ts new file mode 100644 index 00000000000..7c85b99e63f --- /dev/null +++ b/apps/desktop/e2e/session-cookies.spec.ts @@ -0,0 +1,178 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { createServer, type Server } 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, test } from '@playwright/test' +import type { SimDesktopApi } from '@sim/desktop-bridge' + +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) +const SCOPE = 'session-cookies-fixture' + +/** + * Electron drops every expiry-less cookie on quit, so a site that keeps its + * login in a session cookie signed the user out of the built-in browser on + * every restart. This drives a real quit and relaunch against one profile. + */ +test.describe('built-in browser session cookies', () => { + let server: Server + let origin: string + let site: string + let userData: string + let app: ElectronApplication | undefined + let serial = 0 + let lastCookieHeader: string | undefined + const calls = new Map< + string, + { chatId: string; toolName: string; args: Record } + >() + + test.beforeAll(async () => { + server = createServer(async (request, response) => { + const path = new URL(request.url ?? '/', 'http://localhost').pathname + if (path === '/api/auth/get-session') { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end( + JSON.stringify({ user: { id: 'fixture-user' }, session: { id: 'fixture-session' } }) + ) + return + } + if (path === '/api/desktop/tool/authorize') { + let body = '' + for await (const chunk of request) body += chunk.toString() + const call = calls.get(JSON.parse(body).toolCallId) + response.writeHead(call ? 200 : 403, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(call ?? {})) + return + } + if (path.startsWith('/api/')) { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end('{}') + return + } + if (path === '/sign-in') { + // A login redirect that sets a short-lived cookie the next hop deletes. + response.writeHead(302, { + 'Set-Cookie': 'oauth_state=pending; HttpOnly; Path=/', + Location: '/signed-in', + }) + response.end() + return + } + if (path === '/signed-in') { + response.writeHead(200, { + 'Content-Type': 'text/html', + 'Set-Cookie': [ + 'oauth_state=; Path=/; Max-Age=0', + 'login=fixture; HttpOnly; SameSite=Lax; Path=/', + 'remember=1; Path=/; Max-Age=3600', + ], + }) + response.end('Signed in') + return + } + if (path === '/account') { + lastCookieHeader = request.headers.cookie ?? '' + response.writeHead(200, { 'Content-Type': 'text/html' }) + response.end('Account') + return + } + if (request.headers.host?.startsWith('localhost')) { + // Favicon and other stray site requests must not set the app session cookie on the site. + response.writeHead(404) + response.end() + return + } + response.writeHead(200, { + 'Content-Type': 'text/html', + 'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/', + }) + response.end('Sim fixture

Session cookie 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}` + /** Pages outside the app origin browse in the built-in browser's own partition. */ + site = `http://localhost:${address.port}` + }) + + test.beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'sim-session-cookies-e2e-')) + }) + + test.afterEach(async () => { + await app?.close() + app = undefined + rmSync(userData, { recursive: true, force: true }) + calls.clear() + }) + + test.afterAll(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + }) + + async function launch(): Promise { + const launched = 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 }, + }) + const host = await launched.firstWindow() + await expect(host.getByRole('heading')).toHaveText('Session cookie fixture') + await host.evaluate(async (scope) => { + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + await api.browserAgent.activateScope(scope) + const updateBounds = () => + api.browserAgent.setPanelBounds( + { x: 0, y: 80, width: innerWidth, height: innerHeight - 80 }, + null, + scope + ) + updateBounds() + setInterval(updateBounds, 200) + }, SCOPE) + return launched + } + + async function navigate(target: ElectronApplication, url: string) { + const id = `fixture-${++serial}` + calls.set(id, { chatId: SCOPE, toolName: 'browser_open_url', args: { url } }) + const host = await target.firstWindow() + const result = await host.evaluate( + async ({ id, url, scope }) => { + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + return api.browserAgent.executeTool(id, 'browser_open_url', { url }, scope) + }, + { id, url, scope: SCOPE } + ) + expect(result.ok, result.error).toBe(true) + } + + async function cookiesSentToSite(target: ElectronApplication): Promise { + lastCookieHeader = undefined + await navigate(target, `${site}/account`) + await expect.poll(() => lastCookieHeader).not.toBeUndefined() + return (lastCookieHeader ?? '') + .split(';') + .map((pair) => pair.trim()) + .filter(Boolean) + .sort() + } + + test('keeps a session-cookie login across a restart without reviving deleted cookies', async () => { + app = await launch() + await navigate(app, `${site}/sign-in`) + expect(await cookiesSentToSite(app)).toEqual(['login=fixture', 'remember=1']) + + await app.evaluate(({ session }) => + session.fromPartition('persist:sim-browser-agent').cookies.flushStore() + ) + await app.close() + + app = await launch() + expect(await cookiesSentToSite(app)).toEqual(['login=fixture', 'remember=1']) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/session-cookies.test.ts b/apps/desktop/src/main/browser-agent/session-cookies.test.ts new file mode 100644 index 00000000000..c20381eb983 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/session-cookies.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + SESSION_COOKIE_LIFETIME_SECONDS, + withSessionCookieLifetime, + withSessionCookieMaxAge, +} from '@/main/browser-agent/session-cookies' + +const NOW = 1_800_000_000 +const MAX_AGE = `Max-Age=${SESSION_COOKIE_LIFETIME_SECONDS}` + +describe('withSessionCookieLifetime', () => { + it('gives an expiry-less cookie the bounded lifetime', () => { + expect(withSessionCookieLifetime({ url: 'https://example.com/', name: 'a' }, NOW)).toEqual({ + url: 'https://example.com/', + name: 'a', + expirationDate: NOW + SESSION_COOKIE_LIFETIME_SECONDS, + }) + }) + + it('leaves a cookie that already expires untouched', () => { + const cookie = { url: 'https://example.com/', name: 'a', expirationDate: NOW + 60 } + expect(withSessionCookieLifetime(cookie, NOW)).toBe(cookie) + }) +}) + +describe('withSessionCookieMaxAge', () => { + it('adds Max-Age to a session cookie', () => { + expect(withSessionCookieMaxAge('sid=abc; Path=/; Secure; HttpOnly')).toBe( + `sid=abc; Path=/; Secure; HttpOnly; ${MAX_AGE}` + ) + expect(withSessionCookieMaxAge('sid=abc')).toBe(`sid=abc; ${MAX_AGE}`) + }) + + it('leaves cookies with an expiry or a deletion untouched', () => { + for (const value of [ + 'sid=abc; Expires=Wed, 21 Oct 2030 07:28:00 GMT', + 'sid=abc; max-age=60', + 'sid=; Path=/; Max-Age=0', + 'sid=abc;EXPIRES=Thu, 01 Jan 1970 00:00:00 GMT', + ]) { + expect(withSessionCookieMaxAge(value)).toBe(value) + } + }) + + it('reads attributes only, never the cookie value', () => { + expect(withSessionCookieMaxAge('max-age=1')).toBe(`max-age=1; ${MAX_AGE}`) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/session-cookies.ts b/apps/desktop/src/main/browser-agent/session-cookies.ts new file mode 100644 index 00000000000..b46853d941d --- /dev/null +++ b/apps/desktop/src/main/browser-agent/session-cookies.ts @@ -0,0 +1,60 @@ +import type { CookiesSetDetails, Session } from 'electron' + +/** + * Session cookies in the browser partition get this lifetime instead of dying + * with the process. + * + * Electron hard-codes Chromium's `persist_session_cookies` and + * `restore_old_session_cookies` to false, so a `persist:` partition still drops + * every expiry-less cookie on quit — which signs the user out of any site that + * keeps its login in one, whether it was imported from Chrome or set here. + * Chrome keeps them across restarts under "continue where you left off"; this + * matches that, bounded. The site re-sending the cookie renews the window, so + * only a login left untouched for this long lapses. + */ +export const SESSION_COOKIE_LIFETIME_SECONDS = 30 * 24 * 60 * 60 + +/** Gives an expiry-less cookie the bounded lifetime; cookies that already expire are unchanged. */ +export function withSessionCookieLifetime( + cookie: CookiesSetDetails, + nowSeconds: number +): CookiesSetDetails { + if (cookie.expirationDate !== undefined) return cookie + return { ...cookie, expirationDate: nowSeconds + SESSION_COOKIE_LIFETIME_SECONDS } +} + +/** + * Adds `Max-Age` to a `Set-Cookie` value that has neither `Expires` nor + * `Max-Age`. Deletions carry one of the two, so they pass through untouched. + */ +export function withSessionCookieMaxAge(setCookie: string): string { + const attributes = setCookie.split(';').slice(1) + const expires = attributes.some((attribute) => { + const name = attribute.split('=', 1)[0].trim().toLowerCase() + return name === 'expires' || name === 'max-age' + }) + return expires ? setCookie : `${setCookie}; Max-Age=${SESSION_COOKIE_LIFETIME_SECONDS}` +} + +/** + * Makes session cookies a site sets over HTTP survive an app restart. + * + * The rewrite happens on the response headers, before Chromium stores the + * cookie, so it is created persistent in the same order as every other cookie + * write — a later deletion from the site still wins. Rewriting stored cookies + * after the fact would race exactly that: login redirects set and clear + * short-lived cookies milliseconds apart. Cookies set from page script + * (`document.cookie`) are not covered; they cannot be `HttpOnly`, so session + * logins rarely live in them. + */ +export function keepSessionCookiesAcrossRestarts(ses: Session): void { + ses.webRequest.onHeadersReceived((details, callback) => { + const headers = details.responseHeaders + const key = headers && Object.keys(headers).find((name) => name.toLowerCase() === 'set-cookie') + if (!headers || !key) { + callback({}) + return + } + callback({ responseHeaders: { ...headers, [key]: headers[key].map(withSessionCookieMaxAge) } }) + }) +} diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index d1643b12dbe..1b7de802598 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -72,6 +72,10 @@ import { registerAgentWebContents, } from '@/main/browser-agent/registry' import { handleBrowserRequest } from '@/main/browser-agent/request-policy' +import { + keepSessionCookiesAcrossRestarts, + withSessionCookieLifetime, +} from '@/main/browser-agent/session-cookies' import { clearHostVerdictCache } from '@/main/browser-agent/url-guard' import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store' import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads' @@ -1327,17 +1331,19 @@ export async function listAgentCookieSignals(): Promise { * here and is counted rather than being quietly relaxed. * * Failures are per-cookie: one rejected cookie must not cost the user the - * rest. Nothing about a cookie is logged. + * rest. Nothing about a cookie is logged. Session cookies get the bounded + * lifetime from {@link withSessionCookieLifetime} so they survive a restart. */ export async function importAgentCookies( cookies: CookiesSetDetails[] ): Promise<{ imported: number; failed: number }> { const jar = electronSession.fromPartition(AGENT_PARTITION).cookies + const nowSeconds = Date.now() / 1000 let imported = 0 let failed = 0 for (const cookie of cookies) { try { - await jar.set(cookie) + await jar.set(withSessionCookieLifetime(cookie, nowSeconds)) imported += 1 } catch { failed += 1 @@ -1594,6 +1600,7 @@ const browserPermissions: BrowserPermissionHandlers = { function configureAgentPartition(ses: Session): void { if (configuredPartitions.has(ses)) return configuredPartitions.add(ses) + keepSessionCookiesAcrossRestarts(ses) ses.setPermissionRequestHandler(browserPermissions.request) ses.setPermissionCheckHandler(browserPermissions.check) ses.webRequest.onBeforeRequest((details, callback) => { diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index d929be7057f..822eab9e3ad 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -241,7 +241,7 @@ function createWebContentsMock() { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setUserAgent: vi.fn(), - webRequest: { onBeforeRequest: vi.fn() }, + webRequest: { onBeforeRequest: vi.fn(), onHeadersReceived: vi.fn() }, on: vi.fn(), }, } diff --git a/apps/docs/components/docs-layout/docs-sidebar.tsx b/apps/docs/components/docs-layout/docs-sidebar.tsx index 24e33677746..c9d062c2e09 100644 --- a/apps/docs/components/docs-layout/docs-sidebar.tsx +++ b/apps/docs/components/docs-layout/docs-sidebar.tsx @@ -103,7 +103,7 @@ export function DocsSidebar() { ['Docs', '/introduction'], ['API Reference', '/api-reference/getting-started'], ['CLI', '/cli'], - ['MCP', '/mcp'], + ['MCP', '/mcp/overview'], ['Academy', '/academy'], ].map(([label, href]) => ( setOpen(false)}> diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index eaa8f3b1b9a..53cb7eb66b8 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -4319,6 +4319,35 @@ export const ZaiIcon = (props: SVGProps) => ( ) +export function KieIcon(props: SVGProps) { + const id = useId() + const gradientId = `kie_gradient_${id}` + + return ( + + Kie + + + + + + + + + + ) +} + export const KimiIcon = (props: SVGProps) => ( Kimi @@ -9947,3 +9976,39 @@ export function TypeSafeIcon(props: SVGProps) { ) } + +/** Otter.ai's wordmark, in its brand blue. */ +export function OtterIcon(props: SVGProps) { + return ( + + + + + + + + ) +} diff --git a/apps/docs/components/navbar/navbar.tsx b/apps/docs/components/navbar/navbar.tsx index 4610c6d392f..c82d82421b0 100644 --- a/apps/docs/components/navbar/navbar.tsx +++ b/apps/docs/components/navbar/navbar.tsx @@ -46,7 +46,7 @@ const NAV_TABS = [ }, { label: 'MCP', - href: '/mcp', + href: '/mcp/overview', match: (p: string) => isInSection(p, 'mcp'), external: false, }, diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index b01ec77b994..ab68c3ddf52 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -200,6 +200,7 @@ import { OktaIcon, OnePasswordIcon, OpenAIIcon, + OtterIcon, OutlookIcon, PackageSearchIcon, PagerDutyIcon, @@ -543,6 +544,7 @@ export const blockTypeToIconMap: Record = { onedrive: MicrosoftOneDriveIcon, onepassword: OnePasswordIcon, openai: OpenAIIcon, + otter: OtterIcon, outlook: OutlookIcon, pagerduty: PagerDutyIcon, parallel_ai: ParallelIcon, diff --git a/apps/docs/content/docs/agents/index.mdx b/apps/docs/content/docs/agents/index.mdx index 7ab7f7afc70..2fe27a3073c 100644 --- a/apps/docs/content/docs/agents/index.mdx +++ b/apps/docs/content/docs/agents/index.mdx @@ -17,7 +17,7 @@ The example throughout is an agent that scores inbound sales leads. ## The Agent block -You set up the reasoning step by giving the Agent block a **model** and a **prompt**. The model is the LLM that powers it; you pick one from the available providers, and the default is `claude-sonnet-5`. The prompt is a system message that defines who the agent is and how it should behave, plus a user message that carries the input, usually a reference like ``. +You set up the reasoning step by giving the Agent block a **model** and a **prompt**. The model is the LLM that powers it; you pick one from the available providers, and the default is `claude-sonnet-5-5`. The prompt is a system message that defines who the agent is and how it should behave, plus a user message that carries the input, usually a reference like ``. When it runs, the Agent block reasons, calls any tools it needs, and stores its result under its own name. By default that result is free text in `content`, read by a later block as ``, alongside run details like the model used, token counts, tool calls, and cost. Every setting and output field is in the [Agent block reference](/workflows/blocks/agent). diff --git a/apps/docs/content/docs/agents/mcp.mdx b/apps/docs/content/docs/agents/mcp.mdx index b5f5adea4a4..60cce667fe1 100644 --- a/apps/docs/content/docs/agents/mcp.mdx +++ b/apps/docs/content/docs/agents/mcp.mdx @@ -9,6 +9,11 @@ import { Callout } from 'fumadocs-ui/components/callout' The Model Context Protocol ([MCP](https://modelcontextprotocol.com/)) is an open standard for connecting AI to external tools and data. Add an MCP server to your workspace and its tools become available to your agents — a way to integrate services Sim doesn't have a built-in integration for. + + To use your Sim workspace from Claude, Codex, or Cursor, connect the + [Sim MCP server](/mcp/overview). + + ## Adding an MCP Server as a Tool MCP servers provide collections of tools that your agents can use. diff --git a/apps/docs/content/docs/getting-started/index.mdx b/apps/docs/content/docs/getting-started/index.mdx index 99737fa5f73..4156a12b51b 100644 --- a/apps/docs/content/docs/getting-started/index.mdx +++ b/apps/docs/content/docs/getting-started/index.mdx @@ -28,7 +28,7 @@ Build a people research agent in 10 minutes. It takes a name through a chat inte - **System**: "You are a people research agent. When given a person's name, use your search tools to find their location, profession, educational background, and other relevant details." - **User**: insert `` so the agent reads whatever the chat receives. - Leave the **Model** on the default (`claude-sonnet-5`), or pick any other. + Leave the **Model** on the default (`claude-sonnet-5-5`), or pick any other.