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
1 change: 0 additions & 1 deletion AGENTS.md

This file was deleted.

162 changes: 162 additions & 0 deletions AGENTS.md

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"@xterm/headless": "6.0.0",
"electron-updater": "6.8.9",
"micromatch": "4.0.8",
"safe-regex2": "5.1.0",
"safe-regex2": "5.1.2",
"pdf-lib": "1.17.1"
},
"devDependencies": {
Expand All @@ -51,19 +51,19 @@
"@sim/emcn": "workspace:*",
"@sim/tsconfig": "workspace:*",
"@types/micromatch": "4.0.10",
"@types/node": "24.2.1",
"@types/node": "24.19.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"electron": "43.5.0",
"electron-builder": "26.15.3",
"esbuild": "0.28.1",
"esbuild": "0.28.2",
"jsdom": "^26.0.0",
"postcss": "^8",
"postcss": "^8.5.30",
"postcss-load-config": "6.0.1",
"postgres": "^3.4.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"typescript": "^7.0.2",
"vitest": "^5.0.1"
"vitest": "^5.0.4"
}
}
25 changes: 13 additions & 12 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@
"@sim/logger": "workspace:*",
"@sim/utils": "workspace:*",
"@sim/workflow-renderer": "workspace:*",
"@xyflow/react": "12.11.3",
"@xyflow/react": "12.12.0",
"class-variance-authority": "^0.7.1",
"drizzle-orm": "^0.45.2",
"drizzle-orm": "^0.45.4",
"fumadocs-core": "16.8.5",
"fumadocs-mdx": "14.3.2",
"fumadocs-openapi": "10.8.1",
Expand All @@ -39,21 +39,22 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"remark-breaks": "^4.0.0",
"shiki": "4.3.1",
"remark-gfm": "4.0.1",
"shiki": "4.5.0",
"streamdown": "2.5.0",
"framer-motion": "^12.5.0",
"zod": "4.3.6"
},
"devDependencies": {
"@sim/tsconfig": "workspace:*",
"@tailwindcss/postcss": "^4.0.12",
"@types/node": "24.2.1",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.0.4",
"gray-matter": "^4.0.3",
"jsdom": "^26.0.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.12",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "24.19.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"gray-matter": "4.0.3",
"postcss": "^8.5.30",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vitest": "^5.0.1"
"vitest": "^5.0.4"
}
}
14 changes: 7 additions & 7 deletions apps/realtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,21 @@
"@sim/workflow-persistence": "workspace:*",
"@sim/workflow-types": "workspace:*",
"@socket.io/redis-adapter": "8.3.0",
"drizzle-orm": "^0.45.2",
"lib0": "0.2.117",
"drizzle-orm": "^0.45.4",
"lib0": "0.2.119",
"postgres": "^3.4.5",
"redis": "5.10.0",
"socket.io": "^4.8.1",
"socket.io": "^4.8.4",
"y-protocols": "1.0.7",
"yjs": "13.6.31",
"yjs": "13.6.33",
"zod": "4.3.6"
},
"devDependencies": {
"@sim/testing": "workspace:*",
"@sim/tsconfig": "workspace:*",
"@types/node": "24.2.1",
"socket.io-client": "4.8.1",
"@types/node": "24.19.2",
"socket.io-client": "4.8.4",
"typescript": "^7.0.2",
"vitest": "^5.0.1"
"vitest": "^5.0.4"
}
}
18 changes: 18 additions & 0 deletions apps/sim/app/(landing)/comparisons/[provider]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { LIBRARY_SECTION } from '@/lib/library/seo'
import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparisons/comparison-sections'
import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparisons/components/brand-icon-tile'
import { ComparisonCards } from '@/app/(landing)/comparisons/components/comparison-cards'
import { ComparisonLinkRow } from '@/app/(landing)/comparisons/components/comparison-link-row'
import { ComparisonTable } from '@/app/(landing)/comparisons/components/comparison-table'
import { ProseText } from '@/app/(landing)/comparisons/components/prose-text'
import { getLibraryPostsForCompetitor } from '@/app/(landing)/comparisons/library-links'
Expand Down Expand Up @@ -91,6 +92,7 @@ export default async function ComparisonProviderPage({
const relatedPosts = await getLibraryPostsForCompetitor(competitor)
const verdict = buildBottomLine(competitor)
const CompetitorIcon = competitor.brand?.icon
const otherCompetitors = ALL_COMPETITORS.filter((other) => other.id !== competitor.id)

const breadcrumbJsonLd = {
'@context': 'https://schema.org',
Expand Down Expand Up @@ -378,6 +380,22 @@ export default async function ComparisonProviderPage({
</>
) : null}

<section aria-labelledby='more-comparisons-heading'>
<h2
id='more-comparisons-heading'
className='px-6 pt-10 pb-6 text-[var(--text-primary)] text-xl leading-[100%] tracking-[-0.02em] lg:text-2xl'
>
More comparisons
</h2>
<ul className='grid grid-cols-1 gap-px border-[var(--border)] border-y bg-[var(--border)] lg:grid-cols-2'>
{otherCompetitors.map((other) => (
<li key={other.id} className='bg-[var(--bg)] lg:odd:last:col-span-2'>
<ComparisonLinkRow competitor={other} />
</li>
))}
</ul>
</section>

<section aria-labelledby='faq-heading' className='px-6 py-10'>
<h2
id='faq-heading'
Expand Down
8 changes: 5 additions & 3 deletions apps/sim/app/(landing)/comparisons/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,11 @@ export interface ComparisonVerdict {

export function buildBottomLine(competitor: CompetitorProfile): ComparisonVerdict {
const strength = competitor.standoutFeatures[0]
const chooseCompetitor = strength
? `Choose ${competitor.name} if you specifically need ${lowercaseFirst(strength.title)}: ${strength.description}`
: `Choose ${competitor.name} if its specific strengths, documented above, matter more to your team than an AI-native, self-hostable workspace.`
const chooseCompetitor =
competitor.chooseCompetitorAnswer ??
(strength
? `Choose ${competitor.name} if you specifically need ${lowercaseFirst(strength.title)}: ${strength.description}`
: `Choose ${competitor.name} if its specific strengths, documented above, matter more to your team than an AI-native, self-hostable workspace.`)

return {
chooseSim: `Choose Sim if you want an open-source, self-hostable AI workspace that treats AI agents as first-class citizens: native multi-LLM support, real-time multiplayer editing, human-in-the-loop approvals, and, on the Enterprise plan, environment promotion (dev/qa/prod) and governance (SSO, credential-level permissions, audit logs) built in rather than bolted on.`,
Expand Down
12 changes: 3 additions & 9 deletions apps/sim/app/_shell/providers/theme-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useState } from 'react'
import { usePathname } from 'next/navigation'
import type { ThemeProviderProps } from 'next-themes'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { LANDING_ROUTES } from '@/lib/landing/routes'
import { isLandingPath } from '@/lib/landing/routes'

/**
* First path segments outside the `(landing)` group whose pages pin the light
Expand Down Expand Up @@ -41,6 +41,8 @@ const NON_LANDING_LIGHT_SEGMENTS = [
*/
const LIGHT_MODE_SEGMENTS: ReadonlySet<string> = new Set(NON_LANDING_LIGHT_SEGMENTS)

const APP_SURFACE = { defaultTheme: 'system', storageKey: 'sim-theme' } as const

/**
* The marketing surface: the root plus every `app/(landing)` segment. These
* pages keep their own stored theme, written only by the landing footer's
Expand All @@ -50,16 +52,8 @@ const LIGHT_MODE_SEGMENTS: ReadonlySet<string> = new Set(NON_LANDING_LIGHT_SEGME
* every signed-in user, which would render the marketing site in the OS theme
* and overwrite any choice the footer toggle made.
*/
const LANDING_SEGMENTS: ReadonlySet<string> = new Set(LANDING_ROUTES)

const APP_SURFACE = { defaultTheme: 'system', storageKey: 'sim-theme' } as const
const LANDING_SURFACE = { defaultTheme: 'light', storageKey: 'sim-landing-theme' } as const

function isLandingPath(pathname: string): boolean {
const firstSegment = pathname.split('/')[1]
return firstSegment === '' || LANDING_SEGMENTS.has(firstSegment)
}

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const pathname = usePathname()

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/mcp/oauth/start/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const DCR_UNSUPPORTED_MESSAGE =
/**
* The MCP SDK throws a plain `Error` (no typed class or code) when an auth server lacks a
* `registration_endpoint`, so this string-match is the only available signal. Pinned to
* `@modelcontextprotocol/sdk` v1.31.0 `registerClient` (client/auth.js): "Incompatible auth
* `@modelcontextprotocol/sdk` v1.32.1 `registerClient` (client/auth.js): "Incompatible auth
* server: does not support dynamic client registration". If a version bump rephrases this, the
* 422 branch silently stops firing (users get a generic 500) — update the substring here.
*/
Expand Down
22 changes: 7 additions & 15 deletions apps/sim/app/api/webhooks/agentmail/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,8 @@ const { mockVerify, mockExecuteInboxTask } = vi.hoisted(() => ({
mockExecuteInboxTask: vi.fn(),
}))

vi.mock('svix', () => ({
Webhook: class {
private readonly secret: string
constructor(secret: string) {
this.secret = secret
}
verify(payload: string, headers: Record<string, string>) {
return mockVerify(this.secret, payload, headers)
}
},
vi.mock('@sim/security/standard-webhooks', () => ({
verifyStandardWebhook: mockVerify,
}))

vi.mock('@/lib/core/admission/gate', () => admissionGateMock)
Expand Down Expand Up @@ -92,9 +84,7 @@ describe('POST /api/webhooks/agentmail', () => {

mockTryAdmit.mockReturnValue({ release: mockRelease })
mockExecuteInboxTask.mockResolvedValue(undefined)
mockVerify.mockImplementation(() => {
throw new Error('signature mismatch')
})
mockVerify.mockReturnValue({ valid: false, reason: 'no_matching_signature' })
})

it('checks the signature against only the secret the payload routes to', async () => {
Expand All @@ -106,12 +96,14 @@ describe('POST /api/webhooks/agentmail', () => {
expect(mockEq).toHaveBeenCalledWith(schemaMock.workspace.inboxProviderId, TARGET_INBOX_ID)
expect(dbChainMockFns.limit).toHaveBeenCalledWith(1)
expect(mockVerify).toHaveBeenCalledTimes(1)
expect(mockVerify).toHaveBeenCalledWith('whsec_b', expect.any(String), expect.any(Object))
expect(mockVerify).toHaveBeenCalledWith(
expect.objectContaining({ secret: 'whsec_b', headerPrefix: 'svix' })
)
})

it('rejects a payload naming an inbox no workspace owns, without hashing it', async () => {
queueTableRows(schemaMock.workspace, [])
mockVerify.mockReturnValue(undefined)
mockVerify.mockReturnValue({ valid: true })

const response = await POST(
webhookRequest(envelope({ inbox_id: 'agent-unknown@agentmail.to' }))
Expand Down
30 changes: 12 additions & 18 deletions apps/sim/app/api/webhooks/agentmail/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import {
workspace,
} from '@sim/db'
import { createLogger } from '@sim/logger'
import { verifyStandardWebhook } from '@sim/security/standard-webhooks'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { tasks } from '@trigger.dev/sdk'
import { and, eq, gt, ne, sql } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { Webhook } from 'svix'
import {
agentMailEnvelopeSchema,
agentMailMessageSchema,
Expand Down Expand Up @@ -41,22 +41,8 @@ const MAX_EMAILS_PER_HOUR = 20

const AGENTMAIL_BODY_LABEL = 'AgentMail webhook body'

/** Svix throws on a bad signature, stale timestamp, or malformed header. */
function verifiesAgainst(
secret: string,
rawBody: string,
headers: Record<string, string>
): boolean {
try {
new Webhook(secret).verify(rawBody, headers)
return true
} catch {
return false
}
}

/**
* Bound the unauthenticated AgentMail webhook body before buffering it for Svix
* Bound the unauthenticated AgentMail webhook body before buffering it for
* signature verification, so an oversized payload cannot exhaust pod memory.
*/
async function readAgentMailBody(req: Request): Promise<string> {
Expand Down Expand Up @@ -106,7 +92,7 @@ export const POST = withRouteHandler(async (req: Request) => {
}

/**
* Route to one tenant before hashing: svix HMACs the whole body synchronously,
* Route to one tenant before hashing: verification HMACs the whole body synchronously,
* so checking every workspace secret would let one unauthenticated request cost
* a hash per tenant. `inbox_id` is attacker-controlled but only selects which
* secret to check — verification still binds the payload's inbox to the workspace.
Expand All @@ -129,7 +115,15 @@ export const POST = withRouteHandler(async (req: Request) => {
.where(eq(workspace.inboxProviderId, inboxId))
.limit(1)

if (!result || !verifiesAgainst(result.webhookSecret, rawBody, headersResult.data)) {
if (
!result ||
!verifyStandardWebhook({
secret: result.webhookSecret,
rawBody,
headers: req.headers,
headerPrefix: 'svix',
}).valid
) {
logger.warn('Webhook signature verification failed', { payloadInboxId: inboxId })
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
Expand Down
14 changes: 9 additions & 5 deletions apps/sim/app/api/webhooks/agentmail/thread-scope.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/
* `In-Reply-To` header — a value every recipient of the agent's reply holds. Against
* real PostgreSQL, a reply delivered to one workspace's inbox must never reach a chat,
* task, or transcript owned by another workspace, while a reply inside the workspace
* still continues its thread. The webhook is signed with the real Svix scheme.
* still continues its thread. The webhook is signed per the Standard Webhooks spec.
*/
import { authBanMock } from '@sim/testing/mocks/auth-ban.mock'
import {
Expand Down Expand Up @@ -40,6 +40,7 @@ vi.mock('@/lib/mothership/inbox/executor', () => ({
executeInboxTask: mockExecuteInboxTask,
}))

import { createHmac } from 'node:crypto'
import { db } from '@sim/db'
import {
copilotChats,
Expand All @@ -53,7 +54,6 @@ import {
import { generateId } from '@sim/utils/id'
import { and, eq, inArray } from 'drizzle-orm'
import { NextRequest } from 'next/server'
import { Webhook } from 'svix'
import { persistCopilotChatTurn } from '@/lib/mothership/chat/messages-store'
import { buildPersistedUserMessage } from '@/lib/mothership/chat/persisted-message'
import { POST } from '@/app/api/webhooks/agentmail/route'
Expand Down Expand Up @@ -142,15 +142,19 @@ async function deliver(
},
})
const svixId = `msg_${generateId()}`
const timestamp = new Date()
const timestamp = String(Math.floor(Date.now() / 1000))
const key = Buffer.from(ws.secret.slice('whsec_'.length), 'base64')
const signature = createHmac('sha256', key)
.update(`${svixId}.${timestamp}.${body}`)
.digest('base64')
return POST(
new NextRequest('http://localhost:3000/api/webhooks/agentmail', {
method: 'POST',
headers: {
'content-type': 'application/json',
'svix-id': svixId,
'svix-timestamp': String(Math.floor(timestamp.getTime() / 1000)),
'svix-signature': new Webhook(ws.secret).sign(svixId, timestamp, body),
'svix-timestamp': timestamp,
'svix-signature': `v1,${signature}`,
},
body,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ function guardParagraphLeading(text: string): string {
* paragraph renders as just its inline children; this override wraps that with the leading guard.
*/
const BlockSafeParagraph = Paragraph.extend({
parseMarkdown: (token, helpers) => {
const parsed = Paragraph.config.parseMarkdown?.(token, helpers) ?? []
parseMarkdown(token, helpers) {
const parsed = this.parent?.(token, helpers) ?? []
return Array.isArray(parsed) ? parsed : splitBlockImageParagraph(parsed)
},
renderMarkdown: (node: JSONContent, h, context) => {
Expand Down Expand Up @@ -271,8 +271,8 @@ export function createMarkdownContentExtensions(
nodeViews.mention ?? MarkdownMention,
TaskList,
TaskItem.extend({
parseMarkdown: (token, helpers) => {
const parsed = TaskItem.config.parseMarkdown?.(token, helpers) ?? []
parseMarkdown(token, helpers) {
const parsed = this.parent?.(token, helpers) ?? []
if (Array.isArray(parsed)) return parsed
const content = (parsed.content ?? []).flatMap(splitBlockImageParagraph)
if (content[0]?.type !== 'paragraph') content.unshift({ type: 'paragraph' })
Expand Down
Loading
Loading