Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
68b256c
feat(library): How do you build an AI Slackbot without code? (#8371)
icecrasher321 Sep 28, 2026
b20af74
feat(library): AI Agent Workflow Builders for Multi-Step Tasks: 6-Pla…
icecrasher321 Sep 28, 2026
ec8cc53
docs(library): update ai-agent-orchestration-frameworks-explained (#8…
icecrasher321 Sep 28, 2026
2f85e5c
docs(library): update best-ai-agents-for-slack (#8375)
icecrasher321 Sep 28, 2026
9557c37
docs(library): update ai-agents-for-marketing-automation (#8374)
icecrasher321 Sep 28, 2026
1321ee9
improvement(tables): bump rows version once at commit and serialize s…
waleedlatif1 Sep 28, 2026
c979b63
docs(library): update best-no-code-ai-agent-builders-2026 (#8377)
icecrasher321 Sep 28, 2026
9cf2d31
feat(search): read Drive documents and Gmail conversations (#8352)
waleedlatif1 Sep 28, 2026
d4b1bfd
feat(emails): introduce Desktop, CLI, and MCP newsletter (#8379)
waleedlatif1 Sep 28, 2026
536e9f6
feat(otter): add Otter.ai integration with triggers and knowledge bas…
waleedlatif1 Sep 28, 2026
2827611
feat(docs): OAuth scopes section on each integration page (#8382)
waleedlatif1 Sep 28, 2026
2c614ae
fix(providers): retry transient failures on OpenAI Responses and Gemi…
waleedlatif1 Sep 28, 2026
caf11b1
feat(providers): add Claude Sonnet 5.5 and make it the default model …
waleedlatif1 Sep 28, 2026
35efc8a
fix(desktop): keep built-in browser logins across restarts, faster ta…
TheodoreSpeaks Sep 28, 2026
fc78ff0
fix(sim-setup): report Compose db/redis state in status (#8370)
AYANscyy2 Sep 28, 2026
d12e31c
fix(search): preserve Gmail reads when replies disappear (#8388)
waleedlatif1 Sep 28, 2026
3ab8ed0
fix(chats): align organization shift-click selection with workspaces …
waleedlatif1 Sep 28, 2026
59ee92b
improvement(chat): reuse shared overflow fades on scroll edges (#8390)
waleedlatif1 Sep 28, 2026
f00f5c5
docs(library): update sim-vs-dify-open-source-ai-workspace-vs-llm-app…
icecrasher321 Sep 28, 2026
a2af0e9
feat(search): add HubSpot CRM member search (#8387)
waleedlatif1 Sep 28, 2026
07f3297
feat(providers): add Kie provider for Claude, GPT, Grok, and Kimi mod…
waleedlatif1 Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
178 changes: 178 additions & 0 deletions apps/desktop/e2e/session-cookies.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }
>()

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('<!doctype html><title>Signed in</title>')
return
}
if (path === '/account') {
lastCookieHeader = request.headers.cookie ?? ''
response.writeHead(200, { 'Content-Type': 'text/html' })
response.end('<!doctype html><title>Account</title>')
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('<!doctype html><title>Sim fixture</title><h1>Session cookie fixture</h1>')
})
await new Promise<void>((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<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
)
})

async function launch(): Promise<ElectronApplication> {
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<string[]> {
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'])
})
})
51 changes: 51 additions & 0 deletions apps/desktop/src/main/browser-agent/session-cookies.test.ts
Original file line number Diff line number Diff line change
@@ -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}`)
})
})
60 changes: 60 additions & 0 deletions apps/desktop/src/main/browser-agent/session-cookies.ts
Original file line number Diff line number Diff line change
@@ -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) } })
})
}
11 changes: 9 additions & 2 deletions apps/desktop/src/main/browser-agent/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1327,17 +1331,19 @@ export async function listAgentCookieSignals(): Promise<BrowserCookieSignal[]> {
* 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
Expand Down Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/test/electron-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
}
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/components/docs-layout/docs-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]) => (
<ChipLink key={href} href={href} onNavigate={() => setOpen(false)}>
Expand Down
Loading
Loading