Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ jobs:
bunx vitest run
scripts/retired-columns.postgres.test.ts
scripts/connector-sync-schedule-precision.postgres.test.ts
scripts/database-failure-classification.postgres.test.ts

- name: Verify OAuth lifecycle and SCIM membership guards in PostgreSQL
working-directory: apps/sim
Expand Down Expand Up @@ -267,6 +268,7 @@ jobs:
lib/knowledge/__integration__/search-source-pagination.integration.ts
lib/knowledge/__integration__/search-reference-batching.integration.ts
lib/knowledge/__integration__/embedding-insert-batches.integration.ts
lib/knowledge/__integration__/processing-lock-scope.integration.ts
lib/knowledge/__integration__/connector-lifecycle-locks.integration.ts
lib/knowledge/__integration__/connector-deferral.integration.ts
lib/knowledge/__integration__/stored-document-recovery.integration.ts
Expand All @@ -275,10 +277,13 @@ jobs:
lib/knowledge/__integration__/listing-continuation.integration.ts
lib/knowledge/__integration__/member-scope-renewal.integration.ts
lib/knowledge/__integration__/member-document-lifecycle.integration.ts
lib/knowledge/__integration__/connector-lease-pages.integration.ts
lib/knowledge/__integration__/slack-empty-threads.integration.ts
lib/knowledge/__integration__/kb-block-search.integration.ts
lib/knowledge/__integration__/gitlab-workspace.integration.ts
lib/knowledge/__integration__/unfilled-projection-source.integration.ts
lib/knowledge/__integration__/knowledge-projection.integration.ts
lib/knowledge/__integration__/async-projection-processing.integration.ts
lib/knowledge/__integration__/purged-detach-reservation.integration.ts
lib/core/outbox/service.integration.ts
lib/knowledge/__integration__/connector-upload.integration.ts
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ const PAGES: Record<string, string> = {
'/login': '<!doctype html><html><body><h1 id="login">fixture-login</h1></body></html>',
}

/** `User-Agent` of every request the fixture origin has served, in arrival order. */
const requestUserAgents: string[] = []

function startFixtureServer(): Promise<{ server: Server; origin: string }> {
return new Promise((resolvePromise) => {
const server = createServer((request, response) => {
requestUserAgents.push(request.headers['user-agent'] ?? '')
const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
const sessionCookie = request.headers.cookie
?.split(';')
Expand Down Expand Up @@ -85,6 +89,21 @@ test.describe('desktop shell smoke', () => {
expect(window.url()).toBe(`${origin}/home`)
})

test('presents one stock Chrome user agent on every request from the first load', async () => {
requestUserAgents.length = 0
app = await launchApp(origin)
const window = await app.firstWindow()
await expect(window.locator('#app')).toHaveText('fixture-app')
await window.evaluate(() => fetch('/home').then((response) => response.text()))

const pageUserAgent = await window.evaluate(() => navigator.userAgent)
expect(pageUserAgent).toMatch(
/^Mozilla\/5\.0 \(.+\) AppleWebKit\/537\.36 \(KHTML, like Gecko\) Chrome\/\d+\.0\.0\.0 Safari\/537\.36$/
)
expect(requestUserAgents.length).toBeGreaterThanOrEqual(2)
expect(new Set(requestUserAgents)).toEqual(new Set([pageUserAgent]))
})

test('internal window.open creates an independent full Sim window', async () => {
app = await launchApp(origin)
const window = await app.firstWindow()
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/main/browser-agent/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface MockView {
session: {
setPermissionRequestHandler: ReturnType<typeof vi.fn>
setPermissionCheckHandler: ReturnType<typeof vi.fn>
setUserAgent: ReturnType<typeof vi.fn>
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> }
}
on: ReturnType<typeof vi.fn>
Expand Down Expand Up @@ -358,15 +359,14 @@ describe('browser-agent session', () => {
expect(onTabNavigated).toHaveBeenCalledWith(contents, true)
})

it('gives every tab a user agent with no Electron token in it', () => {
it('leaves every tab on the process-wide user agent instead of overriding it', () => {
const first = session.ensureTab()
const second = session.addTab()

for (const tab of [first, second]) {
const contents = (tab.view as unknown as MockView).webContents
const agent = contents.setUserAgent.mock.calls.at(-1)?.[0] as string | undefined
expect(agent).toMatch(/^Mozilla\/5\.0 \(.+\) .*Chrome\/\d+\.0\.0\.0 Safari\/537\.36$/)
expect(agent).not.toMatch(/Electron|Sim\//)
expect(contents.setUserAgent).not.toHaveBeenCalled()
expect(contents.session.setUserAgent).not.toHaveBeenCalled()
}
})

Expand Down
10 changes: 0 additions & 10 deletions apps/desktop/src/main/browser-agent/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ import {
isBlockedSubresourceUrl,
subresourceNeedsResolution,
} from '@/main/browser-agent/url-guard'
import { browserUserAgent } from '@/main/browser-agent/user-agent'
import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store'
import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads'
import {
Expand Down Expand Up @@ -1413,11 +1412,6 @@ function configureAgentPartition(ses: Session): void {
}
return ALLOWED_SITE_PERMISSIONS.has(permission)
})
// Service workers do not inherit a tab's user agent. With only the tab's set,
// the document request carries the browser string while the worker's own
// script request still announces Electron — and on a site that routes its
// fetches through a worker, that is the one the server sees.
ses.setUserAgent(browserUserAgent())
// SSRF choke point for the agent partition. Document navigations (top-level +
// iframes) get the full DNS-resolving check — the one seam every navigation
// passes through, including page-initiated ones the driver never sees (server
Expand Down Expand Up @@ -1965,10 +1959,6 @@ function initializeTabView(view: WebContentsView, scopeId: string): WebContentsV
const contents = view.webContents
registerAgentWebContents(contents)
configureAgentPartition(contents.session)
// The session default does not reach a WebContents that already exists, and
// the first tab is what brings the session into being, so each tab sets its
// own as well — otherwise tab one browses as Electron and the rest as Chrome.
contents.setUserAgent(browserUserAgent())
attachAgentContextMenu(contents, {
addToChat: (text) => withBrowserScope(scopeId, () => addPageSelectionToChat(contents, text)),
openTab: (url) => withBrowserScope(scopeId, () => openTabWithUrl(url, { agentOwned: false })),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import { attachTelemetryPolicy } from '@/main/telemetry-policy'
import { TerminalRegistry } from '@/main/terminal/registry'
import { installTray, type TrayHandle } from '@/main/tray'
import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/main/updater'
import { installBrowserUserAgent } from '@/main/user-agent'
import { createMainWindow, setupPermissionHandlers } from '@/main/window'
import { attachWindowOpenPolicy, isPopupContents } from '@/main/windows'

Expand Down Expand Up @@ -899,6 +900,7 @@ app.setName(APP_NAME_FOR_CHANNEL[channelForOrigin(DEFAULT_ORIGIN)])
if (process.env.SIM_DESKTOP_USER_DATA) {
app.setPath('userData', process.env.SIM_DESKTOP_USER_DATA)
}
installBrowserUserAgent()

// The scheme the offline page and server picker load from must be declared
// before the app is ready; the per-session handlers attach later.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { app } from 'electron'
import { describe, expect, it, vi } from 'vitest'
import { browserUserAgent, stockChromeUserAgent } from '@/main/browser-agent/user-agent'
import { installBrowserUserAgent, stockChromeUserAgent } from '@/main/user-agent'

vi.mock('electron', () => import('@/test/electron-mock'))

const ELECTRON_DEFAULT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36'
const STOCK_CHROME =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'

describe('stockChromeUserAgent', () => {
it('drops the application and Electron tokens a browser allowlist rejects', () => {
Expand All @@ -15,9 +17,7 @@ describe('stockChromeUserAgent', () => {
})

it('reproduces the desktop string Chrome sends under user-agent reduction', () => {
expect(stockChromeUserAgent(ELECTRON_DEFAULT)).toBe(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
)
expect(stockChromeUserAgent(ELECTRON_DEFAULT)).toBe(STOCK_CHROME)
})

it('keeps the platform token of the machine it is running on', () => {
Expand All @@ -32,12 +32,13 @@ describe('stockChromeUserAgent', () => {
})
})

describe('browserUserAgent', () => {
it('derives from the string Electron would otherwise have sent', () => {
describe('installBrowserUserAgent', () => {
it('idempotently makes stock Chrome the process-wide fallback every request path uses', () => {
app.userAgentFallback = ELECTRON_DEFAULT

expect(browserUserAgent()).toBe(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
)
installBrowserUserAgent()
installBrowserUserAgent()

expect(app.userAgentFallback).toBe(STOCK_CHROME)
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* The user agent the browser resource presents to sites.
* The user agent the whole desktop process presents: the embedded browser and
* the app's own windows alike (desktop identity travels in `X-Sim-Client-Info`).
*
* Electron's default string carries two tokens no browser sends —
* `Sim/<version>` and `Electron/<version>`. Chromium's own token sits right
Expand Down Expand Up @@ -38,9 +39,11 @@ export function stockChromeUserAgent(defaultUserAgent: string): string {
}

/**
* Derived from the string Electron would otherwise have sent, so the reported
* Chromium version tracks whatever Chromium the app actually ships.
* Sets the stock Chrome identity as `app.userAgentFallback` before any session
* exists. Session and per-tab overrides miss some request paths (a cross-origin
* challenge frame still sends the process default), and a site that sees two
* user agents in one challenge rejects it as a spoof. Idempotent.
*/
export function browserUserAgent(): string {
return stockChromeUserAgent(app.userAgentFallback)
export function installBrowserUserAgent(): void {
app.userAgentFallback = stockChromeUserAgent(app.userAgentFallback)
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Point cron at an **internal** address where possible (the in-cluster Service, or
| Time pause/resume | `/api/resume/poll` | `*/1 * * * *` | Workflows paused on a timer |
| Outbox processing | `/api/webhooks/outbox/process` | `*/1 * * * *` | Transactional-outbox retries for billing, membership, enterprise issuance, and workflow-deployment side effects |
| Workspace file search dispatch | `/api/cron/workspace-file-search-dispatch` | `*/1 * * * *` | Dispatches indexing work for workspace file search |
| Knowledge projection | `/api/cron/knowledge-projection` | `*/1 * * * *` | Brings knowledge base search up to date with document, permission, and chunk changes |
| Connector sync | `/api/knowledge/connectors/sync` | `*/5 * * * *` | Knowledge base connector syncs |
| Connector member sync | `/api/knowledge/connectors/member-sync` | `*/5 * * * *` | Per-member access sync for permission-aware connectors |
| Connector directory sync | `/api/knowledge/connectors/directory-sync` | `*/5 * * * *` | Refreshes the directory groups administrator-mode connectors mirror, so a membership change takes effect without waiting for a content sync |
Expand Down
86 changes: 86 additions & 0 deletions apps/sim/app/api/cron/knowledge-projection/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
enqueueSweep: vi.fn(),
verifyCronAuth: vi.fn(),
}))

vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mocks.verifyCronAuth }))
vi.mock('@/lib/knowledge/projection/enqueue', () => ({
enqueueKnowledgeProjectionSweep: mocks.enqueueSweep,
}))

import { GET } from '@/app/api/cron/knowledge-projection/route'

function request() {
return createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/cron/knowledge-projection'
)
}

describe('knowledge projection sweep route', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.verifyCronAuth.mockReturnValue(null)
})

it('returns as soon as Trigger.dev accepts the pass', async () => {
mocks.enqueueSweep.mockResolvedValue({
triggered: true,
backend: 'trigger-dev',
jobId: 'run-1',
})

const response = await GET(request())

expect(response.status).toBe(202)
await expect(response.json()).resolves.toEqual({
success: true,
triggered: true,
backend: 'trigger-dev',
jobId: 'run-1',
})
})

it('answers 200 without a pass when the projector has nothing to do', async () => {
mocks.enqueueSweep.mockResolvedValue({ triggered: false, backend: null, jobId: null })

const response = await GET(request())

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
triggered: false,
backend: null,
jobId: null,
})
})

it('returns the cron auth refusal without enqueueing', async () => {
mocks.verifyCronAuth.mockReturnValue(new Response(null, { status: 401 }))

const response = await GET(request())

expect(response.status).toBe(401)
expect(mocks.enqueueSweep).not.toHaveBeenCalled()
})

it('fails closed when Trigger.dev does not accept the pass', async () => {
mocks.enqueueSweep.mockRejectedValue(new Error('trigger unavailable'))

const response = await GET(request())

expect(response.status).toBe(500)
await expect(response.json()).resolves.toEqual({
success: false,
error: 'Sweep enqueue failed',
})
})
})
29 changes: 29 additions & 0 deletions apps/sim/app/api/cron/knowledge-projection/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { enqueueKnowledgeProjectionSweep } from '@/lib/knowledge/projection/enqueue'

const logger = createLogger('KnowledgeProjectionSweepRoute')

export const dynamic = 'force-dynamic'
export const maxDuration = 60

/**
* The knowledge projector's periodic sweep: enqueues one pass per window while there is work, and
* returns once Trigger.dev accepts it. Writers ask for passes as they commit; this converges
* whatever those requests missed.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const authError = verifyCronAuth(request, 'Knowledge projection sweep')
if (authError) return authError

try {
const result = await enqueueKnowledgeProjectionSweep()
return NextResponse.json({ success: true, ...result }, { status: result.triggered ? 202 : 200 })
} catch (error) {
logger.error('Knowledge projection sweep enqueue failed', { error: getErrorMessage(error) })
return NextResponse.json({ success: false, error: 'Sweep enqueue failed' }, { status: 500 })
}
})
15 changes: 15 additions & 0 deletions apps/sim/app/api/knowledge/connectors/member-sync/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,21 @@ describe('member sync scheduler owner routing', () => {
).toBe(true)
})

it('still dispatches due connectors when the stale observation sweep fails', async () => {
mocks.sweep.mockRejectedValue(
Object.assign(new Error('canceling statement due to lock timeout'), { code: '55P03' })
)
queueTableRows(schemaMock.knowledgeConnector, [
{ id: 'workspace-source', workspaceId: 'workspace-a', organizationId: null },
])
const response = await GET(createMockRequest('GET'))
expect(response.status).toBe(200)
expect(mocks.dispatch).toHaveBeenCalledExactlyOnceWith(
'workspace-source',
expect.objectContaining({ requireRunnable: true })
)
})

it('preserves workspace dispatch and refuses absent or ambiguous ownership', async () => {
queueTableRows(schemaMock.knowledgeConnector, [
{ id: 'missing', workspaceId: null, organizationId: null },
Expand Down
14 changes: 11 additions & 3 deletions apps/sim/app/api/knowledge/connectors/member-sync/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import { knowledgeBase, knowledgeConnector, knowledgeConnectorMemberSyncLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, asc, eq, inArray, isNull, lte, type SQL, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
Expand Down Expand Up @@ -157,9 +158,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
logger.warn(`[${requestId}] Closed ${closedLogs.length} orphaned member sync log(s)`)
}

const sweep = await sweepStaleMemberObservations(now)
if (sweep.members > 0) {
logger.warn(`[${requestId}] Swept observations of ${sweep.members} stale member(s)`, sweep)
/** Observation hygiene never holds back dispatch; an unfinished sweep resumes next tick. */
try {
const sweep = await sweepStaleMemberObservations(now)
if (sweep.members > 0) {
logger.warn(`[${requestId}] Swept observations of ${sweep.members} stale member(s)`, sweep)
}
} catch (error) {
logger.error(`[${requestId}] Stale member observation sweep failed`, {
error: getErrorMessage(error),
})
}

const dueConnectors = await db
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/api/knowledge/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ describe('Knowledge Utils', () => {
secretProvenanceVersion: null,
},
])
/** Pre-commit connector and knowledge-base activity check. */
queueTableRows(schemaMock.document, [{ id: 'doc1' }])
/** In-transaction active-document recheck. */
queueTableRows(schemaMock.document, [{ id: 'doc1' }])

Expand Down
Loading
Loading