diff --git a/.github/actions/image/action.yml b/.github/actions/image/action.yml index ff32a025bdb..b4a3307c990 100644 --- a/.github/actions/image/action.yml +++ b/.github/actions/image/action.yml @@ -23,6 +23,10 @@ inputs: description: Newline-separated Docker build arguments. required: false default: '' + target: + description: Dockerfile stage to build. Empty builds the final stage. + required: false + default: '' max-cache-size-mb: description: >- Layer cache to retain after this action prunes, in MB. Must stay above one @@ -77,6 +81,7 @@ runs: push: true tags: ${{ inputs.tags }} build-args: ${{ inputs.build-args }} + target: ${{ inputs.target }} provenance: false sbom: false @@ -183,5 +188,6 @@ runs: push: true tags: ${{ inputs.tags }} build-args: ${{ inputs.build-args }} + target: ${{ inputs.target }} provenance: false sbom: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8986a857d27..db922131867 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -209,8 +209,11 @@ jobs: ecr_repo_secret: ECR_APP gh_runner: linux-x64-8-core bs_runner: blacksmith-16vcpu-ubuntu-2404 + # Dev runs `db:push`, which needs drizzle-kit; every other environment + # gets the final stage, which carries runtime dependencies only. - id: db dockerfile: ./docker/db.Dockerfile + target: dev ecr_repo_secret: ECR_MIGRATIONS gh_runner: ubuntu-latest bs_runner: blacksmith-2vcpu-ubuntu-2404 @@ -259,6 +262,7 @@ jobs: tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:${{ github.sha }}-dev build-args: | MSHIP_PLAN_MODE_DEFAULT=true + target: ${{ matrix.target }} max-cache-size-mb: ${{ matrix.cache_mb }} # Staging/production coordinate task releases with app traffic cutover. diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 11eecdcbb00..ea7e867477e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -239,9 +239,13 @@ Mothership's `computer` tool is available on macOS 14+ once the user opts in und group with the **Computer Use** restriction. Older desktop builds omit this optional bridge and continue to work. -The user grants Accessibility and Screen Recording through macOS, then approves each app for the -current task or persistently. Persistent app approvals are listed in Settings and can be revoked. -Sign-out and deployment changes clear computer-use approval and opt-in. The conversation shows the +The user grants Accessibility and Screen Recording through macOS; every app is then available except +Sim, System Settings, password managers, terminals and scripting apps (shell access has its own +Terminal control), +which the helper refuses. With **Ask before using a new +app** on, each app's first use is approved for the task or persistently, and persistent approvals are +listed in Settings and can be revoked. Sign-out and deployment changes clear opt-in, the ask choice +and approvals. The conversation shows the active app/action and a Stop button; **⌘⇧Esc** also stops active work when the shortcut is available. Stop cancels queued actions, in-flight authorization, approval prompts and the native helper. Already completed input cannot be undone. diff --git a/apps/desktop/native/computer-use/ComputerUse.swift b/apps/desktop/native/computer-use/ComputerUse.swift index 67a01aae22e..bb5b604d746 100644 --- a/apps/desktop/native/computer-use/ComputerUse.swift +++ b/apps/desktop/native/computer-use/ComputerUse.swift @@ -142,10 +142,29 @@ func elementRect(_ element: AXUIElement) -> CGRect? { AXValueGetValue(unsafeBitCast(rawSize, to: AXValue.self), .cgSize, &size) else { return nil } return CGRect(origin: position, size: size) } +/// Apps computer use never operates, lowercased: macOS security settings, password managers, and apps +/// whose primary purpose is running shell commands or scripts (terminals, Script Editor, Automator, +/// Shortcuts), since shell access goes through the Terminal tool's own opt-in. Apps that merely embed a +/// terminal, such as editors, stay usable. +let PROTECTED_APPS: Set = [ + "com.apple.systempreferences", "com.apple.keychainaccess", "com.apple.passwords", + "com.1password.1password", "com.agilebits.onepassword7", "com.agilebits.onepassword-osx", "com.bitwarden.desktop", + "org.keepassxc.keepassxc", "com.lastpass.lastpass", "in.sinew.enpass-desktop", "me.proton.pass.electron", + "com.markmcguill.strongbox", "com.markmcguill.strongbox.mac", + "com.apple.terminal", "com.googlecode.iterm2", "dev.warp.warp-stable", "com.mitchellh.ghostty", + "net.kovidgoyal.kitty", "org.alacritty", "com.github.wez.wezterm", "co.zeit.hyper", "org.tabby", + "com.apple.scripteditor2", "com.apple.automator", "com.apple.shortcuts", +] +/// Whether computer use refuses an app: Sim's own apps and `PROTECTED_APPS`. Bundle identifiers match +/// case-insensitively in Launch Services. +func isProtectedApp(_ bundle: String) -> Bool { + let key = bundle.lowercased() + return key == Bundle.main.bundleIdentifier?.lowercased() || key.hasPrefix("com.simstudio.") || key.hasPrefix("ai.sim.desktop") || PROTECTED_APPS.contains(key) +} func appFor(_ bundleId: String?, launch: Bool = false) async throws -> NSRunningApplication { let bundle = try required(bundleId, "bundleId") - guard bundle != Bundle.main.bundleIdentifier, !bundle.hasPrefix("com.simstudio."), !bundle.hasPrefix("ai.sim.desktop"), bundle != "com.apple.systempreferences" else { - throw ComputerError("protected_app", "Computer use cannot control its own app or macOS security settings.") + guard !isProtectedApp(bundle) else { + throw ComputerError("protected_app", "Computer use does not operate Sim, System Settings, password managers, terminals, or scripting apps; use the terminal tool for shell work.") } var apps = NSRunningApplication.runningApplications(withBundleIdentifier: bundle).filter { !$0.isTerminated } if apps.isEmpty && launch { diff --git a/apps/desktop/native/computer-use/README.md b/apps/desktop/native/computer-use/README.md index c3971b54d70..c615ec6649e 100644 --- a/apps/desktop/native/computer-use/README.md +++ b/apps/desktop/native/computer-use/README.md @@ -10,7 +10,7 @@ Persistent newline-delimited JSON over stdin/stdout: `{id,method,params}` return The helper observes up to 2000 accessibility nodes (depth 64, queue 4000, traversal deadline 8 seconds, each AX call timeout 250ms). Selected-window and focused-element ancestry are prioritized. The menu bar and menus are read in place, so an open menu's items and their submenus come first; items of closed menus are read last, capped at 80 menu nodes. Electron accessibility is enabled using its documented `AXManualAccessibility` attribute. The helper observes 100 windows and 1000 apps. Requests are bounded to 128 KiB during ingestion. Text/value input is capped at 32000 UTF-16 units. State references are per-process, per-app, expire after 60 seconds, and are consumed by every action that reaches the app; a request that fails before sending anything reports `dispatchState: "not_started"` and keeps them valid. An Accessibility failure counts as sent unless Accessibility rejected the request before the app saw it (unsupported attribute or action, illegal argument). Restarts invalidate all references. Screen captures exclude window shadows, preserve the selected window’s coordinate geometry, and are capped to 1600 pixels on the longest side and 8 MiB encoded. Complete JSON replies are capped at 16 MiB: oversized observations omit screenshots first, then truncate accessibility nodes, while preserving action outcomes. Screenshots are optional and failure is reported in `screenshotError` without discarding accessibility state. macOS 26 uses the screenshot-specific capture API; the macOS 14/15 API path includes only the selected window in a display filter and crops its exact frame. That older path requires the window to fit within one display and reports `capture_geometry_unavailable` otherwise. -Coordinates in action inputs are window-local points; node frames are global screen points. Coordinate input without a `windowId` targets the window the snapshot selected, and an element target uses its own window; `press_key` with an `elementId` focuses that element first. Window bounds must still match the observed snapshot. App PID and launch time must match. Secure fields and descendants are unavailable as action targets; secure values and labels are suppressed. Sim's own apps and System Settings are protected targets. `get_app_state` can open an installed bundle ID without activating it. `list_apps` discovers installed apps under `/Applications`, `/System/Applications`, and `~/Applications` (bounded to 5000 filesystem entries and depth 3), merging running regular apps; installed apps omit `pid`. +Coordinates in action inputs are window-local points; node frames are global screen points. Coordinate input without a `windowId` targets the window the snapshot selected, and an element target uses its own window; `press_key` with an `elementId` focuses that element first. Window bounds must still match the observed snapshot. App PID and launch time must match. Secure fields and descendants are unavailable as action targets; secure values and labels are suppressed. Sim's own apps, System Settings, password managers, terminals and scripting apps (Script Editor, Automator, Shortcuts) are protected targets (`PROTECTED_APPS`, matched case-insensitively); shell work belongs to the separate Terminal control. `get_app_state` can open an installed bundle ID without activating it. `list_apps` discovers installed apps under `/Applications`, `/System/Applications`, and `~/Applications` (bounded to 5000 filesystem entries and depth 3), merging running regular apps; installed apps omit `pid`. AXPress and AXSetValue are direct semantic operations. Keyboard events target the app PID. Cmd+A uses the focused editor's writable accessibility selection range where supported. Coordinate mouse input requires the target app in front; `activate_app` makes that focus change explicit and confirms it. Click/drag/scroll validate the foreground process, unchanged window geometry and the app that receives the point; the topmost window-list entry is not the click target because Notification Center and the Dock keep full-screen click-through windows. A hit on a window's own background counts as that window. Background tabs and minimized windows share bounds with the window in front, so they take no coordinate input. An action that opens UI (AXPress, AXPick, AXShowMenu) and times out while the app shows a sheet, modal or menu (`cannotComplete`) counts as dispatched; any other action that times out fails as uncertain. A menu-item press that leaves the item's own menu open with the app highlighting that item (Calendar on macOS 26) is completed with Return. Sheets, including Go to Folder and out-of-process open and save panels whose contents report no window, count as the window they are attached to. Menu item labels carry their shortcuts (for example ⇧⌘N). Some apps ignore background keyboard events. `dispatched:true,verified:false` means only dispatch succeeded; the agent must observe again to establish the outcome. No foreground activation fallback is hidden inside the helper. Native key codes currently assume the US layout for shortcuts; literal text uses Unicode events. Unsupported shortcuts fail explicitly. diff --git a/apps/desktop/native/computer-use/tests/NativeTests.swift b/apps/desktop/native/computer-use/tests/NativeTests.swift index 198911e3ff6..3de6f79b94a 100644 --- a/apps/desktop/native/computer-use/tests/NativeTests.swift +++ b/apps/desktop/native/computer-use/tests/NativeTests.swift @@ -26,6 +26,10 @@ import AppKit expectError("unsupported_key") { _ = try parseKey("F99") } expectError("invalid_arguments") { _ = try parseKey("Hyper+A") } expectError("invalid_arguments") { _ = try required(Optional.none, "example") } + for bundle in ["com.apple.systempreferences", "com.apple.Terminal", "COM.GOOGLECODE.ITERM2", "net.kovidgoyal.kitty", "com.1password.1password", "com.apple.Passwords", "com.bitwarden.desktop", "ai.sim.desktop", "com.simstudio.computer-use", "com.apple.ScriptEditor2", "com.apple.Automator", "com.apple.shortcuts"] { + precondition(isProtectedApp(bundle), bundle) + } + for bundle in ["com.apple.TextEdit", "com.apple.finder", "com.apple.Safari"] { precondition(!isProtectedApp(bundle), bundle) } var queue = AXTraversalQueue(limit: 1000) queue.append(AXTraversalEntry(element: "menu", parent: nil, depth: 0, menu: true, priority: false)) queue.append(AXTraversalEntry(element: "content", parent: nil, depth: 0, menu: false, priority: false)) diff --git a/apps/desktop/scripts/build-native.ts b/apps/desktop/scripts/build-native.ts index ea5c8af243e..eb0d3f1e99d 100644 --- a/apps/desktop/scripts/build-native.ts +++ b/apps/desktop/scripts/build-native.ts @@ -89,6 +89,7 @@ function compileComputerUse(): void { try { execFileSync('xcrun', ['lipo', binary, '-verify_arch', 'arm64', 'x86_64']) execFileSync('codesign', ['--verify', '--strict', bundle]) + assertWeakScreenCaptureKit(binary) return } catch { rmSync(bundle, { recursive: true, force: true }) @@ -108,6 +109,11 @@ function compileComputerUse(): void { '-O', '-target', `${arch}-apple-macosx14.0`, + // The macOS 26 screenshot classes do not exist on 14/15; a strong reference stops the helper loading there. + '-Xlinker', + '-weak_framework', + '-Xlinker', + 'ScreenCaptureKit', 'native/computer-use/ComputerUse.swift', '-o', output, @@ -118,9 +124,27 @@ function compileComputerUse(): void { } execFileSync('xcrun', ['lipo', '-create', ...parts, '-output', binary], { stdio: 'inherit' }) for (const part of parts) rmSync(part) + assertWeakScreenCaptureKit(binary) execFileSync('codesign', ['--force', '--sign', '-', bundle], { stdio: 'inherit' }) recordNativeBuild('computer-use', outputs) logger.info('Compiled native macOS Computer Use helper') } +/** + * The helper targets macOS 14 but uses ScreenCaptureKit classes added later behind `#available`. + * Runners build on the newest macOS, where a strong reference still loads, so this is the only + * place a helper that dyld would refuse on older macOS is caught before it ships. + */ +function assertWeakScreenCaptureKit(binary: string) { + const strong = execFileSync('nm', ['-m', binary], { encoding: 'utf8' }) + .split('\n') + .filter( + (line) => line.includes('(undefined) external') && line.includes('(from ScreenCaptureKit)') + ) + if (strong.length > 0) + throw new Error( + `The Computer Use helper links ScreenCaptureKit strongly and would not load on macOS 14/15:\n${strong.join('\n')}` + ) +} + if (args.includes('--computer-use')) compileComputerUse() diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index dceb49ecbd5..7c3ce842eaa 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -1894,6 +1894,7 @@ describe('credential protection', () => { activeElementSecrecy: 'safe', readActiveElementState: {}, readPageActionState: {}, + readDomQuiet: { quietMs: 1_000, loading: false }, }) const pageCall = vi.mocked(contents.executeJavaScript).getMockImplementation() let releaseObservation: (value: unknown) => void = () => {} @@ -3212,6 +3213,113 @@ describe('credential protection', () => { }) }) + it.each([ + ['still rendering', [0, 40, 90, 200], 4], + ['already quiet', [400], 1], + ['never settling', Array.from({ length: 200 }, () => 0), undefined], + ] as const)( + 'observes after an action only once a page %s has gone quiet, within the settle cap', + async (_label, quietReadings, expectedProbes) => { + const contents = await openPage() + const calls: string[] = [] + let probe = 0 + vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { + if (isPageCall(expression, 'readDomQuiet')) { + calls.push('probe') + const quietMs = quietReadings[Math.min(probe++, quietReadings.length - 1)] + return Promise.resolve({ quietMs, loading: false }) + } + if (isPageCall(expression, 'collectSnapshot')) { + calls.push('snapshot') + return Promise.resolve({ + url: 'https://example.com/', + title: 'Example', + outline: '- heading "Results"', + truncated: false, + refIds: [], + refLineIndexes: {}, + nextElementId: 1_000, + }) + } + if (isPageCall(expression, 'clickElement')) return Promise.resolve(CLICK_TARGET) + return Promise.resolve(undefined) + }) + vi.useFakeTimers() + try { + const clicking = driver.executeTool('chat-test', 'browser_click', { + elementId: 0, + observe: {}, + }) + await vi.advanceTimersByTimeAsync(5_000) + + expect(await clicking).toMatchObject({ ok: true, result: { observation: { ok: true } } }) + const probesBeforeSnapshot = calls.indexOf('snapshot') + expect(probesBeforeSnapshot).toBeGreaterThan(0) + if (expectedProbes !== undefined) expect(probesBeforeSnapshot).toBe(expectedProbes) + } finally { + vi.useRealTimers() + } + } + ) + + it('keeps settling through failed quiet probes, and never observes once cancelled', async () => { + const contents = await openPage() + const calls: string[] = [] + let failing = 2 + vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { + if (isPageCall(expression, 'readDomQuiet')) { + calls.push('probe') + if (failing > 0) { + failing-- + vi.mocked(contents.isLoading).mockReturnValue(true) + return Promise.reject(new Error('Execution context was destroyed')) + } + vi.mocked(contents.isLoading).mockReturnValue(false) + return Promise.resolve({ quietMs: failing === -1 ? 0 : 400, loading: false }) + } + if (isPageCall(expression, 'collectSnapshot')) { + calls.push('snapshot') + return Promise.resolve({ + url: 'https://example.com/', + title: 'Example', + outline: '- heading "Results"', + truncated: false, + refIds: [], + refLineIndexes: {}, + nextElementId: 1_000, + }) + } + if (isPageCall(expression, 'clickElement')) return Promise.resolve(CLICK_TARGET) + return Promise.resolve(undefined) + }) + vi.useFakeTimers() + try { + const settled = driver.executeTool('chat-test', 'browser_click', { + elementId: 0, + observe: {}, + }) + await vi.advanceTimersByTimeAsync(5_000) + expect(await settled).toMatchObject({ ok: true, result: { observation: { ok: true } } }) + expect(calls.indexOf('snapshot')).toBe(3) + + calls.length = 0 + failing = -1 + const cancelled = driver.executeTool( + 'chat-test', + 'browser_click', + { elementId: 0, observe: { query: 'Results' } }, + 'click-cancelled' + ) + await vi.advanceTimersByTimeAsync(300) + expect(driver.cancelTool('chat-test', 'click-cancelled')).toBe(true) + await vi.advanceTimersByTimeAsync(5_000) + await cancelled + expect(calls).not.toContain('snapshot') + } finally { + vi.useRealTimers() + } + }) + it('acts on a ref an earlier snapshot of the same page issued', async () => { const contents = await openPage() respondWith(contents, { diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 02ae25dc4b3..5a35e1d789e 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -70,6 +70,7 @@ import { readActiveElementState, readCheckableElementState, readChildFrameElementState, + readDomQuiet, readFormFieldState, readPageActionState, readPageText, @@ -2062,6 +2063,9 @@ async function prepareTypingSurface( const TRANSIENT_PROBE_ERRORS = new Set(['stale', 'not-visible', 'not-editable']) const SETTLE_GRACE_MS = 1_000 const SETTLE_PROBE_INTERVAL_MS = 250 +/** Structural quiet that counts as settled, and the most an observation waits for it. */ +const DOM_QUIET_MS = 150 +const DOM_SETTLE_MAX_MS = 2_000 /** * First-touch page probe with a short settle grace. A live view re-rendering @@ -2096,6 +2100,33 @@ async function execInPageWithSettleGrace( } } +/** + * Lets a page finish rendering what an action started (a route change, a fetched list) before it + * is observed, so the observation is not a half-rendered page. A page already quiet costs one probe. + */ +async function waitForDomQuiet( + target: PageExecutionTarget, + signal: AbortSignal, + executionDeadline?: number +) { + const until = Math.min( + Date.now() + DOM_SETTLE_MAX_MS, + executionDeadline ?? Number.POSITIVE_INFINITY + ) + while (!signal.aborted && Date.now() + 25 < until) { + const probe = await execInPage(target, readDomQuiet, [], false, executionDeadline).catch( + () => null + ) + // A failed probe while the tab loads is a document swapping mid-navigation, so keep waiting; + // one that fails on a loaded tab (a page that cannot be scripted) has nothing to settle. + if (probe === null && !('isLoading' in target && target.isLoading())) return + const state = toRecord(probe) + const quietMs = typeof state.quietMs === 'number' ? state.quietMs : 0 + if (quietMs >= DOM_QUIET_MS && state.loading === false) return + await interruptibleSleep(Math.min(100, Math.max(25, DOM_QUIET_MS - quietMs)), signal) + } +} + function pointFromPrepared(prepared: Record): { x: number; y: number } { return { x: prepared.x as number, y: prepared.y as number } } @@ -5460,15 +5491,27 @@ export async function executeTool( } return result }, - (query) => - executeToolInner( + async (query) => { + await waitForDomQuiet( + session.requireAutomationTab().view.webContents, + executionController.signal, + executionDeadline + ) + assertCurrentExecution() + if (executionController.signal.aborted) { + throw new ToolError( + 'This browser action was cancelled before it could observe the page.' + ) + } + return executeToolInner( query === undefined ? 'browser_snapshot' : 'browser_find', query === undefined ? {} : { query }, assertCurrentExecution, executionDeadline, invocationEpoch, executionController.signal - ), + ) + }, assertCurrentExecution ) const cancellableExecution = Promise.race([execution, cancellation]) diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index 2c394eba4b2..efc9711dfa8 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -17,6 +17,7 @@ import { readActiveElementState, readCheckableElementState, readChildFrameElementState, + readDomQuiet, readPageActionState, readPageText, readSelectElementState, @@ -526,6 +527,28 @@ describe('collectSnapshot', () => { expect(outline).not.toContain('button "x" [ref=999]') }) + it('counts changes inside a shadow root the snapshot walked as page activity', async () => { + vi.useFakeTimers({ toFake: ['performance'] }) + try { + document.body.innerHTML = '
' + const host = visible(document.querySelector('#host') as HTMLDivElement) + const shadow = host.attachShadow({ mode: 'open' }) + shadow.innerHTML = '' + visible(shadow.querySelector('button') as HTMLButtonElement) + collectSnapshot() + await Promise.resolve() + vi.advanceTimersByTime(500) + expect((readDomQuiet() as { quietMs: number }).quietMs).toBeGreaterThanOrEqual(500) + + shadow.append(document.createElement('ul')) + await Promise.resolve() + + expect((readDomQuiet() as { quietMs: number }).quietMs).toBeLessThan(500) + } finally { + vi.useRealTimers() + } + }) + it('marks elements that appeared since the previous snapshot as new', () => { document.body.innerHTML = '' visible(document.querySelector('button') as HTMLButtonElement) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index a938eba740d..4e726c82ee7 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -56,6 +56,11 @@ declare global { observedRoots: WeakSet }> __simAgentNextElementId?: number + /** When the document last changed structurally (nodes or text), for settling before an observation. */ + __simAgentDomChangedAt?: number + __simAgentDomWatcher?: MutationObserver + /** Adds a document or open shadow root to the settle watcher, once. */ + __simAgentWatchDom: (root: Node) => void /** Every ref this document issued, resolvable while its element keeps that identity. */ __simAgentIssuedRefs?: Map< number, @@ -85,12 +90,37 @@ export function serializePageCall(fn: (...args: never[]) => unknown, args: unkno return `((${String(installPageHelpers)})(), (${String(fn)}).apply(null, ${JSON.stringify(args)}))` } +/** How long the document has gone without a structural change, and whether it is still loading. */ +export function readDomQuiet(): unknown { + return { + quietMs: performance.now() - (window.__simAgentDomChangedAt ?? 0), + loading: document.readyState !== 'complete', + } +} + /** * Installs the helpers page functions share. Page functions are serialized and run standalone, * so the driver runs this installer before each one instead of inlining shared code into every * function. */ export function installPageHelpers(): void { + // Structural changes only: attribute churn (animations, hover styles) would keep a page "busy". + // Snapshots add the same-origin frames and open shadow roots they walk, which a document-level + // observer cannot see into. + if (!window.__simAgentDomWatcher) { + window.__simAgentDomChangedAt = performance.now() + window.__simAgentDomWatcher = new MutationObserver(() => { + window.__simAgentDomChangedAt = performance.now() + }) + } + const watcher = window.__simAgentDomWatcher + const watched = new WeakSet() + window.__simAgentWatchDom = (root: Node): void => { + if (watched.has(root)) return + watched.add(root) + watcher.observe(root, { subtree: true, childList: true, characterData: true }) + } + if (document.documentElement) window.__simAgentWatchDom(document.documentElement) /** * The open modal each document aria-hid together with everything else. MUI's ModalManager * aria-hides every child except the modal's mount node, and a `disablePortal` modal @@ -889,6 +919,7 @@ export function collectSnapshot( try { const innerDoc = (el as HTMLIFrameElement).contentDocument if (innerDoc?.body && isVisible(el)) { + window.__simAgentWatchDom(innerDoc.documentElement) if (!push(`${indent}- iframe:`)) return walk(innerDoc.body.childNodes, childDepth + 1, coveredText) } else if (scopedRoot && !innerDoc && visible) { @@ -901,7 +932,10 @@ export function collectSnapshot( } const shadow = (el as HTMLElement).shadowRoot - if (shadow) walk(shadow.childNodes, childDepth, coveredText) + if (shadow) { + window.__simAgentWatchDom(shadow) + walk(shadow.childNodes, childDepth, coveredText) + } walk(el.childNodes, childDepth, coveredText) } } diff --git a/apps/desktop/src/main/computer-use/service.test.ts b/apps/desktop/src/main/computer-use/service.test.ts index 67d81dff4f4..7a66b868db1 100644 --- a/apps/desktop/src/main/computer-use/service.test.ts +++ b/apps/desktop/src/main/computer-use/service.test.ts @@ -12,11 +12,13 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -function setup(enabled = true) { +/** `askForApps` defaults on so the approval tests exercise the prompt; the default device mode asks for nothing. */ +function setup(enabled = true, askForApps = true) { const root = mkdtempSync(join(tmpdir(), 'sim-computer-use-test-')) roots.push(root) const config = createConfigStore(join(root, 'settings.json')) config.set('computerUseEnabled', enabled) + config.set('computerUseAskForApps', askForApps) let sequence = 0 const request = vi.fn(async (method: string): Promise => { if (method === 'status' || method === 'request_permission') @@ -412,6 +414,25 @@ describe('native computer use authority and lifecycle', () => { await expect(click('s1')).rejects.toThrow('stale') }) + it('operates any unprotected app without asking or saving an approval by default', async () => { + const { state, click, service, native, approveApp, onActivity } = setup(true, false) + await state() + await click('s1') + expect(approveApp).not.toHaveBeenCalled() + expect(service.listAppPermissions()).toEqual([]) + expect(onActivity).toHaveBeenCalledWith(expect.objectContaining({ appName: 'Fixture' })) + expect(native.request.mock.calls.filter(([method]) => method === 'list_apps')).toHaveLength(1) + }) + + it('asks for new apps only once the user turns asking on, and clears that choice on reset', async () => { + const { state, service, approveApp } = setup(true, false) + await expect(service.setAskForApps(true)).resolves.toMatchObject({ askForApps: true }) + approveApp.mockResolvedValueOnce('deny') + await expect(state()).rejects.toThrow('denied') + service.reset() + expect((await service.getStatus()).askForApps).toBe(false) + }) + it('clears grants and opt-in on account reset', async () => { const { state, service, native } = setup() await state() diff --git a/apps/desktop/src/main/computer-use/service.ts b/apps/desktop/src/main/computer-use/service.ts index 3f9cfd3fd02..afacf4696f3 100644 --- a/apps/desktop/src/main/computer-use/service.ts +++ b/apps/desktop/src/main/computer-use/service.ts @@ -51,6 +51,8 @@ export class ComputerUseService { private snapshots = new Map() private activity: ComputerUseActivity | null = null private taskGrants = new Map>() + /** App names from the helper's app list, so an unprompted action can name its app without listing apps again. */ + private appNames = new Map() constructor(private readonly deps: ComputerUseServiceDeps) {} @@ -58,6 +60,10 @@ export class ComputerUseService { return this.deps.supported && this.deps.config.get('computerUseEnabled') === true } + private asksForApps(): boolean { + return this.deps.config.get('computerUseAskForApps') === true + } + getActivity(): ComputerUseActivity | null { return this.activity } @@ -68,6 +74,7 @@ export class ComputerUseService { enabled: this.isEnabled(), permissions: { accessibility: false, screenCapture: false }, activeAction: this.activity, + askForApps: this.asksForApps(), } if (!status.enabled) return status const native = await this.deps.native.request('status', {}) @@ -94,6 +101,16 @@ export class ComputerUseService { return this.getStatus() } + async setAskForApps(ask: boolean): Promise { + const previous = this.asksForApps() + this.deps.config.set('computerUseAskForApps', ask) + if (!this.deps.config.flush()) { + this.deps.config.set('computerUseAskForApps', previous) + throw new Error('Could not save Computer Use settings.') + } + return this.getStatus() + } + async requestPermission( permission: 'accessibility' | 'screenCapture' ): Promise { @@ -127,7 +144,9 @@ export class ComputerUseService { reset(): void { this.cancel() + this.appNames.clear() this.deps.config.set('computerUseEnabled', false) + this.deps.config.set('computerUseAskForApps', false) this.deps.config.set('computerUseAllowedApps', []) if (!this.deps.config.flush()) throw new Error('Could not clear saved Computer Use permissions.') @@ -338,16 +357,14 @@ export class ComputerUseService { }) } + /** Every unprotected app is allowed unless the user chose to approve each app's first use. */ private async authorizeApp(bundleId: string, work: ToolWork): Promise { const approved = this.listAppPermissions().find((app) => app.bundleId === bundleId) if (approved) return approved.displayName const taskName = this.taskGrants.get(work.scopeId)?.get(bundleId) if (taskName) return taskName - const apps = await this.deps.native.request('list_apps', {}) - this.check(work) - const app = - apps.kind === 'apps' ? apps.apps.find((app) => app.bundleId === bundleId) : undefined - const target = { bundleId, displayName: app?.name ?? bundleId } + const target = { bundleId, displayName: await this.appName(bundleId, work) } + if (!this.asksForApps()) return target.displayName const answer = await this.deps.approveApp(target, work.controller.signal) this.check(work) if (answer === 'deny') @@ -371,4 +388,18 @@ export class ComputerUseService { } return target.displayName } + + private async appName(bundleId: string, work: ToolWork): Promise { + const known = this.appNames.get(bundleId) + if (known) return known + const apps = await this.deps.native.request('list_apps', {}) + this.check(work) + if (apps.kind === 'apps') { + this.appNames.clear() + for (const app of apps.apps) this.appNames.set(app.bundleId, app.name) + } + /** An app outside the scanned folders keeps its bundle ID, so it is not rescanned on every action. */ + if (!this.appNames.has(bundleId)) this.appNames.set(bundleId, bundleId) + return this.appNames.get(bundleId) ?? bundleId + } } diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 28ca953f16f..e9657914f57 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -111,6 +111,8 @@ export interface DesktopSettings { preventSleepWhileRunning?: boolean /** Native app access is opt-in and cleared when the signed-in account changes. */ computerUseEnabled?: boolean + /** Ask before operating an app for the first time; by default every unprotected app is allowed. */ + computerUseAskForApps?: boolean computerUseAllowedApps?: Array<{ bundleId: string; displayName: string }> /** Device-wide browser page appearance; `app` follows Sim. */ browserTheme?: 'app' | 'light' | 'dark' diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index b5580f76393..030c9620362 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -583,6 +583,7 @@ describe('registerIpcHandlers', () => { computerRoots.push(root) const config = createConfigStore(join(root, 'settings.json')) config.set('computerUseEnabled', true) + config.set('computerUseAskForApps', true) const status: ComputerUseResult = { kind: 'status', platform: 'darwin', diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index b88167462ac..068b315b7e7 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -751,6 +751,18 @@ export function registerIpcHandlers(deps: IpcDeps): void { return deps.computerUse?.requestPermission(permission) }, }, + 'computer-use:set-ask-for-apps': { + kind: 'invoke', + gate: 'app-origin', + requiresAccountData: true, + needsUserActivation: true, + denied: null, + handler: (ask) => { + if (typeof ask !== 'boolean') + throw new Error('A boolean Computer Use preference is required.') + return deps.computerUse?.setAskForApps(ask) + }, + }, 'computer-use:list-permissions': { kind: 'invoke', gate: 'app-origin', diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b59f2e933fa..7f06f2b5180 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -131,6 +131,7 @@ const api: SimDesktopApi = { setEnabled: (enabled: boolean) => ipcRenderer.invoke('computer-use:set-enabled', enabled), requestPermission: (permission: 'accessibility' | 'screenCapture') => ipcRenderer.invoke('computer-use:request-permission', permission), + setAskForApps: (ask: boolean) => ipcRenderer.invoke('computer-use:set-ask-for-apps', ask), listAppPermissions: () => ipcRenderer.invoke('computer-use:list-permissions'), revokeApp: (bundleId: string) => ipcRenderer.invoke('computer-use:revoke-app', bundleId), executeTool: (toolCallId: string, params: ComputerUseInput) => diff --git a/apps/docs/components/docs/table-of-contents.tsx b/apps/docs/components/docs/table-of-contents.tsx index 4f4b5289e3c..fb6da145b37 100644 --- a/apps/docs/components/docs/table-of-contents.tsx +++ b/apps/docs/components/docs/table-of-contents.tsx @@ -19,9 +19,18 @@ export function TableOfContents({ headings, active, compact = false }: TableOfCo {headings.map(({ id, title, depth }) => (
  • { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return const details = event.currentTarget.closest('details') if (details) details.open = false }} @@ -40,7 +49,16 @@ export function TableOfContents({ headings, active, compact = false }: TableOfCo ) return compact ? ( -
    +
    { + if (event.key !== 'Escape' || !event.currentTarget.open) return + event.preventDefault() + event.stopPropagation() + event.currentTarget.open = false + event.currentTarget.querySelector('summary')?.focus() + }} + > On this page diff --git a/apps/docs/components/ui/heading-link.tsx b/apps/docs/components/ui/heading-link.tsx new file mode 100644 index 00000000000..052a5de1a1e --- /dev/null +++ b/apps/docs/components/ui/heading-link.tsx @@ -0,0 +1,53 @@ +'use client' + +import { useState } from 'react' +import { useCopyToClipboard } from '@sim/emcn' +import { Check, Link, TriangleAlert } from '@sim/emcn/icons' +import { cn } from '@/components/docs/classnames' + +interface HeadingLinkProps { + id: string +} + +/** Copies a section permalink while retaining native anchor navigation. */ +export function HeadingLink({ id }: HeadingLinkProps) { + const { copied, copy } = useCopyToClipboard() + const [failed, setFailed] = useState(false) + const announcement = failed + ? 'Could not copy. Copy the link from the address bar.' + : copied + ? 'Link copied' + : '' + const Icon = failed ? TriangleAlert : copied ? Check : Link + + return ( + <> + { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return + setFailed(!(await copy(event.currentTarget.href))) + }} + className={cn( + 'pointer-coarse:before:-inset-3.5 relative ml-2 inline-flex rounded align-middle text-[var(--docs-muted)] opacity-0 pointer-coarse:opacity-100 transition-opacity pointer-coarse:before:absolute hover:text-[var(--docs-link)] focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-[var(--docs-link)] focus-visible:outline-offset-2 group-hover/heading:opacity-100 motion-reduce:transition-none', + (copied || failed) && 'opacity-100' + )} + > + + + + {announcement} + + + ) +} diff --git a/apps/docs/components/ui/heading.tsx b/apps/docs/components/ui/heading.tsx index 2aa4d508e6f..d91a8af80d3 100644 --- a/apps/docs/components/ui/heading.tsx +++ b/apps/docs/components/ui/heading.tsx @@ -1,6 +1,6 @@ import type { HTMLAttributes } from 'react' -import { Link } from '@sim/emcn/icons' import { cn } from '@/components/docs/classnames' +import { HeadingLink } from '@/components/ui/heading-link' type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' interface HeadingProps extends HTMLAttributes { @@ -29,15 +29,7 @@ export function Heading({ as: Tag = 'h2', className, children, id, ...props }: H )} > {children} - {id && ( - - - - )} + {id && } ) } diff --git a/apps/docs/content/docs/cli/knowledge.mdx b/apps/docs/content/docs/cli/knowledge.mdx index 1a03bb030e4..2d424c49bc1 100644 --- a/apps/docs/content/docs/cli/knowledge.mdx +++ b/apps/docs/content/docs/cli/knowledge.mdx @@ -1040,7 +1040,7 @@ sim knowledge connectors create [options] | Option | Required | Description | | --- | --- | --- | | `--connector-type ` | Yes | Registered connector type. | -| `--credential-id ` | No | OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or, when this deployment enables it, a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors. | +| `--credential-id ` | No | OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors. | | `--api-key ` | No | Write-only API key for connector types whose `auth.mode` is `apiKey` (see connector types), or a personal access token for an OAuth connector that also accepts one, such as GitHub. Send it instead of `credentialId`. Pass a raw key, or a secret reference written as the whole value `{{SECRET_NAME}}`, which the server resolves; `$SECRET_NAME` is not a reference. | | `--source-config ` | Yes | Connector-specific source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | | `--sync-interval-minutes ` | No | Scheduled synchronization interval in minutes; zero disables scheduling. Defaults to 1440. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 5edce25dba9..6aaec498ffd 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -2649,7 +2649,7 @@ sim knowledge connectors create [options] | Option | Required | Description | | --- | --- | --- | | `--connector-type ` | Yes | Registered connector type. | -| `--credential-id ` | No | OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or, when this deployment enables it, a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors. | +| `--credential-id ` | No | OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors. | | `--api-key ` | No | Write-only API key for connector types whose `auth.mode` is `apiKey` (see connector types), or a personal access token for an OAuth connector that also accepts one, such as GitHub. Send it instead of `credentialId`. Pass a raw key, or a secret reference written as the whole value `{{SECRET_NAME}}`, which the server resolves; `$SECRET_NAME` is not a reference. | | `--source-config ` | Yes | Connector-specific source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | | `--sync-interval-minutes ` | No | Scheduled synchronization interval in minutes; zero disables scheduling. Defaults to 1440. | diff --git a/apps/docs/content/docs/desktop/index.mdx b/apps/docs/content/docs/desktop/index.mdx index 393f553975f..5fe39e511b7 100644 --- a/apps/docs/content/docs/desktop/index.mdx +++ b/apps/docs/content/docs/desktop/index.mdx @@ -91,19 +91,20 @@ By default, local file tools ask for folder access when they need an unapproved ### Native Mac-app control -**Computer Use** appears under **Settings → Account → Desktop** only when the deployment and installed app provide it. It requires **macOS 14 or later** and is off by default on each Mac. Browser, Terminal, and local file permissions are separate controls. +**Computer Use** appears under **Settings → Account → Desktop** when the installed app provides it. It requires **macOS 14 or later** and is off by default on each Mac. Organization admins can withhold it with the **Computer Use** permission-group restriction. Browser, Terminal, and local file permissions are separate controls. To enable it when available: -1. Open **Computer Use** and turn **Allow Mothership to use Mac apps** to **On** to allow Sim to use native apps. -2. Use **Open System Settings** to grant **Accessibility** for app interaction and **Screen Recording** for screenshots. Grant these permissions yourself, then return to Sim to refresh their status. -3. When Sim requests an app, choose **Allow for This Task**, **Always Allow**, or **Cancel**. **Always Allow** saves that app's permission on this Mac. +1. Turn **Use apps on this Mac** to **On**. +2. Select **Allow** next to **Accessibility** (app interaction) and **Screen Recording** (screenshots), grant them in System Settings, then return to Sim. -Sim can read approved app windows and interact through clicks, text, keyboard shortcuts, scrolling, and supported accessibility controls. Screenshots and returned app content can be sent to your Sim deployment and model. Some actions require bringing the app to the foreground; app compatibility and macOS permissions can still limit what succeeds. +Sim can then use any app except terminals and scripting apps (Script Editor, Automator, Shortcuts), password managers, and macOS System Settings (and Sim itself); shell work goes through **Terminal** access instead. To approve each app the first time Sim uses it, turn on **Ask before using a new app**; Sim then offers **Allow for This Task**, **Always Allow**, or **Cancel**. + +Sim can read app windows and interact through clicks, text, keyboard shortcuts, scrolling, and supported accessibility controls. Screenshots and returned app content can be sent to your Sim deployment and model. Some actions require bringing the app to the foreground; app compatibility and macOS permissions can still limit what succeeds. The conversation shows the active app and action. Select **Stop**, or press **Cmd + Shift + Escape** when that shortcut is available, to cancel active and queued computer actions. Stopping cannot undo input already delivered to an app. Verify the app's current state before repeating an interrupted action. -Use **Revoke** under **Approved apps** to remove a saved permission, or switch Computer Use off. Signing out or changing deployments clears the device opt-in and app approvals. Computer Use cannot operate Sim's own permission controls or macOS System Settings. +With **Ask before using a new app** on, use **Revoke** under **Approved apps** to remove a saved approval. Signing out or changing deployments clears the device opt-in and app approvals. Computer Use never operates Sim, macOS System Settings, password managers, terminals, or scripting apps. ### Terminal access diff --git a/apps/docs/content/docs/knowledgebase/connectors.mdx b/apps/docs/content/docs/knowledgebase/connectors.mdx index 8c98e6ced2e..828d59cf760 100644 --- a/apps/docs/content/docs/knowledgebase/connectors.mdx +++ b/apps/docs/content/docs/knowledgebase/connectors.mdx @@ -46,7 +46,7 @@ API-key and personal-access-token connections use the credential shown in their | Azure DevOps | PAT with Project and Team Read, Wiki Read, Work Items Read, and Code Read. | | GitLab | PAT with `read_api` and access to the selected project; permission-aware methods have additional requirements. | | YouTube | YouTube Data API key from Google Cloud Console. | -| Amazon S3 | Secret Access Key; enter Access Key ID, region, and bucket as configuration fields. Where enabled, **Authentication → Saved AWS credential** uses a workspace AWS credential instead (access keys, an assumed IAM role, or a keyless role trusted through Sim OIDC); it signs only for Amazon S3 in a commercial or GovCloud AWS region (not China or isolated regions), so it has no Access Key ID or custom endpoint. Either way, the identity needs `s3:ListBucket` on the bucket and `s3:GetObject` on the synced objects. | +| Amazon S3 | Secret Access Key; enter Access Key ID, region, and bucket as configuration fields. **Authentication → Saved AWS credential** uses a workspace AWS credential instead (access keys, an assumed IAM role, or a keyless role trusted through Sim OIDC); it signs only for Amazon S3 in a commercial or GovCloud AWS region (not China or isolated regions), so it has no Access Key ID or custom endpoint. Either way, the identity needs `s3:ListBucket` on the bucket and `s3:GetObject` on the synced objects. | | Sentry | Auth token with `project:read` and `event:read`. | | PagerDuty | Create a [REST API key](https://support.pagerduty.com/main/docs/api-access-keys) under **Integrations → Developer Tools → API Access Keys**. | | SFTP | Password or unencrypted private key, plus a required SHA-256 host-key fingerprint; enter host, port, username, and root path as configuration fields. | diff --git a/apps/docs/content/docs/platform/enterprise/data-retention.mdx b/apps/docs/content/docs/platform/enterprise/data-retention.mdx index b12a72e6054..c3252f45023 100644 --- a/apps/docs/content/docs/platform/enterprise/data-retention.mdx +++ b/apps/docs/content/docs/platform/enterprise/data-retention.mdx @@ -118,6 +118,8 @@ Redaction applies to text strings at the configured workflow stages. It does not If log redaction fails, affected strings are replaced with `[REDACTION_FAILED]` rather than stored unmasked. Failure at Workflow input or Block outputs stops the run instead of passing unredacted values downstream. +Base64-encoded file content, such as a `data:` URL or a long base64 string, can't be checked for PII as text, because any PII is inside the encoded file. In stored logs it is replaced with `[BINARY_CONTENT_REDACTED]`. At Workflow input and Block outputs it is masked like any other string. **Run from block** and re-running with a previous run's input read that run's log, so they see the marker in place of that content, the same way they see masked PII. + ## Self-hosted setup ### Enable cleanup and schedule it diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index b490259f54c..68cbae44ef8 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -7155,7 +7155,7 @@ "description": "Registered connector type." }, "credentialId": { - "description": "OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or, when this deployment enables it, a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors.", + "description": "OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors.", "type": "string", "minLength": 1, "maxLength": 255 diff --git a/apps/docs/scripts/test-navigation-e2e.ts b/apps/docs/scripts/test-navigation-e2e.ts index e6842fb0036..911d3ec98f5 100644 --- a/apps/docs/scripts/test-navigation-e2e.ts +++ b/apps/docs/scripts/test-navigation-e2e.ts @@ -178,6 +178,119 @@ try { ? await (engine === 'webkit' ? webkit : chromium).launch() : await webkit.connect(webkitEndpoint, { timeout: 180_000 }) const feedbackBrowser = browser + await check( + 'Section permalinks copy the full URL and preserve native link navigation', + async () => { + const context = await feedbackBrowser.newContext({ + permissions: ['clipboard-read', 'clipboard-write'], + viewport: { width: 1440, height: 900 }, + }) + try { + const page = await context.newPage() + await open(page, '/files/editor?view=reference') + const formatting = page.locator('#formatting-text').getByRole('link') + await formatting.click() + const first = new URL('/files/editor?view=reference#formatting-text', target).href + await expect(page).toHaveURL(first) + await expect(formatting).toHaveAccessibleName('Link copied') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(first) + + const tables = page.locator('#tables').getByRole('link') + await tables.focus() + await page.keyboard.press('Enter') + const second = new URL('/files/editor?view=reference#tables', target).href + await expect(page).toHaveURL(second) + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(second) + const [newTab] = await Promise.all([ + context.waitForEvent('page', { timeout: 10_000 }), + page + .locator('#code-blocks') + .getByRole('link') + .click({ modifiers: ['ControlOrMeta'] }), + ]) + await newTab.waitForLoadState('domcontentloaded') + await expect(newTab).toHaveURL( + new URL('/files/editor?view=reference#code-blocks', target).href + ) + await newTab.close() + await page.bringToFront() + await expect(page).toHaveURL(second) + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(second) + await page.screenshot({ path: join(directory, 'section-permalink.png') }) + } finally { + await context.close() + } + }, + engine === 'webkit' + ? 'Playwright WebKit cannot grant clipboard read/write permissions; Chromium verifies native clipboard contents.' + : undefined + ) + await check('Section permalink navigation survives an unavailable clipboard', async () => { + const context = await feedbackBrowser.newContext() + try { + await context.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { value: undefined }) + }) + const page = await context.newPage() + await open(page, '/files/editor') + const link = page.locator('#formatting-text').getByRole('link') + await link.click() + await expect(page).toHaveURL(new URL('/files/editor#formatting-text', target).href) + await expect(link).toHaveAccessibleName('Could not copy. Copy the link from the address bar.') + await expect(page.getByRole('status').filter({ hasText: 'Could not copy.' })).toBeVisible() + await page.screenshot({ path: join(directory, 'section-permalink-copy-unavailable.png') }) + await page + .getByRole('navigation', { name: 'Page navigation', exact: true }) + .getByRole('link', { name: 'Previous Files', exact: true }) + .click() + await expect(page).toHaveURL(new URL('/files', target).href) + await expect( + page.getByRole('link', { name: 'Could not copy. Copy the link from the address bar.' }) + ).toHaveCount(0) + } finally { + await context.close() + } + }) + await check( + 'Compact page outline dismisses with Escape and preserves new-tab clicks', + async () => { + for (const width of [390, 1100]) { + const context = await feedbackBrowser.newContext({ viewport: { width, height: 900 } }) + try { + const page = await context.newPage() + await open(page, '/files/editor') + const summary = page + .locator('summary') + .filter({ hasText: 'On this page' }) + .filter({ visible: true }) + const details = summary.locator('..') + await summary.click() + await expect(details).toHaveAttribute('open', '') + await details.getByRole('link', { name: 'Formatting text', exact: true }).focus() + await page.keyboard.press('Escape') + await expect(details).not.toHaveAttribute('open') + await expect(summary).toBeFocused() + + await summary.click() + const link = details.getByRole('link', { name: 'Formatting text', exact: true }) + const [newTab] = await Promise.all([ + context.waitForEvent('page', { timeout: 10_000 }), + link.click({ modifiers: ['ControlOrMeta'] }), + ]) + await newTab.waitForLoadState('domcontentloaded') + await expect(newTab).toHaveURL(new URL('/files/editor#formatting-text', target).href) + await expect(details).toHaveAttribute('open', '') + await newTab.close() + await link.click() + await expect(page).toHaveURL(new URL('/files/editor#formatting-text', target).href) + await expect(details).not.toHaveAttribute('open') + await page.screenshot({ path: join(directory, `section-outline-${width}.png`) }) + } finally { + await context.close() + } + } + } + ) 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 diff --git a/apps/pii/server.py b/apps/pii/server.py index 6934daf8355..405d96e13bc 100644 --- a/apps/pii/server.py +++ b/apps/pii/server.py @@ -184,6 +184,7 @@ def build_analyzer() -> AnalyzerEngine: anonymizer: AnonymizerEngine NER_ENTITIES: frozenset _BLANK_ARTIFACTS: dict +_NLP_MAX_LENGTH: dict # Every entity the spaCy NER recognizers can actually produce. A request touching # any of these must run spaCy; a request naming only non-NER (regex/checksum) @@ -227,6 +228,96 @@ def _regex_only(entities: list[str] | None, score_threshold: float | None) -> bo ) +# spaCy refuses (error E088) any text longer than its model's `max_length`, so a +# longer text runs NER in windows of this many characters, following spaCy's +# guidance to segment long documents (it sizes NER memory at roughly 1GB per +# 100,000 characters of one text). +NER_WINDOW_CHARS = 100_000 +# How much of the previous window each window repeats. Far longer than any entity +# spaCy tags, so an entity cut by one window's edge lies whole inside the next. +NER_WINDOW_OVERLAP = 1_000 + + +def _exceeds_nlp_length(text: str, language: str) -> bool: + limit = _NLP_MAX_LENGTH.get(language) + return limit is not None and len(text) > limit + + +def _window_entities( + entities: list[str] | None, language: str, score_threshold: float | None +) -> list[str]: + """The requested built-in entities this language serves that windows must find, + or all of them when `entities` is empty (detect-all). Without a score_threshold + that is only what spaCy NER produces; with one it also includes the pattern + entities, whose context-boosted score can decide whether a match clears the + threshold and needs the real NLP artifacts only a window has.""" + served = set(analyzer.get_supported_entities(language)) + if score_threshold is None or score_threshold <= 0: + served &= NER_ENTITIES + return sorted(served if not entities else served.intersection(entities)) + + +def _analyze_long( + text: str, + language: str, + entities: list[str] | None, + score_threshold: float | None, + return_decision_process: bool = False, + ad_hoc_recognizers: list[PatternRecognizer] | None = None, +) -> list[RecognizerResult]: + """Analyze a text longer than spaCy's `max_length`. + + Pattern, checksum, and custom recognizers have no length limit, so they run + once over the whole text (against blank NLP artifacts, as the regex-only path + does) and no match can straddle a cut. Only NER runs in overlapping windows + (see NER_WINDOW_CHARS), asked for just its own entities, plus the pattern + entities when a score_threshold makes context scoring matter (see + `_window_entities`). Every window's spans are kept, shifted to whole-text + offsets: NER reads context, so a window can find a name its neighbour missed + or cut short. A span found more than once keeps its highest score, and + overlapping spans of one type are merged by the anonymizer.""" + entities = entities or None + results = analyzer.analyze( + text=text, + language=language, + entities=entities, + score_threshold=score_threshold, + return_decision_process=return_decision_process, + nlp_artifacts=_BLANK_ARTIFACTS.get(language), + ad_hoc_recognizers=ad_hoc_recognizers or None, + ) + window_entities = _window_entities(entities, language, score_threshold) + if not window_entities: + return results + step = NER_WINDOW_CHARS - NER_WINDOW_OVERLAP + for start in range(0, len(text), step): + for result in analyzer.analyze( + text=text[start : start + NER_WINDOW_CHARS], + language=language, + entities=window_entities, + score_threshold=score_threshold, + return_decision_process=return_decision_process, + ): + results.append( + RecognizerResult( + result.entity_type, + result.start + start, + result.end + start, + result.score, + result.analysis_explanation, + result.recognition_metadata, + ) + ) + if start + NER_WINDOW_CHARS >= len(text): + break + unique: dict[tuple[str, int, int], RecognizerResult] = {} + for result in results: + key = (result.entity_type, result.start, result.end) + if key not in unique or result.score > unique[key].score: + unique[key] = result + return list(unique.values()) + + def _analyze_one( text: str, language: str, @@ -235,12 +326,15 @@ def _analyze_one( return_decision_process: bool = False, ad_hoc_recognizers: list[PatternRecognizer] | None = None, ): + regex_only = _regex_only(entities, score_threshold) + if not regex_only and _exceeds_nlp_length(text, language): + return _analyze_long( + text, language, entities, score_threshold, return_decision_process, ad_hoc_recognizers + ) # Regex-only requests reuse a blank NlpArtifacts to skip the spaCy NLP pass; # otherwise analyze() computes artifacts (runs spaCy) as usual. Custom-pattern # recognizers are regex-based, so they run fine against the blank artifacts. - nlp_artifacts = ( - _BLANK_ARTIFACTS.get(language) if _regex_only(entities, score_threshold) else None - ) + nlp_artifacts = _BLANK_ARTIFACTS.get(language) if regex_only else None return analyzer.analyze( text=text, language=language, @@ -264,7 +358,9 @@ def _analyze_many( score_threshold: float | None, ad_hoc_recognizers: list[PatternRecognizer] | None = None, ): - """Analyze many texts, skipping the spaCy pass for regex-only requests.""" + """Analyze many texts, skipping the spaCy pass for regex-only requests and + analyzing any text longer than spaCy's `max_length` on its own (see + `_analyze_long`).""" if _regex_only(entities, score_threshold): blank = _BLANK_ARTIFACTS.get(language) return [ @@ -278,21 +374,29 @@ def _analyze_many( ) for text in texts ] - return list( - batch_analyzer.analyze_iterator( - texts=texts, - language=language, - batch_size=NLP_BATCH_SIZE, - entities=entities or None, - score_threshold=score_threshold, - ad_hoc_recognizers=ad_hoc_recognizers or None, - ) + long_indices = {i for i, text in enumerate(texts) if _exceeds_nlp_length(text, language)} + short_indices = [i for i in range(len(texts)) if i not in long_indices] + results: list[list[RecognizerResult]] = [[] for _ in texts] + short_results = batch_analyzer.analyze_iterator( + texts=[texts[i] for i in short_indices], + language=language, + batch_size=NLP_BATCH_SIZE, + entities=entities or None, + score_threshold=score_threshold, + ad_hoc_recognizers=ad_hoc_recognizers or None, ) + for i, per_text in zip(short_indices, short_results): + results[i] = list(per_text) + for i in sorted(long_indices): + results[i] = _analyze_long( + texts[i], language, entities, score_threshold, ad_hoc_recognizers=ad_hoc_recognizers + ) + return results @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncIterator[None]: - global analyzer, batch_analyzer, anonymizer, NER_ENTITIES, _BLANK_ARTIFACTS + global analyzer, batch_analyzer, anonymizer, NER_ENTITIES, _BLANK_ARTIFACTS, _NLP_MAX_LENGTH logger.info("building analyzer (spacy)") analyzer = build_analyzer() batch_analyzer = BatchAnalyzerEngine(analyzer_engine=analyzer) @@ -319,6 +423,10 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: language: analyzer.nlp_engine.process_text("", language) for language in SUPPORTED_LANGUAGES } + _NLP_MAX_LENGTH = { + language: nlp.max_length + for language, nlp in getattr(analyzer.nlp_engine, "nlp", {}).items() + } yield diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index af6506485e3..d74c61ddb88 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -5,7 +5,6 @@ import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { createLogger } from '@sim/logger' import { useRouter, useSearchParams } from 'next/navigation' 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' @@ -112,7 +111,7 @@ function SignupFormContent({ const searchParams = useSearchParams() const { refetch: refetchSession } = useSession() const posthog = usePostHog() - const { measurement, marketing } = useTrackingConsent() + const { measurement } = useTrackingConsent() useCaptureWhenReady('signup_page_viewed', {}) const [isLoading, setIsLoading] = useState(false) @@ -352,7 +351,6 @@ function SignupFormContent({ } if (measurement) trackGoogleEvent('sign_up', { method: 'email' }) - if (marketing) trackFreebuffConversion('signup_completed', response.data.user.id) try { await refetchSession() diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx index b887ca7b617..b5004de6944 100644 --- a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx @@ -44,7 +44,7 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { title: 'Cookie Policy', description: 'What cookies Sim sets, why, how long they last, and how to change your choice at any time.', - lastUpdated: 'October 13, 2026', + lastUpdated: 'November 12, 2026', intro: [ { kind: 'paragraph', @@ -238,12 +238,6 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { ], ['personalization_id', 'X (Twitter)', 'Personalizes the ads shown on X.', '13 months'], ['muc_ads', 'X (Twitter)', 'Measures ad conversions across X domains.', '13 months'], - [ - 'bfcid', - 'Freebuff', - 'Stores the click identifier from a Freebuff ad that brought you to sim.ai, so a sign-up can be credited to that ad. Deleted if you withdraw Marketing consent.', - '30 days', - ], ]), ], }, @@ -280,8 +274,7 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { , Google Ads,{' '} X (Twitter), - and PostHog. For Freebuff,{' '} - {CHANGE_CHOICES} to withdraw Marketing consent. + and PostHog. ), }, @@ -303,7 +296,6 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { Google{' '} (Analytics and Ads),{' '} X (Twitter),{' '} - Freebuff,{' '} Ahrefs,{' '} PostHog, and{' '} Cloudflare. diff --git a/apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx b/apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx index 0a9f9c47f48..1dbfc3eff3d 100644 --- a/apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx +++ b/apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx @@ -1,15 +1,29 @@ 'use client' -import { type ReactNode, useEffect, useId, useState } from 'react' +import { type ReactNode, useEffect, useId, useRef, useState } from 'react' +import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { Chip, ChipDropdown, ChipInput, ChipTextarea, Label } from '@sim/emcn' +import { toError } from '@sim/utils/errors' import { DEMO_REQUEST_COMPANY_SIZE_OPTIONS, type DemoRequestBody, } from '@/lib/api/contracts/demo-requests' +import { getEnv } from '@/lib/core/config/env' import { isFreeEmailDomain } from '@/lib/messaging/email/free-email' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { useSubmitDemoRequest } from '@/hooks/queries/demo-requests' +/** Build-time-inlined Turnstile site key; absent when captcha isn't configured. */ +const TURNSTILE_SITE_KEY = getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY') + +/** + * How long Continue waits on the invisible challenge before sending the request + * without a token. An interactive challenge cannot be shown in the hidden + * widget, so waiting longer only delays the calendar; the server then marks the + * request unverified and the booking goes to manual review. + */ +const CAPTCHA_WAIT_MS = 5_000 + /** Options for the "What can we help you with?" select; the first is the default. */ const TOPIC_OPTIONS = [ { value: 'demo', label: 'Book a demo' }, @@ -155,17 +169,27 @@ function DemoField({ label, htmlFor, required, error, children }: DemoFieldProps * looks like an address attempt, and the primary {@link Chip} stays disabled until * every required field is valid. * - * On a valid submit it fires the inbound-demo notification to sales (via - * {@link useSubmitDemoRequest}, best-effort - never blocking) and composes a - * {@link DemoLead} (name, email, and a notes summary) handed to `onComplete`, - * which advances the card to the scheduler. + * On a valid submit it sends the demo request (via {@link useSubmitDemoRequest}) + * with an invisible Turnstile token and a honeypot `website` field, and only + * once the server has recorded it hands a {@link DemoLead} (name, email, and a + * notes summary) to `onComplete`, which advances the card to the scheduler. The + * record decides whether the booking is confirmed automatically or reviewed by + * hand, so the calendar never opens before it exists. A failed request keeps the + * form open with an inline error. */ export function DemoForm({ onComplete }: DemoFormProps) { const submitDemoRequest = useSubmitDemoRequest() const [form, setForm] = useState(INITIAL_STATE) + const [website, setWebsite] = useState('') + /** Turnstile mounts on first focus, like the scheduler preload, so its script never competes with page load. */ + const [captchaArmed, setCaptchaArmed] = useState(false) + const turnstileRef = useRef(null) + const widgetLoadedRef = useRef(false) - const setField = (key: keyof DemoFormState) => (value: string) => + const setField = (key: keyof DemoFormState) => (value: string) => { setForm((prev) => ({ ...prev, [key]: value })) + if (submitDemoRequest.isError) submitDemoRequest.reset() + } /** * Prefill the email from the `?email=` param the landing hero's CTA forwards @@ -204,24 +228,53 @@ export function DemoForm({ onComplete }: DemoFormProps) { ? 'Please use your work email address.' : undefined - const handleSubmit = () => { - if (!canSubmit) return + /** + * Runs the invisible challenge, falling back to no token when the widget never + * loaded or does not answer; the server then treats the request as unverified + * rather than rejecting it. + */ + const solveCaptcha = async (): Promise => { + const widget = turnstileRef.current + if (!TURNSTILE_SITE_KEY || !widgetLoadedRef.current || !widget) return undefined + try { + widget.reset() + widget.execute() + return await widget.getResponsePromise(CAPTCHA_WAIT_MS) + } catch { + return undefined + } + } + + const [isSolvingCaptcha, setIsSolvingCaptcha] = useState(false) + const isSubmitting = isSolvingCaptcha || submitDemoRequest.isPending + const submitError = submitDemoRequest.isError + ? toError(submitDemoRequest.error).message || 'Something went wrong. Please try again.' + : null - // Best-effort sales notification — fire-and-forget so it never blocks scheduling. - submitDemoRequest.mutate({ + const handleSubmit = async () => { + if (!canSubmit || isSubmitting) return + + const request: DemoRequestBody = { firstName: form.firstName.trim(), lastName: form.lastName.trim(), companyEmail: trimmedEmail, phoneNumber: form.phone.trim() || undefined, companySize: form.companySize as DemoRequestBody['companySize'], details: buildDetails(form), - }) + website, + } - onComplete({ + const lead: DemoLead = { name: `${form.firstName} ${form.lastName}`.trim(), email: trimmedEmail, notes: buildNotes(form), - }) + } + + setIsSolvingCaptcha(true) + const captchaToken = await solveCaptcha() + setIsSolvingCaptcha(false) + + submitDemoRequest.mutate({ ...request, captchaToken }, { onSuccess: () => onComplete(lead) }) } return ( @@ -236,114 +289,158 @@ export function DemoForm({ onComplete }: DemoFormProps) {
    { event.preventDefault() - handleSubmit() + void handleSubmit() }} aria-labelledby='demo-form-heading' - className='mt-5 flex flex-col gap-4' + className='relative mt-5 flex flex-col gap-4' noValidate + onFocusCapture={() => setCaptchaArmed(true)} > -
    - + {/* Out of flow, so neither control adds a row or a gap to the pinned card height. */} + + {/* Locked while submitting, so the request and the scheduler's prefill never go stale mid-wait. */} +
    +
    + + setField('firstName')(event.target.value)} + placeholder='Jane' + autoComplete='given-name' + /> + + + setField('lastName')(event.target.value)} + placeholder='Doe' + autoComplete='family-name' + /> + +
    + + setField('firstName')(event.target.value)} - placeholder='Jane' - autoComplete='given-name' + value={form.email} + onChange={(event) => setField('email')(event.target.value)} + error={Boolean(emailError)} + placeholder='jane@acme.co' + autoComplete='email' /> - + + setField('lastName')(event.target.value)} - placeholder='Doe' - autoComplete='family-name' + value={form.phone} + onChange={(event) => setField('phone')(event.target.value)} + placeholder='+1 (555) 000-0000' + autoComplete='tel' /> -
    - - setField('email')(event.target.value)} - error={Boolean(emailError)} - placeholder='jane@acme.co' - autoComplete='email' - /> - - - - setField('phone')(event.target.value)} - placeholder='+1 (555) 000-0000' - autoComplete='tel' - /> - - - - setField('company')(event.target.value)} - placeholder='Acme Inc.' - autoComplete='organization' - /> - - - - - - - - - - - - setField('message')(event.target.value)} - placeholder='What are you hoping to build with Sim?' - rows={3} - /> - + + setField('company')(event.target.value)} + placeholder='Acme Inc.' + autoComplete='organization' + /> + + + + + + + + + + + + setField('message')(event.target.value)} + placeholder='What are you hoping to build with Sim?' + rows={3} + /> + + + {/* + `Chip` gives its label span `flex-1`; under `fullWidth` that left-aligns the + label, so center it with `justify-center` + a `flex-none` span override. + */} + + {submitError ? ( +

    + {submitError} +

    + ) : null} - {/* - `Chip` gives its label span `flex-1`; under `fullWidth` that left-aligns the - label, so center it with `justify-center` + a `flex-none` span override. - */} - Continue + {isSubmitting ? 'Continuing…' : 'Continue'}
    diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx index c90897a0c81..8ac239179f5 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: 'November 4, 2026', + lastUpdated: 'November 12, 2026', intro: [ { kind: 'paragraph', @@ -263,7 +263,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { ], [ 'Behavioral remarketing', - 'Cookie and pixel identifiers, ad click identifiers, browser and Device data, campaign attribution, website interaction data, and, for Freebuff sign-up conversions, a pseudonymous Sim Account identifier and the sign-up time', + 'Cookie and pixel identifiers, ad click identifiers, browser and Device data, campaign attribution, and website interaction data', 'Consent — Article 6(1)(a)', 'Marketing cookies and ad personalization require Marketing consent. Google Ads may load earlier with ad storage denied and send limited cookieless consent signals. Consent may be changed or withdrawn through the cookie preferences link.', ], @@ -577,7 +577,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { { kind: 'paragraph', content: richText( - 'Sim advertises through Google Ads, X, and Freebuff, and measures which ads lead to sign-ups and demo bookings. These services use non-essential Cookies, pixels, and similar technologies. Marketing Cookies and ad personalization are enabled only after You consent to the Marketing category in the cookie banner.' + 'Sim advertises through Google Ads and X, and measures which ads lead to sign-ups and demo bookings. These services use non-essential Cookies, pixels, and similar technologies. Marketing Cookies and ad personalization are enabled only after You consent to the Marketing category in the cookie banner.' ), }, { @@ -592,12 +592,6 @@ export const PRIVACY_CONFIG: LegalPageConfig = { "**X.** The X pixel loads on Sim's marketing pages only after Marketing consent. It reports page views and demo bookings." ), }, - { - kind: 'paragraph', - content: richText( - '**Freebuff.** After Marketing consent, the Freebuff tag stores the click identifier from a Freebuff ad. When You sign up, Sim sends Freebuff that click identifier, the event type, a pseudonymous Sim Account identifier, and the sign-up time so the sign-up can be credited to the ad. Sim does not send Your name or email address.' - ), - }, { kind: 'paragraph', content: richText( @@ -621,9 +615,6 @@ export const PRIVACY_CONFIG: LegalPageConfig = { richText( 'X: [Personalization and data settings](https://x.com/settings/privacy_and_safety)' ), - richText( - 'Freebuff: withdraw Marketing consent through the cookie preferences link; Sim then deletes the Freebuff click identifier cookie' - ), ], }, ], @@ -987,7 +978,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { { kind: 'paragraph', content: richText( - 'We do not sell Personal Information for money. When Marketing Cookies are enabled, Sim makes identifiers and internet activity on sim.ai available to Google, X, and Freebuff for advertising and ad measurement. This includes cookie and click identifiers, pages visited, and conversion events. California law may treat this as a "sale" or "sharing" of Personal Information. You may opt out at any time through the cookie preferences link, by sending a Global Privacy Control signal, or by emailing privacy@sim.ai. Sim does not knowingly sell or share the Personal Information of consumers under 16.' + 'We do not sell Personal Information for money. When Marketing Cookies are enabled, Sim makes identifiers and internet activity on sim.ai available to Google and X for advertising and ad measurement. This includes cookie and click identifiers, pages visited, and conversion events. California law may treat this as a "sale" or "sharing" of Personal Information. You may opt out at any time through the cookie preferences link, by sending a Global Privacy Control signal, or by emailing privacy@sim.ai. Sim does not knowingly sell or share the Personal Information of consumers under 16.' ), }, { kind: 'subheading', text: 'Global Privacy Control' }, diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx index d274ff4e101..e6c00148435 100644 --- a/apps/sim/app/_shell/consent/consent-provider.tsx +++ b/apps/sim/app/_shell/consent/consent-provider.tsx @@ -5,8 +5,8 @@ import { TrackingConsentProvider } from '@/lib/consent/tracking-consent' import { AttributionCookieGuard } from '@/app/_shell/consent/attribution-cookie-guard' import { ConsentBanner } from '@/app/_shell/consent/consent-banner' import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider' -import { FreebuffClickIdGuard } from '@/app/_shell/consent/freebuff-click-id-guard' import { GoogleAnalyticsPageViewTracker } from '@/app/_shell/consent/google-analytics-page-view-tracker' +import { RetiredCookieCleanup } from '@/app/_shell/consent/retired-cookie-cleanup' interface ConsentProviderProps { children: ReactNode @@ -24,8 +24,8 @@ export function ConsentProvider({ children }: ConsentProviderProps) { {children} - + diff --git a/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx b/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx deleted file mode 100644 index bc221be8fb0..00000000000 --- a/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx +++ /dev/null @@ -1,21 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { clearFreebuffClickId } from '@/lib/analytics/freebuff' -import { useTrackingConsent } from '@/lib/consent/tracking-consent' - -/** - * Drops a stored Freebuff click id once consent resolves without marketing, so - * a withdrawn or expired grant can never be attributed by the server postback, - * which only sees the cookie. Withdrawal reloads the page, so this runs before - * any later signup. - */ -export function FreebuffClickIdGuard() { - const { isResolved, marketing } = useTrackingConsent() - - useEffect(() => { - if (isResolved && !marketing) clearFreebuffClickId() - }, [isResolved, marketing]) - - return null -} diff --git a/apps/sim/app/_shell/consent/retired-cookie-cleanup.tsx b/apps/sim/app/_shell/consent/retired-cookie-cleanup.tsx new file mode 100644 index 00000000000..d583f938864 --- /dev/null +++ b/apps/sim/app/_shell/consent/retired-cookie-cleanup.tsx @@ -0,0 +1,24 @@ +'use client' + +import { useEffect } from 'react' + +/** + * Cookies a removed integration may have left in returning browsers. Nothing + * reads them any more, so they are deleted whatever the visitor's consent: + * keeping an unused advertising identifier serves no one. `bfcid` lives at most + * 30 days, so this list can be emptied once that window has passed. + */ +const RETIRED_COOKIES = ['bfcid'] as const + +/** Deletes every retired cookie present in this browser, once per page load. */ +export function RetiredCookieCleanup() { + useEffect(() => { + for (const name of RETIRED_COOKIES) { + if (document.cookie.includes(`${name}=`)) { + document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax` + } + } + }, []) + + return null +} diff --git a/apps/sim/app/api/demo-requests/bookings/route.ts b/apps/sim/app/api/demo-requests/bookings/route.ts new file mode 100644 index 00000000000..e016e4f8d9d --- /dev/null +++ b/apps/sim/app/api/demo-requests/bookings/route.ts @@ -0,0 +1,95 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { type NextRequest, NextResponse } from 'next/server' +import { + calBookingRequestSchema, + type calBookingWebhookContract, + calWebhookEnvelopeSchema, +} from '@/lib/api/contracts/demo-requests' +import type { ContractJsonResponse } from '@/lib/api/contracts/types' +import { env } from '@/lib/core/config/env' +import { + assertContentLengthWithinLimit, + isPayloadSizeLimitError, + readStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { reviewDemoBookingRequest } from '@/lib/demo-booking/review' +import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' + +const logger = createLogger('DemoBookingWebhookAPI') + +const BODY_LABEL = 'Cal.com booking webhook body' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +type WebhookResponse = ContractJsonResponse + +const ACKNOWLEDGED: WebhookResponse = { ok: true } + +async function readBody(request: NextRequest): Promise { + assertContentLengthWithinLimit(request.headers, WEBHOOK_MAX_BODY_BYTES, BODY_LABEL) + const buffer = await readStreamToBufferWithLimit(request.body, { + maxBytes: WEBHOOK_MAX_BODY_BYTES, + label: BODY_LABEL, + }) + return new TextDecoder().decode(buffer) +} + +/** + * Cal.com webhook for the `/demo` event type, which requires confirmation, so + * every booking arrives as a request that stays off calendars until decided. + * Subscribe it to `BOOKING_REQUESTED` on that event type only, signed with + * `CAL_WEBHOOK_SECRET`. The signature is an HMAC-SHA256 hex digest of the raw + * body in `x-cal-signature-256`. {@link reviewDemoBookingRequest} confirms, + * declines, or leaves each request for manual review. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const secret = env.CAL_WEBHOOK_SECRET + if (!secret) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + let rawBody: string + try { + rawBody = await readBody(request) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return NextResponse.json({ error: 'Request body too large' }, { status: 413 }) + } + throw error + } + + const signature = request.headers.get('x-cal-signature-256') + if (!signature || !safeCompare(signature, hmacSha256Hex(rawBody, secret))) { + logger.warn('Rejected a demo booking webhook with an invalid signature') + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }) + } + + let json: unknown + try { + json = JSON.parse(rawBody) + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + const envelope = calWebhookEnvelopeSchema.safeParse(json) + if (!envelope.success || envelope.data.triggerEvent !== 'BOOKING_REQUESTED') { + return NextResponse.json(ACKNOWLEDGED) + } + + const booking = calBookingRequestSchema.safeParse(envelope.data.payload) + if (!booking.success) { + logger.warn('Demo booking request is missing its uid or attendee') + return NextResponse.json(ACKNOWLEDGED) + } + + const decision = await reviewDemoBookingRequest({ + bookingUid: booking.data.uid, + attendeeEmail: booking.data.attendees[0].email, + }) + logger.info('Demo booking request reviewed', { decision }) + return NextResponse.json({ ok: true, decision }) +}) diff --git a/apps/sim/app/api/demo-requests/route.ts b/apps/sim/app/api/demo-requests/route.ts index dd101b3b3e9..ca1dc395219 100644 --- a/apps/sim/app/api/demo-requests/route.ts +++ b/apps/sim/app/api/demo-requests/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' +import { after, type NextRequest, NextResponse } from 'next/server' import { formatAttributionForNotification } from '@/lib/analytics/attribution' import { getDemoRequestCompanySizeLabel, @@ -9,9 +9,11 @@ import { parseRequest } from '@/lib/api/server' import { env } from '@/lib/core/config/env' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' +import { isTurnstileConfigured, verifyTurnstileToken } from '@/lib/core/security/turnstile' import { getClientIp } from '@/lib/core/utils/request' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { type DemoBookerStatus, recordDemoBooker } from '@/lib/demo-booking/bookers' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' @@ -24,15 +26,37 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 60_000, } +const CAPTCHA_UNAVAILABLE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 3, + refillRate: 1, + refillIntervalMs: 60_000, +} + +const SUCCESS_RESPONSE = { success: true, message: 'Thanks! Our team will reach out shortly.' } +const TOO_MANY_REQUESTS_RESPONSE = { error: 'Too many requests. Please try again later.' } + +/** + * Public demo-form endpoint: per-IP rate limit, honeypot drop, captcha, then a + * record of how far the form vouched for the visitor's email, which decides + * their Cal.com booking (see {@link recordDemoBooker} and + * `lib/demo-booking/review.ts`). The form opens the calendar only after this + * answers, so a booking can never outrun its record; a failed write answers 503 + * and the visitor retries instead of booking unrecorded. The sales notification + * goes out after the response. + * + * The captcha mirrors `/api/contact`: a valid Turnstile token marks the email + * `verified`; a missing token or a Cloudflare transport error passes through the + * stricter no-captcha bucket and marks it `unverified`, so a person whose + * widget was blocked still books, pending a manual review; an invalid token is + * rejected. A tripped honeypot answers as a success and records nothing, so the + * bot's booking is declined. + */ export const POST = withRouteHandler(async (req: NextRequest) => { try { const ip = getClientIp(req) if (!ip) { logger.warn('Unable to resolve client IP for public rate limit') - return NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429 } - ) + return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 }) } const storageKey = `public:demo-request:${ip}` const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect( @@ -41,14 +65,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) if (!allowed) { - logger.warn(`Rate limit exceeded for IP ${ip}`, { remaining, resetAt }) - return NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { - status: 429, - headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) }, - } - ) + logger.warn('Rate limit exceeded', { ip, remaining, resetAt }) + return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { + status: 429, + headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) }, + }) } const parsed = await parseRequest(submitDemoRequestContract, req, {}) @@ -57,8 +78,61 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return parsed.response } - const { firstName, lastName, companyEmail, phoneNumber, companySize, details } = - parsed.data.body + const { + firstName, + lastName, + companyEmail, + phoneNumber, + companySize, + details, + website, + captchaToken, + } = parsed.data.body + + if (website && website.trim().length > 0) { + logger.warn('Honeypot triggered, discarding', { ip }) + return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) + } + + let bookerStatus: DemoBookerStatus = 'unverified' + if (isTurnstileConfigured()) { + const token = captchaToken && captchaToken.length > 0 ? captchaToken : null + if (token) { + const verification = await verifyTurnstileToken({ token, remoteIp: ip }) + if (verification.success) { + bookerStatus = 'verified' + } else if (!verification.transportError) { + logger.warn('Captcha verification failed', { ip, errorCodes: verification.errorCodes }) + return NextResponse.json( + { error: 'Captcha verification failed. Please try again.' }, + { status: 400 } + ) + } + } + + if (bookerStatus !== 'verified') { + const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect( + `public:demo-request:nocaptcha:${ip}`, + CAPTCHA_UNAVAILABLE_RATE_LIMIT, + { failClosed: true } + ) + if (!nocaptchaAllowed) { + logger.warn('Rate limit rejected (no-captcha)', { ip }) + return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 }) + } + } + } + + try { + await recordDemoBooker(companyEmail, bookerStatus) + } catch (error) { + logger.error('Could not record the demo booker', { error }) + return NextResponse.json( + { error: 'We could not save your request. Please try again.' }, + { status: 503 } + ) + } + const attribution = formatAttributionForNotification((name) => req.cookies.get(name)?.value) logger.info('Processing demo request', { @@ -77,26 +151,22 @@ Details: ${details} ${attribution ? `\nAttribution:\n${attribution}\n` : ''}` - const emailResult = await sendEmail({ - to: [`enterprise@${env.EMAIL_DOMAIN || getEmailDomain()}`], - subject: `[DEMO REQUEST] ${firstName} ${lastName}`, - text: emailText, - from: getFromEmailAddress(), - replyTo: companyEmail, - emailType: 'transactional', + // The visitor waits on this response before the calendar opens; the sales + // notification does not need to hold it up. + after(async () => { + const emailResult = await sendEmail({ + to: [`enterprise@${env.EMAIL_DOMAIN || getEmailDomain()}`], + subject: `[DEMO REQUEST] ${firstName} ${lastName}`, + text: emailText, + from: getFromEmailAddress(), + replyTo: companyEmail, + emailType: 'transactional', + }) + if (emailResult.success) logger.info('Demo request email sent successfully') + else logger.error('Error sending demo request email', emailResult.message) }) - if (!emailResult.success) { - logger.error('Error sending demo request email', emailResult.message) - return NextResponse.json({ error: 'Failed to submit request' }, { status: 500 }) - } - - logger.info('Demo request email sent successfully') - - return NextResponse.json( - { success: true, message: 'Thanks! Our team will reach out shortly.' }, - { status: 201 } - ) + return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) } catch (error) { logger.error('Error processing demo request', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index 24efb15b98f..30adf034650 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -31,6 +31,7 @@ import { getDatabaseConnectionDescriptor, } from '@/lib/credentials/database-connections/descriptors' import { + GOOGLE_DELEGATION_SERVICE_NAMES, googleDelegationServiceFor, googleKeylessOffer, isGoogleKeylessService, @@ -61,7 +62,6 @@ import { DatabricksAccountModal } from '@/app/workspace/[workspaceId]/integratio import { buildGoogleKeylessConfig, emptyGoogleKeylessInput, - GOOGLE_DELEGATION_SERVICE_NAMES, GoogleKeylessFields, type GoogleKeylessInput, isGoogleKeylessInputComplete, diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/google-keyless-fields.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/google-keyless-fields.tsx index 0d09ec8c370..b81f806b995 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/google-keyless-fields.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/google-keyless-fields.tsx @@ -56,12 +56,6 @@ const DELEGATION_OPTIONS = GOOGLE_DELEGATION_SERVICES.map((service) => ({ label: getServiceConfigByServiceId(service)?.name ?? service, })) -/** "Gmail, Google Drive, …, and Google Calendar": the services keyless credentials can delegate. */ -export const GOOGLE_DELEGATION_SERVICE_NAMES = new Intl.ListFormat('en', { - style: 'long', - type: 'conjunction', -}).format(DELEGATION_OPTIONS.map((option) => option.label)) - /** Whether the provider and service account fields have Google's documented format. */ function isTrustComplete(value: GoogleKeylessInput): boolean { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 5e57cb57234..b14b5b9aac3 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -106,7 +106,6 @@ import { kbDocumentSortParams, } from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' import { canDeleteKnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/permissions' -import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { BrandIcon } from '@/blocks/brand-icon' @@ -421,7 +420,6 @@ export function KnowledgeBase({ const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false) const [documentForTagsId, setDocumentForTagsId] = useState(null) const showAddConnectorModal = addConnectorType != null - const savedCredentialSetupEnabled = useFeatureFlag('knowledge-connector-saved-credentials') const updateAddConnectorParam = useCallback( (value: string | null) => { void setAddConnectorType(value, { history: 'replace', scroll: false }) @@ -1474,7 +1472,6 @@ export function KnowledgeBase({ knowledgeBaseId={id} initialConnectorType={addConnectorType || undefined} isSearchIndex={knowledgeBase?.isSearchIndex} - savedCredentialSetupEnabled={savedCredentialSetupEnabled} /> )} @@ -1516,7 +1513,6 @@ export function KnowledgeBase({ isLoading={isLoadingConnectors} canEdit={userPermissions.canEdit} className='mt-0' - savedCredentialSetupEnabled={savedCredentialSetupEnabled} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 2950343d65a..c9dfaf6988b 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -113,8 +113,6 @@ interface AddConnectorModalProps { initialSyncIntervalMinutes?: number onCreated?: (connectorType: string, connector: ConnectorData) => void setupDraftKey?: string - /** Offers a key connector's saved-credential alternative, such as a saved AWS credential for S3. */ - savedCredentialSetupEnabled?: boolean } type Step = 'select-type' | 'configure' @@ -131,7 +129,6 @@ export function AddConnectorModal({ lockedAccessMode, initialSyncIntervalMinutes = 1440, onCreated, - savedCredentialSetupEnabled = false, setupDraftKey, scope: explicitScope, }: AddConnectorModalProps) { @@ -221,9 +218,7 @@ export function AddConnectorModal({ const isMembersMode = access.accessMode === 'members' const apiKeyConfig = connectorConfig ? getConnectorApiKeyConfig(connectorConfig.auth) : undefined const savedCredentialConfig = - savedCredentialSetupEnabled && connectorConfig?.auth.mode === 'apiKey' - ? connectorConfig.auth.savedCredential - : undefined + connectorConfig?.auth.mode === 'apiKey' ? connectorConfig.auth.savedCredential : undefined const isApiKeyMode = connectorConfig?.auth.mode === 'apiKey' ? !savedCredentialConfig || useApiKey diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index b659418ae9e..5eec846a5e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -31,7 +31,6 @@ interface ConnectorsSectionProps { isLoading: boolean canEdit: boolean className?: string - savedCredentialSetupEnabled?: boolean } const STATUS_CONFIG = { @@ -52,7 +51,6 @@ export function ConnectorsSection({ isLoading, canEdit, className, - savedCredentialSetupEnabled = false, }: ConnectorsSectionProps) { const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId }) const [editingConnector, setEditingConnector] = useState(null) @@ -86,7 +84,6 @@ export function ConnectorsSection({ knowledgeBaseId={knowledgeBaseId} isSearchIndex={isSearchIndex} connector={editingConnector} - savedCredentialSetupEnabled={savedCredentialSetupEnabled} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 045814e9628..4c6b9471546 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -26,7 +26,6 @@ interface EditConnectorModalProps { knowledgeBaseId: string isSearchIndex?: boolean connector: ConnectorData - savedCredentialSetupEnabled?: boolean } export function EditConnectorModal({ @@ -36,7 +35,6 @@ export function EditConnectorModal({ isSearchIndex = false, connector, scope, - savedCredentialSetupEnabled = false, }: EditConnectorModalProps) { const [activeTab, setActiveTab] = useState('settings') const [documentFilter, setDocumentFilter] = useState('active') @@ -46,7 +44,6 @@ export function EditConnectorModal({ isSearchIndex, connector, onSaved: () => onOpenChange(false), - savedCredentialSetupEnabled, }) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts index 8cad515af54..59a35285c5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts @@ -127,8 +127,6 @@ interface UseConnectorSettingsFormOptions { connector: ConnectorData onSaved: (connector: ConnectorData) => void syncing?: boolean - /** Whether a key connector's saved credential may be replaced; the server refuses it while setup is off. */ - savedCredentialSetupEnabled?: boolean } /** Editable connector settings shared by the source page and knowledge-base modal. */ @@ -139,7 +137,6 @@ export function useConnectorSettingsForm({ connector, onSaved, syncing = isConnectorSyncingOrPending(connector), - savedCredentialSetupEnabled = false, }: UseConnectorSettingsFormOptions) { const liveSearch = isSearchIndex const connectorConfig = liveSearchSourceMeta( @@ -297,7 +294,7 @@ export function useConnectorSettingsForm({ accessDirty && !accessModeChanged && isContentEngineAccessMode(access.accessMode) /** Exposes credential selection for mode changes and administrator credential recovery. */ const needsWorkspaceCredential = - (usesSavedCredential && savedCredentialSetupEnabled) || + usesSavedCredential || (connectorConfig?.auth.mode === 'oauth' && isContentEngineAccessMode(access.accessMode) && (persistedAccess.accessMode === 'members' || diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 016a072e5e2..1bdf799711e 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -12,7 +12,6 @@ import { hasDesktopBackgroundExecutor, isDesktopBackgroundExecutorAvailable, } from '@/lib/desktop/executor/availability' -import { isConnectorSavedCredentialSetupEnabled } from '@/lib/knowledge/connectors/saved-credential-rollout' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry' import { isProjectsEnabled } from '@/lib/projects/rollout.server' @@ -136,7 +135,6 @@ async function WorkspaceContent({ desktopExecutorRegistered, projectsEnabled, simLinkOrganizationId, - connectorSavedCredentialsEnabled, ] = await Promise.all([ cookies(), orgSettings, @@ -156,7 +154,6 @@ async function WorkspaceContent({ hasDesktopBackgroundExecutor(session.user.id), isProjectsEnabled(), resolveSimLinkOrganization(principal), - isConnectorSavedCredentialSetupEnabled(), prefetchWorkspaceAccess(queryClient, workspaceId, principal), prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext), ]) @@ -172,7 +169,6 @@ async function WorkspaceContent({ 'mothership-model-selector': modelSelectorEnabled, 'mothership-plan-mode': planModeEnabled, projects: projectsEnabled, - 'knowledge-connector-saved-credentials': connectorSavedCredentialsEnabled, }} > diff --git a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx index 02ba4adb864..5b243f9dda5 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx @@ -10,7 +10,6 @@ export interface WorkspaceFeatureFlags { 'mothership-plan-mode': boolean 'mothership-memory-spaces'?: boolean projects?: boolean - 'knowledge-connector-saved-credentials'?: boolean } const FeatureFlagsContext = createContext(null) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx index 6c03a168096..4e323e494e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx @@ -110,6 +110,26 @@ function appRow(bundleId: string) { return container.querySelector(`[title="${bundleId}"]`)?.parentElement } +it('lists approved apps only while first use of an app needs approval', async () => { + const status = (askForApps: boolean): ComputerUseStatus => ({ + supported: true, + enabled: true, + permissions: { accessibility: true, screenCapture: true }, + activeAction: null, + askForApps, + }) + libDesktopMockFns.mockGetDesktopBridge.mockReturnValue({ + computerUse: { ...native, setAskForApps: vi.fn(async (ask: boolean) => status(ask)) }, + }) + native.getStatus.mockResolvedValue(status(false)) + await act(async () => renderSettings()) + expect(container.querySelector('[aria-label="Ask before using a new app"]')).not.toBeNull() + expect(appRow('com.example.First')).toBeUndefined() + native.getStatus.mockResolvedValue(status(true)) + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(appRow('com.example.First')).toBeDefined() +}) + it('does not restore a revoked app when an earlier activity refresh settles late', async () => { await act(async () => renderSettings()) const stale = createDeferred() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx index af632d25f50..97ee0bcdb56 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx @@ -11,6 +11,11 @@ import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/componen import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' import { useComputerUseStatus } from '@/hooks/use-computer-use-status' +const ON_OFF_OPTIONS = [ + { value: 'off', label: 'Off' }, + { value: 'on', label: 'On' }, +] + /** The activity stays visible so an in-flight action can be stopped; the device controls need the permission group's consent. */ export function ComputerUseSettings() { const { workspaceId } = useParams<{ workspaceId: string }>() @@ -60,19 +65,18 @@ function ComputerUseSettingsControls() { setPending(false) } } + const askForApps = status?.askForApps + const setAskForApps = bridge.setAskForApps return (
    @@ -81,38 +85,62 @@ function ComputerUseSettingsControls() { />

    - Off by default. Each app requires your approval. You can stop an action from the - conversation or revoke an app below. + {askForApps === undefined + ? 'Each app needs your approval before Sim can use it.' + : askForApps + ? 'Sim asks before using each new app, and never uses terminals, scripting apps, password managers, System Settings, or Sim itself.' + : 'Sim can use any app except terminals, scripting apps, password managers, System Settings, and Sim itself.'}

    {error && (
    - Could not connect to the computer helper. + Could not reach the Computer Use helper. void refresh()}>Retry
    )} - {(['accessibility', 'screenCapture'] as const).map((permission) => ( -
    - - {status?.permissions[permission] ? ( - Allowed - ) : ( - - void update(async () => setStatus(await bridge.requestPermission(permission))) - } - > - Open System Settings - + {status?.enabled && ( + <> + {(['accessibility', 'screenCapture'] as const).map((permission) => { + const label = permission === 'accessibility' ? 'Accessibility' : 'Screen Recording' + return ( +
    + + {status.permissions[permission] ? ( + Allowed + ) : ( + + void update(async () => + setStatus(await bridge.requestPermission(permission)) + ) + } + > + Allow + + )} +
    + ) + })} + {setAskForApps && askForApps !== undefined && ( +
    + + + void update(async () => setStatus(await setAskForApps(value === 'on'))) + } + /> +
    )} -
    - ))} -

    - Accessibility allows interaction with approved apps. Screen Recording allows screenshots. - Return here after granting access to refresh the status. -

    - {apps.length > 0 && ( + + )} + {askForApps !== false && apps.length > 0 && (
    {apps.map((app) => ( diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx index 62c3a20e58f..b9579208ea7 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import { ArrowLeft, Chip, toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' +import { SIM_SITE_URL } from '@sim/utils/site' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { PAGE_HEADER_BAR } from '@/components/page-header-bar' @@ -37,8 +38,8 @@ import { } from '@/app/workspace/[workspaceId]/upgrade/search-params' import { useFullscreenOriginStore } from '@/stores/fullscreen-origin/store' -/** Enterprise "Talk to sales" books time with the sales team on Cal.com. */ -const SALES_CAL_URL = 'https://cal.com/team/sim/demo' as const +/** The demo form, so a sales booking passes the same spam checks as every other. */ +const SALES_DEMO_URL = `${SIM_SITE_URL}/demo` /** * Props for {@link Upgrade}. @@ -155,7 +156,7 @@ export function Upgrade({ workspaceId }: UpgradeProps) { const onClick = (): void => { switch (cta.intent) { case 'sales': - window.open(SALES_CAL_URL, '_blank', 'noopener,noreferrer') + window.open(SALES_DEMO_URL, '_blank', 'noopener,noreferrer') return case 'downgrade': void state.onUpgradeToOtherTier() diff --git a/apps/sim/connectors/aws-session.ts b/apps/sim/connectors/aws-session.ts index e0289d5b84f..d8a3aade355 100644 --- a/apps/sim/connectors/aws-session.ts +++ b/apps/sim/connectors/aws-session.ts @@ -1,3 +1,5 @@ +import { type AwsSigningKeys, awsSigningKeys } from '@/lib/internal/aws/signing-keys' + /** * The AWS keys a saved AWS credential resolved to for one connector run. * @@ -8,24 +10,13 @@ * from one place and never from tool or user input. Server-only state: the * slot is never persisted or logged. */ -export interface ConnectorAwsSession { - accessKeyId: string - secretAccessKey: string - /** Present for STS sessions (assumed and keyless roles); absent for saved long-lived keys. */ - sessionToken?: string -} +export type ConnectorAwsSession = AwsSigningKeys const AWS_SESSION_CONTEXT_KEY = 'credentialAwsSession' /** The `syncContext` entries that carry a resolved AWS session to a connector. */ export function awsSessionSyncContext(session: ConnectorAwsSession): Record { - return { - [AWS_SESSION_CONTEXT_KEY]: { - accessKeyId: session.accessKeyId, - secretAccessKey: session.secretAccessKey, - ...(session.sessionToken ? { sessionToken: session.sessionToken } : {}), - }, - } + return { [AWS_SESSION_CONTEXT_KEY]: awsSigningKeys(session) } } /** The AWS session seeded into this run, or undefined when the connector signs with inline keys. */ @@ -40,5 +31,5 @@ export function readAwsSession( if (sessionToken !== undefined && (typeof sessionToken !== 'string' || !sessionToken)) { return undefined } - return { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) } + return awsSigningKeys({ accessKeyId, secretAccessKey, sessionToken }) } diff --git a/apps/sim/executor/handlers/agent/handler.test.ts b/apps/sim/executor/handlers/agent/handler.test.ts index 85c92e38033..b33da2edba5 100644 --- a/apps/sim/executor/handlers/agent/handler.test.ts +++ b/apps/sim/executor/handlers/agent/handler.test.ts @@ -42,7 +42,8 @@ import { AGENT, BlockType } from '@/executor/constants' import { AgentBlockHandler } from '@/executor/handlers/agent/handler' import * as agentMemory from '@/executor/handlers/agent/memory' import type { AgentInputs, Message } from '@/executor/handlers/agent/types' -import type { ExecutionContext, StreamingExecution } from '@/executor/types' +import type { BlockLog, ExecutionContext, StreamingExecution } from '@/executor/types' +import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { getEncryptedConversationMessage, @@ -839,7 +840,7 @@ describe('AgentBlockHandler', () => { timing: { total: 1 }, }) - const openLog = (blockId = mockBlock.id, endedAt = '') => ({ + const openLog = (blockId = mockBlock.id, endedAt = ''): BlockLog => ({ blockId, startedAt: '2026-01-01T00:00:00.000Z', endedAt, @@ -1228,6 +1229,35 @@ describe('AgentBlockHandler', () => { expect(mockResolveBedrockCredentialSession).toHaveBeenCalledTimes(1) }) + it('resolves the AWS credential once for a fallback on the same provider', async () => { + const session = { + credentialId: 'aws-cred', + accessKeyId: 'ASIASESSIONKEY000001', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + } + mockResolveBedrockCredentialSession.mockResolvedValueOnce(session) + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('overloaded')) + .mockResolvedValueOnce(providerResponse('bedrock/claude-fallback')) + + await handler.execute(mockContext, mockBlock, { + ...baseInputs, + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + bedrockCredential: 'aws-cred', + fallbackModels: [{ model: 'bedrock/claude-fallback' }], + }) + + expect(mockResolveBedrockCredentialSession).toHaveBeenCalledTimes(1) + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2) + const [[primaryProvider, primaryRequest], [fallbackProvider, fallbackRequest]] = + mockExecuteProviderRequest.mock.calls + expect([primaryProvider, fallbackProvider]).toEqual(['bedrock', 'bedrock']) + expect(primaryRequest.bedrockSession).toEqual(session) + expect(fallbackRequest.bedrockSession).toEqual(session) + }) + it('re-resolves tuning for the fallback: row value wins, caps clamp, undeclared values drop', async () => { const fallbackCap = getModelCapabilities('gpt-5.4-mini')?.maxOutputTokens expect(fallbackCap).toEqual(expect.any(Number)) @@ -1274,6 +1304,111 @@ describe('AgentBlockHandler', () => { expect(blockLog).toMatchObject({ modelFallbacks: ['gpt-4o'] }) }) + describe('a fallback whose provider cannot take the attached file', () => { + const attachment = { + id: 'file-1', + name: 'example.png', + key: 'execution/test-workspace/test-workflow/exec-1/example.png', + url: 'https://storage.example.com/expired', + size: 8, + type: 'image/png', + context: 'execution', + base64: 'iVBORw0KGgo=', + } + + /** A secret only the primary's tool call activates, so only its failed attempt's registries know it. */ + const contextWithToolSecretCatalog = (blockLog: ReturnType) => ({ + ...mockContext, + executionId: 'exec-1', + blockLogs: [blockLog], + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([ + { name: 'TOKEN', plaintext: 'tool-secret', encryptedValue: 'encrypted-tool-secret' }, + ]), + }) + + const inputsWithAttachment = (fallbackModels: Array<{ model: string }>) => ({ + ...baseInputs, + files: [attachment], + fallbackModels, + }) + + let hydrate: ReturnType + const primaryError = new Error('overloaded after reading tool-secret') + + beforeEach(() => { + mockGetProviderFromModel.mockImplementation((model: string) => + model.startsWith('deepseek') ? 'deepseek' : providerFor(model) + ) + hydrate = vi + .spyOn(userFileBase64, 'hydrateUserFilesWithBase64') + .mockImplementation(async (files) => files) + mockExecuteProviderRequest.mockImplementationOnce(async (_provider, _request, options) => { + const runtimeRegistry = options.resolvedSecretTraceRegistry as ResolvedSecretTraceRegistry + runtimeRegistry.recordResolved('TOKEN', 'tool-secret', { propagated: true }) + throw primaryError + }) + }) + + afterEach(() => { + hydrate.mockRestore() + }) + + it("is skipped, and the primary's error rethrows against its failed attempt's registries", async () => { + const blockLog = openLog() + blockLog.modelFallbacks = ['stale-from-earlier-run'] + const ctx = contextWithToolSecretCatalog(blockLog) + + await expect( + handler.execute(ctx, mockBlock, inputsWithAttachment([{ model: 'deepseek-chat' }])) + ).rejects.toBe(primaryError) + + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + /** Only the final error failed, and a skip is not a failed try, so the log records nothing. */ + expect(blockLog.modelFallbacks).toBeUndefined() + for (const registry of [ + ctx.errorResolvedSecretTraceRegistry, + ctx.resolvedSecretTraceRegistry, + ]) { + expect(projectResolvedSecretDiagnosticError(primaryError, registry)).toMatchObject({ + error: 'overloaded after reading {{TOKEN}}', + }) + } + }) + + it('is skipped for a later fallback that starts from the settled registries and answers', async () => { + const blockLog = openLog() + const ctx = contextWithToolSecretCatalog(blockLog) + const fallbackRegistries: Array = [] + mockExecuteProviderRequest.mockImplementationOnce(async (_provider, _request, options) => { + const executionContext = options.executionContext as ExecutionContext + fallbackRegistries.push( + executionContext.errorResolvedSecretTraceRegistry, + executionContext.resolvedSecretTraceRegistry + ) + return providerResponse('claude-sonnet-5', 'from fallback') + }) + + const result = await handler.execute( + ctx, + mockBlock, + inputsWithAttachment([{ model: 'deepseek-chat' }, { model: 'claude-sonnet-5' }]) + ) + + expect(mockExecuteProviderRequest.mock.calls.map(([provider]) => provider)).toEqual([ + 'openai', + 'anthropic', + ]) + expect((result as { model: string }).model).toBe('claude-sonnet-5') + /** Only the primary's tool call activated the secret, so registries from the settled inputs leave it unredacted. */ + expect(fallbackRegistries).toHaveLength(2) + for (const registry of fallbackRegistries) { + expect(projectResolvedSecretDiagnosticError(primaryError, registry)).toMatchObject({ + error: 'overloaded after reading tool-secret', + }) + } + }) + }) + it('skips sim-auto, duplicates, the primary itself, and unusable providers', async () => { mockExecuteProviderRequest .mockRejectedValueOnce(new Error('down')) diff --git a/apps/sim/executor/handlers/agent/handler.ts b/apps/sim/executor/handlers/agent/handler.ts index 03da301096e..e5d9e7d50be 100644 --- a/apps/sim/executor/handlers/agent/handler.ts +++ b/apps/sim/executor/handlers/agent/handler.ts @@ -64,7 +64,6 @@ import { validateModelProvider, } from '@/ee/access-control/utils/permission-check' import { AGENT, BlockType, DEFAULTS, stripCustomToolPrefix } from '@/executor/constants' -import { isRetryableBlockError } from '@/executor/execution/block-retry' import { getMemoryMessageAppendKey, getMemoryMessageTurnId, @@ -87,6 +86,7 @@ import { parseResponseFormat } from '@/executor/handlers/shared/response-format' import type { BlockHandler, BlockNodeMetadata, + BlockRetryAttempt, ExecutionContext, StreamingExecution, UserFile, @@ -95,14 +95,14 @@ import { collectBlockData } from '@/executor/utils/block-data' import { stringifyJSON } from '@/executor/utils/json' import { BLOCK_MODEL_CREDENTIAL_FIELDS, - resolveBlockModelCredentials, + type BlockModelCredentials, } from '@/executor/utils/model-credentials' import { - assertFallbackFamilyCredentials, + areFallbacksHeld, getModelFallbacks, PROVIDER_FAMILY_CREDENTIAL_FIELDS, - recordModelFallbacks, resolveFallbackApiKey, + walkModelCandidates, } from '@/executor/utils/model-fallbacks' import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection' import { prepareResolvedSecretProjectedInputs } from '@/executor/utils/resolved-secret-input-projection' @@ -195,21 +195,11 @@ function stripAutoPreamble(messages: Message[] | undefined): Message[] | undefin }) } -/** One model in the order the block tries them; the primary carries the block's own key. */ -interface ModelCandidate extends FallbackModelCandidate { - isPrimary: boolean - /** - * What the trace calls this model when it fails. A routed sim-auto primary - * shows as the auto identity, since naming the pool model is the leak that - * `applyAutoModelLabel` exists to close. - */ - traceName?: string -} - interface ExecuteAcrossModelsConfig { - candidates: ModelCandidate[] + primary: { model: string; traceName?: string } + fallbacks: FallbackModelCandidate[] + retry: BlockRetryAttempt | undefined retryPrimaryOnStreamStart: boolean - primaryModel: string /** * The model the builder configured, which is what the editor showed the * per-row tuning fields against. Under sim-auto that is the auto id, not the @@ -603,13 +593,7 @@ export class AgentBlockHandler implements BlockHandler { throw new NonRetryableExecutionError('Evaluation models cannot serve as chat fallbacks') } const retry = nodeMetadata?.retry - const fallbacksHeld = retry !== undefined && !retry.isFinalTry - const fallbackCandidates = - modelInputs.previousInteractionId || fallbacksHeld - ? [] - : configuredFallbacks.filter( - (candidate) => candidate.model.toLowerCase() !== model.toLowerCase() - ) + const fallbacksHeld = areFallbacksHeld(retry) if (configuredFallbacks.length > 0 && modelInputs.previousInteractionId) { logger.info('Fallback models skipped for a deep-research follow-up turn', { blockId: block.id, @@ -617,28 +601,20 @@ export class AgentBlockHandler implements BlockHandler { } else if (configuredFallbacks.length > 0 && fallbacksHeld) { logger.info('Fallback models held for the final try', { blockId: block.id, - attempt: retry.attempt, - maxTries: retry.maxTries, + attempt: retry?.attempt, + maxTries: retry?.maxTries, }) } - const candidates: ModelCandidate[] = [ - { - model, - apiKey: modelInputs.apiKey, - isPrimary: true, - ...(autoRouting ? { traceName: SIM_AUTO_MODEL_ID } : {}), - }, - ...fallbackCandidates.map((candidate) => ({ ...candidate, isPrimary: false })), - ] const { result, servedModel, resultRegistry: servedRegistry, } = await this.executeAcrossModels(ctx, block, { - candidates, + primary: { model, ...(autoRouting ? { traceName: SIM_AUTO_MODEL_ID } : {}) }, + fallbacks: modelInputs.previousInteractionId ? [] : configuredFallbacks, + retry, retryPrimaryOnStreamStart: fallbacksHeld && configuredFallbacks.length > 0 && !modelInputs.previousInteractionId, - primaryModel: model, configuredModel: autoRouting ? SIM_AUTO_MODEL_ID : model, primaryProviderId: providerId, messages: messagesWithInputFiles, @@ -2586,41 +2562,21 @@ export class AgentBlockHandler implements BlockHandler { } /** - * Runs the provider request against each candidate in order until one answers. - * - * Which model serves the request is decided here, inside one handler - * invocation. How many invocations the block gets is the executor's retry - * policy, and the caller keeps the fallbacks out of the candidate list until - * the final try, so with retry on the block runs the primary alone on every - * earlier try and walks the whole chain once. - * - * Falling through is deliberately as indiscriminate as block retry - * (`isRetryableBlockError`): a provider error carries no status, so an - * overloaded upstream cannot be told from any other failure, and a builder who - * lists fallbacks wants the block to answer. Only a stop and an explicitly - * non-retryable failure end the chain early. The abort signal is checked - * before the error itself because `handleExecutionError` rewrites a timeout - * into a plain `Error` without a cause, which the predicate would then treat - * as replayable. + * Runs the Agent's provider request over `walkModelCandidates`, which owns the + * candidate order, the retry hold, skips, stops, saved credentials, and the + * block-log record. What is left here is Agent-only: * - * Messages are built once by the caller, since building them appends to - * memory. Hydration runs per provider, because attachment support and the - * inline budget differ, and is cached so two candidates on one provider do - * not download the same files twice. A fallback whose provider is - * blacklisted, not permitted, or cannot take the attachments is skipped - * rather than counted as a failed try. - * - * A streaming candidate is accepted only once its first chunk has arrived - * (`primeStreamingExecution`), so a startup failure inside the stream still - * falls through; that wait is skipped when no candidate follows, which keeps - * blocks without fallbacks on today's path. - * - * When every candidate fails, the last attempted candidate's error is thrown - * exactly as it escaped `executeProviderRequest`, with the registries that - * call installed for its projection, so error ports and the block-level - * error handling see the shapes they see today. The models that failed are - * written to the block log on every exit, cleared as well as set, because - * block retry reuses one log entry across tries. + * - Hydration runs per provider, because attachment support and the inline + * budget differ, and is cached per provider. A fallback whose provider cannot + * take the attachments is skipped in `prepare`, not counted as a failed try. + * - A streaming candidate is accepted only once its first chunk has arrived + * (`primeStreamingExecution`), so a startup failure inside the stream still + * falls through; the wait is skipped when no candidate follows. + * - `executeProviderRequest` installs a failed attempt's registries, the only + * ones that know secrets a tool call activated. They stay on `ctx` until + * another candidate actually runs, from the settled inputs again, so the + * fallback warn, the rethrown error, and the block-log record project + * against the last failed attempt. */ private async executeAcrossModels( ctx: ExecutionContext, @@ -2633,128 +2589,101 @@ export class AgentBlockHandler implements BlockHandler { }> { const { hydratedByProvider } = config let resultRegistry = config.resultRegistry - const failedModels: string[] = [] - let lastError: unknown - let lastErrorRegistries: - | { - error: ResolvedSecretTraceRegistry | undefined - resolved: ResolvedSecretTraceRegistry | undefined - } - | undefined - - for (let index = 0; index < config.candidates.length; index++) { - const candidate = config.candidates[index] - const hasNext = index < config.candidates.length - 1 - - /** A run stopped while a candidate was being skipped must not start another. */ - if (!candidate.isPrimary && ctx.abortSignal?.aborted) break - - let candidateProviderId: string - if (candidate.isPrimary) { - candidateProviderId = config.primaryProviderId - } else { - try { - candidateProviderId = getProviderFromModel(candidate.model) - await validateModelProvider(ctx.userId, ctx.workspaceId, candidate.model, ctx) - assertFallbackFamilyCredentials(candidateProviderId, config.primaryProviderId) - } catch (error) { - this.warnFallbackSkipped(ctx, block, candidate.model, 'unusable', error) - continue - } - } + /** The settled inputs, for skips and hydration, leaving the failed attempt's registries on `ctx`. */ + const settledCtx = (): ExecutionContext => ({ + ...ctx, + resolvedSecretTraceRegistry: config.settledInputRegistry, + errorResolvedSecretTraceRegistry: config.providerErrorRegistry, + }) - let messages: Message[] | undefined - if (hydratedByProvider.has(candidateProviderId)) { - messages = hydratedByProvider.get(candidateProviderId) - } else { - try { - messages = await this.hydrateMessageFilesForProvider( - ctx, + return walkModelCandidates({ + ctx, + block, + primary: config.primary, + primaryProviderId: config.primaryProviderId, + fallbacks: config.fallbacks, + retry: config.retry, + credentials: { inputs: config.modelInputs, callerLabel: 'vertex-agent' }, + prepare: async (_candidate, { providerId }) => { + if (hydratedByProvider.has(providerId)) return + hydratedByProvider.set( + providerId, + await this.hydrateMessageFilesForProvider( + settledCtx(), config.messages, - candidateProviderId, + providerId, config.fileProjection.projectedNameByFile, config.fileProjection.modelBoundInputPaths ) - } catch (error) { - if (candidate.isPrimary) throw error - this.warnFallbackSkipped( - ctx, - block, - candidate.model, - 'cannot take the attached files', - error - ) - continue - } - hydratedByProvider.set(candidateProviderId, messages) + ) /** Hydration imported this provider's file provenance; the result fork must carry it. */ resultRegistry = config.settledInputRegistry?.forkForInputPaths([]) - } + }, + attempt: async ( + candidate, + { isPrimary, hasNext, providerId, sameProvider, credentials: savedCredentials } + ) => { + if (!isPrimary) { + ctx.errorResolvedSecretTraceRegistry = config.providerErrorRegistry + ctx.resolvedSecretTraceRegistry = config.settledInputRegistry + } + const messages = hydratedByProvider.get(providerId) - /** - * A fallback's own key applies only while its key field is visible. On the - * primary's provider it reuses the block's key; otherwise the provider layer resolves BYOK or - * the platform key, or reports that a key is required, which counts as - * this candidate failing. A previous interaction id belongs to the primary's - * provider alone. Tuning is re-resolved against the fallback's own - * capabilities so the request is one its provider accepts. - */ - let inputs: AgentInputs = config.modelInputs - if (!candidate.isPrimary) { - const { adjustments, ...tuning } = resolveFallbackTuning( - candidate, - config.configuredModel, - config.modelInputs - ) - const sameProvider = candidateProviderId === config.primaryProviderId - inputs = { - ...(sameProvider - ? config.modelInputs - : omit(config.modelInputs, [ - ...PROVIDER_FAMILY_CREDENTIAL_FIELDS, - ...BLOCK_MODEL_CREDENTIAL_FIELDS, - ])), - apiKey: resolveFallbackApiKey({ + let inputs: AgentInputs = config.modelInputs + if (!isPrimary) { + const { adjustments, ...tuning } = resolveFallbackTuning( candidate, - configuredModel: config.configuredModel, - sameProvider, - primaryApiKey: config.modelInputs.apiKey, - blockId: block.id, - logger, - }), - previousInteractionId: undefined, - ...(config.fallbackSystemPrompt !== undefined - ? { systemPrompt: config.fallbackSystemPrompt } - : {}), - ...tuning, - } - if (adjustments.length > 0) { - logger.info( - 'Fallback model tuning adjusted', - projectAgentDiagnosticMetadata( - ctx, - { blockId: block.id, model: candidate.model, adjustments }, - { blockId: block.id, adjustmentCount: adjustments.length } - ) + config.configuredModel, + config.modelInputs ) + inputs = { + ...(sameProvider + ? config.modelInputs + : omit(config.modelInputs, [ + ...PROVIDER_FAMILY_CREDENTIAL_FIELDS, + ...BLOCK_MODEL_CREDENTIAL_FIELDS, + ])), + apiKey: resolveFallbackApiKey({ + candidate, + configuredModel: config.configuredModel, + sameProvider, + primaryApiKey: config.modelInputs.apiKey, + blockId: block.id, + logger, + }), + /** A previous interaction id belongs to the primary's provider alone. */ + previousInteractionId: undefined, + ...(config.fallbackSystemPrompt !== undefined + ? { systemPrompt: config.fallbackSystemPrompt } + : {}), + ...tuning, + } + if (adjustments.length > 0) { + logger.info( + 'Fallback model tuning adjusted', + projectAgentDiagnosticMetadata( + ctx, + { blockId: block.id, model: candidate.model, adjustments }, + { blockId: block.id, adjustmentCount: adjustments.length } + ) + ) + } } - } - const providerRequest = this.buildProviderRequest({ - ctx, - providerId: candidateProviderId, - model: candidate.model, - messages: - !candidate.isPrimary && config.fallbackSystemPrompt !== undefined - ? stripAutoPreamble(messages) - : messages, - inputs, - formattedTools: config.formattedTools, - responseFormat: config.responseFormat, - streaming: config.streaming, - }) + const providerRequest = this.buildProviderRequest({ + ctx, + providerId, + model: candidate.model, + messages: + !isPrimary && config.fallbackSystemPrompt !== undefined + ? stripAutoPreamble(messages) + : messages, + inputs, + formattedTools: config.formattedTools, + responseFormat: config.responseFormat, + streaming: config.streaming, + }) - try { let result = await this.executeProviderRequest( ctx, providerRequest, @@ -2762,32 +2691,27 @@ export class AgentBlockHandler implements BlockHandler { config.responseFormat, resultRegistry, config.providerErrorRegistry, + savedCredentials?.(), config.agentConversation ) if ((hasNext || config.retryPrimaryOnStreamStart) && this.isStreamingExecution(result)) { result = await this.primeStreamingExecution(result as StreamingExecution) } - recordModelFallbacks(ctx, block, failedModels) return { result, servedModel: config.agentConversation?.getFinalResponse()?.model ?? candidate.model, resultRegistry, } - } catch (error) { - lastError = error - failedModels.push(candidate.traceName ?? candidate.model) - if (!hasNext || ctx.abortSignal?.aborted || !isRetryableBlockError(error)) { - recordModelFallbacks(ctx, block, failedModels.slice(0, -1)) - throw error - } - - /** - * `executeProviderRequest` installed the failed attempt's error - * registry, which is the only one that knows secrets a tool call - * activated; the warn is projected against it before the next candidate - * starts from the settled inputs again. The pair is kept so a rethrow - * after every remaining candidate was skipped projects the same way. - */ + }, + onSkip: (model, error, stage) => + this.warnFallbackSkipped( + settledCtx(), + block, + model, + stage === 'prepare' ? 'cannot take the attached files' : 'unusable', + error + ), + onFallback: ({ error, failedModel, nextModel, position }) => { const errorRegistry = ctx.errorResolvedSecretTraceRegistry const diagnosticCtx = errorRegistry ? { ...ctx, resolvedSecretTraceRegistry: errorRegistry } @@ -2798,27 +2722,16 @@ export class AgentBlockHandler implements BlockHandler { diagnosticCtx, { blockId: block.id, - failedModel: candidate.model, - nextModel: config.candidates[index + 1].model, - candidate: index + 1, + failedModel, + nextModel, + candidate: position, error: getErrorMessage(error), }, - { blockId: block.id, candidate: index + 1 } + { blockId: block.id, candidate: position } ) ) - lastErrorRegistries = { error: errorRegistry, resolved: ctx.resolvedSecretTraceRegistry } - ctx.errorResolvedSecretTraceRegistry = config.providerErrorRegistry - ctx.resolvedSecretTraceRegistry = config.settledInputRegistry - } - } - - /** Reached only when every candidate after the last failure was skipped. */ - if (lastErrorRegistries) { - ctx.errorResolvedSecretTraceRegistry = lastErrorRegistries.error - ctx.resolvedSecretTraceRegistry = lastErrorRegistries.resolved - } - recordModelFallbacks(ctx, block, failedModels.slice(0, -1)) - throw lastError + }, + }) } /** @@ -2983,18 +2896,14 @@ export class AgentBlockHandler implements BlockHandler { responseFormat: any, modelRuntimeRegistry: ResolvedSecretTraceRegistry | undefined, providerErrorRegistry: ResolvedSecretTraceRegistry | undefined, + savedCredentials: Promise | undefined, agentConversation?: AgentTurnSession ): Promise { const providerId = providerRequest.provider const model = providerRequest.model try { - const credentials = await resolveBlockModelCredentials( - ctx, - providerId, - providerRequest, - 'vertex-agent' - ) + const credentials: BlockModelCredentials = (await savedCredentials) ?? {} const { blockData, blockNameMapping } = collectBlockData(ctx) const agentMemoryRetrieval = agentConversation?.memoryId diff --git a/apps/sim/executor/utils/model-fallback-request.ts b/apps/sim/executor/utils/model-fallback-request.ts index e4687e58c90..923408d1d60 100644 --- a/apps/sim/executor/utils/model-fallback-request.ts +++ b/apps/sim/executor/utils/model-fallback-request.ts @@ -1,27 +1,19 @@ import { createLogger } from '@sim/logger' import { omit } from '@sim/utils/object' import { resolveFallbackTuning } from '@/lib/workflows/blocks/fallback-models' -import { validateModelProvider } from '@/ee/access-control/utils/permission-check' -import { isRetryableBlockError } from '@/executor/execution/block-retry' import type { BlockRetryAttempt, ExecutionContext } from '@/executor/types' +import type { BlockModelCredentialInputs } from '@/executor/utils/model-credentials' import { - type BlockModelCredentialInputs, - type BlockModelCredentials, - resolveBlockModelCredentials, -} from '@/executor/utils/model-credentials' -import { - assertFallbackFamilyCredentials, getModelFallbacks, PROVIDER_FAMILY_CREDENTIAL_FIELDS, - recordModelFallbacks, resolveFallbackApiKey, + walkModelCandidates, } from '@/executor/utils/model-fallbacks' import { executeBlockProviderRequest } from '@/executor/utils/provider-request' import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models' import type { ProviderRequest, ProviderResponse } from '@/providers/types' -import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('BlockModelFallbacks') @@ -37,16 +29,18 @@ interface ModelFallbackRequestInput { fallbackSystemPrompt: string retry?: BlockRetryAttempt resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined - /** The block's saved Vertex or Bedrock credential selection, resolved per attempt. */ + /** + * The block's saved Vertex or Bedrock credential selection, resolved once per call inside the + * first attempt on the primary's provider and shared by the fallbacks on that provider. + */ credentials?: { inputs: BlockModelCredentialInputs; callerLabel: string } } /** - * Shared non-streaming model execution for Router and Evaluator. Block retries - * exhaust the primary first; the final try walks the ordered fallback models. - * Saved credentials resolve inside the attempt, so one that cannot be resolved fails - * that attempt and the fallbacks still run. Only candidates on the primary's provider - * resolve them, once per call, and they never reach another provider. + * Shared non-streaming model execution for Router and Evaluator over + * `walkModelCandidates`. Saved credentials resolve inside the attempt, so one that + * cannot be resolved fails that attempt and the fallbacks still run; they never + * reach another provider. * Parsing a successful response remains the handler's job, so a routing decision * such as NO_MATCH is never silently replaced by another model's decision. */ @@ -62,77 +56,49 @@ export async function executeModelRequestWithFallbacks({ resolvedSecretTraceRegistry, credentials, }: ModelFallbackRequestInput): Promise<{ result: ProviderResponse; usedFallback: boolean }> { - const fallbacks = getModelFallbacks(ctx, block, fallbackModels, logger) - const candidates = [ - { model: request.model }, - ...(retry && !retry.isFinalTry - ? [] - : fallbacks.filter( - (candidate) => candidate.model.toLowerCase() !== request.model.toLowerCase() - )), - ] - const failedModels: string[] = [] - let lastError: unknown - let savedCredentials: Promise | undefined - - for (const [index, candidate] of candidates.entries()) { - const isPrimary = index === 0 - if (!isPrimary && ctx.abortSignal?.aborted) break - let candidateProviderId = providerId - if (!isPrimary) { - try { - candidateProviderId = getProviderFromModel(candidate.model) - await validateModelProvider(ctx.userId, ctx.workspaceId, candidate.model, ctx) - assertFallbackFamilyCredentials(candidateProviderId, providerId) - } catch (error) { - logger.warn( - 'Fallback model unusable; skipping', - projectResolvedSecretDiagnosticError(error, ctx.resolvedSecretTraceRegistry, { - blockId: block.id, - model: candidate.model, - }) - ) - continue - } - } - - let candidateRequest = request - if (!isPrimary) { - const sameProvider = candidateProviderId === providerId - const { adjustments: _adjustments, ...tuning } = resolveFallbackTuning( - candidate, - configuredModel, - request - ) - candidateRequest = { - ...(sameProvider - ? request - : omit(request, [...PROVIDER_FAMILY_CREDENTIAL_FIELDS, 'bedrockSession'])), - ...tuning, - temperature: tuning.temperature === undefined ? undefined : Number(tuning.temperature), - maxTokens: tuning.maxTokens === undefined ? undefined : Number(tuning.maxTokens), - model: candidate.model, - apiKey: resolveFallbackApiKey({ + return walkModelCandidates({ + ctx, + block, + primary: { + model: request.model, + ...(isAutoModel(configuredModel) ? { traceName: SIM_AUTO_MODEL_ID } : {}), + }, + primaryProviderId: providerId, + fallbacks: getModelFallbacks(ctx, block, fallbackModels, logger), + retry, + credentials, + attempt: async ( + candidate, + { isPrimary, providerId: candidateProviderId, sameProvider, credentials: savedCredentials } + ) => { + let candidateRequest = request + if (!isPrimary) { + const { adjustments: _adjustments, ...tuning } = resolveFallbackTuning( candidate, configuredModel, - sameProvider, - primaryApiKey: request.apiKey, - blockId: block.id, - logger, - }), - systemPrompt: fallbackSystemPrompt, - } - } - - try { - if (credentials && candidateProviderId === providerId) { - savedCredentials ??= resolveBlockModelCredentials( - ctx, - providerId, - credentials.inputs, - credentials.callerLabel + request ) - candidateRequest = { ...candidateRequest, ...(await savedCredentials) } + candidateRequest = { + ...(sameProvider + ? request + : omit(request, [...PROVIDER_FAMILY_CREDENTIAL_FIELDS, 'bedrockSession'])), + ...tuning, + temperature: tuning.temperature === undefined ? undefined : Number(tuning.temperature), + maxTokens: tuning.maxTokens === undefined ? undefined : Number(tuning.maxTokens), + model: candidate.model, + apiKey: resolveFallbackApiKey({ + candidate, + configuredModel, + sameProvider, + primaryApiKey: request.apiKey, + blockId: block.id, + logger, + }), + systemPrompt: fallbackSystemPrompt, + } + } + if (savedCredentials) { + candidateRequest = { ...candidateRequest, ...(await savedCredentials()) } } const result = await executeBlockProviderRequest({ ctx, @@ -140,29 +106,23 @@ export async function executeModelRequestWithFallbacks({ request: candidateRequest, resolvedSecretTraceRegistry, }) - recordModelFallbacks(ctx, block, failedModels) return { result, usedFallback: !isPrimary } - } catch (error) { - lastError = error - failedModels.push( - isPrimary && isAutoModel(configuredModel) ? SIM_AUTO_MODEL_ID : candidate.model - ) - if ( - index === candidates.length - 1 || - ctx.abortSignal?.aborted || - !isRetryableBlockError(error) - ) - break + }, + onSkip: (model, error) => + logger.warn( + 'Fallback model unusable; skipping', + projectResolvedSecretDiagnosticError(error, ctx.resolvedSecretTraceRegistry, { + blockId: block.id, + model, + }) + ), + onFallback: ({ error, failedModel }) => logger.warn( 'Model request failed; trying fallback', projectResolvedSecretDiagnosticError(error, ctx.resolvedSecretTraceRegistry, { blockId: block.id, - failedModel: candidate.model, + failedModel, }) - ) - } - } - - recordModelFallbacks(ctx, block, failedModels.slice(0, -1)) - throw lastError + ), + }) } diff --git a/apps/sim/executor/utils/model-fallbacks.test.ts b/apps/sim/executor/utils/model-fallbacks.test.ts new file mode 100644 index 00000000000..ab1cdf2aaba --- /dev/null +++ b/apps/sim/executor/utils/model-fallbacks.test.ts @@ -0,0 +1,66 @@ +import { llmBedrockCredentialMock } from '@sim/testing/mocks/llm-bedrock-credential.mock' +import { + permissionCheckMock, + permissionCheckMockFns, +} from '@sim/testing/mocks/permission-check.mock' +import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/providers-utils.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionContext } from '@/executor/types' +import { walkModelCandidates } from '@/executor/utils/model-fallbacks' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import type { SerializedBlock } from '@/serializer/types' + +vi.mock('@/ee/access-control/utils/permission-check', () => permissionCheckMock) +vi.mock('@/providers/utils', () => providersUtilsMock) +vi.mock('@/lib/internal/llm/bedrock-credential', () => llmBedrockCredentialMock) + +const block = { id: 'block-1', config: { params: {} } } as SerializedBlock + +function context(): ExecutionContext { + return { + userId: 'user-1', + workspaceId: 'workspace-1', + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), + blockLogs: [{ blockId: 'block-1', endedAt: '' }], + } as ExecutionContext +} + +/** The primary fails, the first fallback cannot be prepared, then `last` decides. */ +function walk(ctx: ExecutionContext, primaryError: Error, last: 'answers' | 'absent') { + return walkModelCandidates({ + ctx, + block, + primary: { model: 'gpt-4o' }, + primaryProviderId: 'openai', + fallbacks: [{ model: 'gpt-4o-mini' }, ...(last === 'answers' ? [{ model: 'gpt-4.1' }] : [])], + prepare: async (candidate) => { + if (candidate.model === 'gpt-4o-mini') throw new Error('cannot take the attachment') + }, + attempt: async (candidate) => { + if (candidate.model === 'gpt-4o') throw primaryError + return candidate.model + }, + onSkip: vi.fn(), + onFallback: vi.fn(), + }) +} + +beforeEach(() => { + providersUtilsMockFns.mockGetProviderFromModel.mockReturnValue('openai') + permissionCheckMockFns.mockValidateModelProvider.mockReset().mockResolvedValue(undefined) +}) + +describe('walkModelCandidates', () => { + it('passes over a candidate that cannot be prepared without recording it as a failed model', async () => { + const ctx = context() + await expect(walk(ctx, new Error('overloaded'), 'answers')).resolves.toBe('gpt-4.1') + expect(ctx.blockLogs[0].modelFallbacks).toEqual(['gpt-4o']) + }) + + it('rethrows the last failure when every remaining candidate is skipped', async () => { + const ctx = context() + const primaryError = new Error('overloaded') + await expect(walk(ctx, primaryError, 'absent')).rejects.toBe(primaryError) + expect(ctx.blockLogs[0].modelFallbacks).toBeUndefined() + }) +}) diff --git a/apps/sim/executor/utils/model-fallbacks.ts b/apps/sim/executor/utils/model-fallbacks.ts index 6bdcc3e3936..a33bb762aba 100644 --- a/apps/sim/executor/utils/model-fallbacks.ts +++ b/apps/sim/executor/utils/model-fallbacks.ts @@ -7,8 +7,16 @@ import { normalizeFallbackModels, } from '@/lib/workflows/blocks/fallback-models' import { providerRequiresFamilyCredentials } from '@/blocks/utils' -import type { ExecutionContext } from '@/executor/types' +import { validateModelProvider } from '@/ee/access-control/utils/permission-check' +import { isRetryableBlockError } from '@/executor/execution/block-retry' +import type { BlockRetryAttempt, ExecutionContext } from '@/executor/types' +import { + type BlockModelCredentialInputs, + type BlockModelCredentials, + resolveBlockModelCredentials, +} from '@/executor/utils/model-credentials' import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection' +import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' type Logger = ReturnType @@ -28,7 +36,7 @@ export const PROVIDER_FAMILY_CREDENTIAL_FIELDS = [ * Refuses a fallback on another provider that needs a Vertex credential, Bedrock keys, or an * Azure endpoint: those fields exist only for the primary's provider family. */ -export function assertFallbackFamilyCredentials( +function assertFallbackFamilyCredentials( candidateProviderId: string, primaryProviderId: string ): void { @@ -100,7 +108,7 @@ export function resolveFallbackApiKey({ } /** Records failed candidates on the current attempt's log, projecting secret-derived model names. */ -export function recordModelFallbacks( +function recordModelFallbacks( ctx: ExecutionContext, block: SerializedBlock, failedModels: string[] @@ -124,3 +132,170 @@ export function recordModelFallbacks( return } } + +/** + * Retry on fail retries the selected model; the fallbacks join only on the try + * after which the executor promises no other. + */ +export function areFallbacksHeld(retry: BlockRetryAttempt | undefined): boolean { + return retry !== undefined && !retry.isFinalTry +} + +/** What the walk tells each attempt about the candidate it is running. */ +interface ModelCandidateAttempt { + isPrimary: boolean + hasNext: boolean + providerId: string + /** Whether the candidate runs on the primary's provider, and so may reuse its key and family credentials. */ + sameProvider: boolean + /** + * The block's saved Vertex or Bedrock credentials, resolved at most once per walk and + * shared by every candidate on the primary's provider. Absent on any other provider. + */ + credentials?: () => Promise +} + +interface ModelCandidateWalk { + ctx: ExecutionContext + block: SerializedBlock + /** + * The configured primary. `traceName` is what the block log calls it when it fails: + * a routed Auto primary shows as the Auto identity, never as the pool model. + */ + primary: { model: string; traceName?: string } + primaryProviderId: string + /** Validated fallback rows (`getModelFallbacks`), before the hold and de-duplication. */ + fallbacks: FallbackModelCandidate[] + retry?: BlockRetryAttempt + /** The block's saved credential selection, resolved only for candidates on the primary's provider. */ + credentials?: { inputs: BlockModelCredentialInputs; callerLabel: string } + /** + * Readies a fallback that passed the provider checks; a throw means it cannot take this + * request, and it is skipped like an unusable fallback. + */ + prepare?: (candidate: FallbackModelCandidate, info: ModelCandidateAttempt) => Promise + /** Builds and sends one candidate's request; a throw is that candidate failing. */ + attempt: (candidate: FallbackModelCandidate, info: ModelCandidateAttempt) => Promise + /** + * A fallback passed over without counting as a failed try: its provider is unknown, not + * permitted, or needs another family's credentials (`unusable`), or `prepare` threw. + */ + onSkip: (model: string, error: unknown, stage: 'unusable' | 'prepare') => void + /** A retryable failure with another candidate after it; `position` is the failed candidate's 1-based place. */ + onFallback: (failure: { + error: unknown + failedModel: string + nextModel: string + position: number + }) => void +} + +/** + * Runs a block's model request against the primary and then each fallback until one + * answers. Retries exhaust the primary first; the fallbacks, de-duplicated against the + * primary, join only on the final try. A fallback that cannot be used is skipped, not + * counted as a failure. The chain stops at the last candidate, on a stop, or on a + * non-retryable error, and then rethrows the last candidate's error unchanged. + * + * Falling through is deliberately as indiscriminate as block retry + * (`isRetryableBlockError`): a provider error carries no status, so an overloaded + * upstream cannot be told from any other failure, and a builder who lists fallbacks + * wants the block to answer. Only a stop and an explicitly non-retryable failure end + * the chain early. + * + * The models that failed are written to the block log on every exit, cleared as well + * as set, because block retry reuses one log entry across tries. + */ +export async function walkModelCandidates({ + ctx, + block, + primary, + primaryProviderId, + fallbacks, + retry, + credentials, + prepare, + attempt, + onSkip, + onFallback, +}: ModelCandidateWalk): Promise { + const primaryModel = primary.model.toLowerCase() + const candidates: FallbackModelCandidate[] = [ + primary, + ...(areFallbacksHeld(retry) + ? [] + : fallbacks.filter((candidate) => candidate.model.toLowerCase() !== primaryModel)), + ] + let savedCredentials: Promise | undefined + const resolveSavedCredentials = credentials + ? () => + (savedCredentials ??= resolveBlockModelCredentials( + ctx, + primaryProviderId, + credentials.inputs, + credentials.callerLabel + )) + : undefined + const failedModels: string[] = [] + let lastError: unknown + + for (const [index, candidate] of candidates.entries()) { + const isPrimary = index === 0 + const hasNext = index < candidates.length - 1 + /** A run stopped while a candidate was being skipped must not start another. */ + if (!isPrimary && ctx.abortSignal?.aborted) break + + let providerId = primaryProviderId + if (!isPrimary) { + try { + providerId = getProviderFromModel(candidate.model) + await validateModelProvider(ctx.userId, ctx.workspaceId, candidate.model, ctx) + assertFallbackFamilyCredentials(providerId, primaryProviderId) + } catch (error) { + onSkip(candidate.model, error, 'unusable') + continue + } + } + + const sameProvider = providerId === primaryProviderId + const info: ModelCandidateAttempt = { + isPrimary, + hasNext, + providerId, + sameProvider, + credentials: sameProvider ? resolveSavedCredentials : undefined, + } + if (!isPrimary && prepare) { + try { + await prepare(candidate, info) + } catch (error) { + onSkip(candidate.model, error, 'prepare') + continue + } + } + + try { + const result = await attempt(candidate, info) + recordModelFallbacks(ctx, block, failedModels) + return result + } catch (error) { + lastError = error + failedModels.push(isPrimary ? (primary.traceName ?? primary.model) : candidate.model) + /** + * The abort signal is checked before the error itself because a handler may + * rewrite a timeout into a plain `Error` without a cause, which the predicate + * would then treat as replayable. + */ + if (!hasNext || ctx.abortSignal?.aborted || !isRetryableBlockError(error)) break + onFallback({ + error, + failedModel: candidate.model, + nextModel: candidates[index + 1].model, + position: index + 1, + }) + } + } + + recordModelFallbacks(ctx, block, failedModels.slice(0, -1)) + throw lastError +} diff --git a/apps/sim/hooks/queries/demo-requests.ts b/apps/sim/hooks/queries/demo-requests.ts index 654aaf13e14..c28c7dad43c 100644 --- a/apps/sim/hooks/queries/demo-requests.ts +++ b/apps/sim/hooks/queries/demo-requests.ts @@ -11,10 +11,10 @@ import { captureClientEvent } from '@/lib/posthog/client' const logger = createLogger('DemoRequestMutation') /** - * Submit an inbound demo request. The route notifies the sales inbox - * (`enterprise@`, replying to the visitor) — no email is sent to the visitor. - * Used as a best-effort notification from the demo-booking flow: failures are - * logged and never block the visitor from scheduling. + * Submit an inbound demo request. The route records how far the form vouched + * for the visitor's email, which decides their booking, then notifies the sales + * inbox (`enterprise@`, replying to the visitor); no email goes to the visitor. + * The demo form opens the calendar only once this succeeds. */ export function useSubmitDemoRequest() { return useMutation({ diff --git a/apps/sim/lib/analytics/attribution.ts b/apps/sim/lib/analytics/attribution.ts index 82fa37b720e..0b6c62412cc 100644 --- a/apps/sim/lib/analytics/attribution.ts +++ b/apps/sim/lib/analytics/attribution.ts @@ -49,7 +49,6 @@ const CLICK_ID_PARAMETERS = [ 'ttclid', 'twclid', 'rdt_cid', - 'bfcid', ] as const /** diff --git a/apps/sim/lib/analytics/freebuff.server.ts b/apps/sim/lib/analytics/freebuff.server.ts deleted file mode 100644 index 3db98deb40a..00000000000 --- a/apps/sim/lib/analytics/freebuff.server.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { createLogger } from '@sim/logger' -import { sleep } from '@sim/utils/helpers' -import { backoffWithJitter } from '@sim/utils/retry' -import type { FreebuffConversionEvent } from '@/lib/analytics/freebuff' -import { env } from '@/lib/core/config/env' - -const logger = createLogger('FreebuffConversions') - -const FREEBUFF_CONVERSIONS_URL = 'https://freebuff.com/api/advertisers/conversions' -const MAX_ATTEMPTS = 4 -const REQUEST_TIMEOUT_MS = 10_000 - -/** Mirrors the tag's own click-id check, so a tampered cookie is never forwarded. */ -const CLICK_ID_SHAPE = /^bfc_[A-Za-z0-9._-]{1,508}$/ - -interface FreebuffConversion { - clickId: string - eventType: FreebuffConversionEvent - /** Idempotency key, shared with the tag call for the same conversion. */ - eventId: string - occurredAt: Date -} - -/** - * Server-to-server conversion postback. Network failures and 5xx responses are - * retried with the same `eventId` and `occurredAt`; every 4xx is terminal. A - * `deduped` answer means the tag already reported it and is a success. Never - * throws, so a caller can fire and forget. - */ -export async function reportFreebuffConversion(conversion: FreebuffConversion): Promise { - const apiKey = env.FREEBUFF_API_KEY - if (!apiKey || !CLICK_ID_SHAPE.test(conversion.clickId)) return - - const body = JSON.stringify({ - clickId: conversion.clickId, - eventType: conversion.eventType, - eventId: conversion.eventId, - occurredAt: conversion.occurredAt.toISOString(), - }) - const context = { eventType: conversion.eventType, eventId: conversion.eventId } - - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - let status: number | undefined - try { - const response = await fetch(FREEBUFF_CONVERSIONS_URL, { - method: 'POST', - headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, - body, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }) - status = response.status - const result = await response.text().catch(() => '') - if (response.ok) { - logger.info('Freebuff conversion recorded', { ...context, result }) - return - } - if (status < 500) { - logger.warn('Freebuff conversion rejected', { ...context, status, result }) - return - } - } catch (error) { - logger.warn('Freebuff conversion request failed', { ...context, attempt, error }) - } - if (attempt < MAX_ATTEMPTS) await sleep(backoffWithJitter(attempt, null)) - else logger.error('Freebuff conversion postback gave up', { ...context, status }) - } -} diff --git a/apps/sim/lib/analytics/freebuff.ts b/apps/sim/lib/analytics/freebuff.ts deleted file mode 100644 index 6f994523d14..00000000000 --- a/apps/sim/lib/analytics/freebuff.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Freebuff Ads conversion tracking. A Freebuff ad click lands with a signed - * `?bfcid=` click id; the hosted tag stores it in a first-party `bfcid` cookie, - * and each conversion is reported twice with the same `eventId` — once by the - * tag and once by the server postback — so Freebuff dedupes them into one. - * - * @see https://freebuff.com/docs/advertisers/conversions - */ - -export const FREEBUFF_TAG_SRC = 'https://freebuff.com/freebuff-tag.js' as const - -/** First-party cookie the tag writes the captured click id to. */ -export const FREEBUFF_CLICK_ID_COOKIE = 'bfcid' as const - -export type FreebuffConversionEvent = 'signup_completed' - -type FreebuffCommand = ( - command: 'conversion', - eventType: FreebuffConversionEvent, - options?: { eventId?: string } -) => void - -declare global { - interface Window { - freebuff?: FreebuffCommand & { q?: unknown[][] } - } -} - -/** Queues commands until the async tag loads and replays them. */ -export function installFreebuffStub(): void { - if (window.freebuff) return - const queue: unknown[][] = [] - window.freebuff = Object.assign((...args: unknown[]) => void queue.push(args), { q: queue }) -} - -/** Deletes the tag's click-id cookie, which it writes host-only on `Path=/`. */ -export function clearFreebuffClickId(): void { - if (!document.cookie.includes(`${FREEBUFF_CLICK_ID_COOKIE}=`)) return - document.cookie = `${FREEBUFF_CLICK_ID_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax` -} - -/** - * Reports a conversion from the page. Call only after the caller has verified - * marketing consent; the tag is a no-op for visitors who did not arrive from an - * ad. `eventId` must match the one the server postback sends. - */ -export function trackFreebuffConversion(event: FreebuffConversionEvent, eventId: string): void { - window.freebuff?.('conversion', event, { eventId }) -} diff --git a/apps/sim/lib/api/contracts/demo-requests.ts b/apps/sim/lib/api/contracts/demo-requests.ts index 47fb32a7088..34fc5e84196 100644 --- a/apps/sim/lib/api/contracts/demo-requests.ts +++ b/apps/sim/lib/api/contracts/demo-requests.ts @@ -55,6 +55,9 @@ export const demoRequestSchema = z.object({ error: 'Please select company size', }), details: z.string().trim().min(1, 'Details are required').max(2000), + /** Honeypot: hidden from people, so any value marks the submission as automated. */ + website: z.string().optional(), + captchaToken: z.string().optional(), }) export type DemoRequestPayload = z.infer @@ -80,3 +83,38 @@ export const submitDemoRequestContract = defineRouteContract({ schema: demoRequestResponseSchema, }, }) + +/** + * The envelope of a Cal.com webhook delivery. Only `BOOKING_REQUESTED` carries a + * booking to review; every other trigger, including the dashboard's test ping, + * is acknowledged and ignored. + */ +export const calWebhookEnvelopeSchema = z.object({ + triggerEvent: z.string(), + payload: z.unknown().optional(), +}) + +/** The fields of a requested booking that the demo booking review reads. */ +export const calBookingRequestSchema = z.object({ + uid: z.string().min(1), + attendees: z.array(z.object({ email: z.string().min(1) })).min(1), +}) + +/** + * The Cal.com webhook for the demo event. Cal.com is the only caller, so no + * client reads this contract; it declares the shapes the route accepts and + * answers. The route reads the raw body itself, because the signature covers + * the exact bytes Cal.com sent. + */ +export const calBookingWebhookContract = defineRouteContract({ + method: 'POST', + path: '/api/demo-requests/bookings', + body: calWebhookEnvelopeSchema, + response: { + mode: 'json', + schema: z.object({ + ok: z.literal(true), + decision: z.enum(['confirmed', 'declined', 'pending']).optional(), + }), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index e09ab863dca..bdf4eed2d5e 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -1934,7 +1934,7 @@ export const v2CreateKnowledgeConnectorBodySchema = z .max(255) .optional() .describe( - 'OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or, when this deployment enables it, a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors.' + 'OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors.' ), apiKey: z .string() diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 0b492da2045..6fb31d22b92 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -36,8 +36,6 @@ import { renderWelcomeEmail, } from '@/components/emails' import { readAttributionProperties } from '@/lib/analytics/attribution' -import { FREEBUFF_CLICK_ID_COOKIE } from '@/lib/analytics/freebuff' -import { reportFreebuffConversion } from '@/lib/analytics/freebuff.server' import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { enqueueAdminRemovedChatCleanup } from '@/lib/auth/admin-removal-chat-cleanup' import { holdAdminRemovalStops, stopAdminRemovedRuns } from '@/lib/auth/admin-removal-stops' @@ -373,23 +371,6 @@ export const auth = betterAuth({ userId: user.id, }) - /** - * Only the marketing-consent-gated Freebuff tag writes the `bfcid` - * cookie, and `FreebuffClickIdGuard` deletes it once marketing consent - * is withdrawn or expires. Not awaited: the postback - * retries on its own and must never delay signup. The browser tag - * reports the same `eventId` on email signup and Freebuff dedupes. - */ - const freebuffClickId = context?.getCookie(FREEBUFF_CLICK_ID_COOKIE) - if (freebuffClickId) { - void reportFreebuffConversion({ - clickId: freebuffClickId, - eventType: 'signup_completed', - eventId: user.id, - occurredAt: user.createdAt, - }) - } - try { PlatformEvents.userSignedUp({ userId: user.id, diff --git a/apps/sim/lib/billing/aws-marketplace/container-license.test.ts b/apps/sim/lib/billing/aws-marketplace/container-license.test.ts new file mode 100644 index 00000000000..d9c817ba494 --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/container-license.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSend } = vi.hoisted(() => ({ mockSend: vi.fn() })) + +vi.mock('@aws-sdk/client-license-manager', () => ({ + LicenseManagerClient: class { + send = mockSend + }, + CheckoutLicenseCommand: class { + constructor(public input: { ProductSKU: string }) {} + }, +})) +vi.mock('@/lib/core/network/proxy-agent.server', () => ({ awsRequestHandler: () => undefined })) +vi.mock('@sim/utils/random', () => ({ randomInt: (min: number) => min })) + +import { applyAwsMarketplaceContainerLicense } from '@/lib/billing/aws-marketplace/container-license' + +const MINUTE = 60 * 1000 + +type Answer = + | 'entitled' + | 'NoEntitlementsAllowedException' + | 'ResourceNotFoundException' + | 'AccessDeniedException' + | 'ServerInternalException' + +/** License Manager's checkout answers in order; the last one repeats once the list runs out. */ +let answers: Answer[] = [] +const checkoutRequests: unknown[] = [] + +function awsError(name: string): Error { + return Object.assign(new Error(name), { name }) +} + +function enterpriseEnabled(): boolean { + return process.env.ENTERPRISE_ENABLED === 'true' +} + +describe('applyAwsMarketplaceContainerLicense', () => { + let kill: ReturnType + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) + answers = [] + checkoutRequests.length = 0 + vi.stubEnv('AWS_MARKETPLACE_LICENSE_PRODUCT_SKU', 'product-placeholder') + kill = vi.spyOn(process, 'kill').mockImplementation(() => true) + mockSend.mockReset() + mockSend.mockImplementation(async (command: { input: unknown }) => { + checkoutRequests.push(command.input) + const answer = answers.length > 1 ? answers.shift() : answers[0] + if (answer !== 'entitled') throw awsError(answer ?? 'ServerInternalException') + return { EntitlementsAllowed: [{ Name: 'enterprise_license', Unit: 'None' }] } + }) + }) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + kill.mockRestore() + Reflect.deleteProperty(process.env, 'ENTERPRISE_ENABLED') + Reflect.deleteProperty(process.env, 'NEXT_PUBLIC_ENTERPRISE_ENABLED') + }) + + it('does nothing outside a Marketplace install', async () => { + vi.stubEnv('AWS_MARKETPLACE_LICENSE_PRODUCT_SKU', undefined) + await applyAwsMarketplaceContainerLicense() + expect(mockSend).not.toHaveBeenCalled() + }) + + it('turns Enterprise on with an uncounted checkout of the entitlement', async () => { + answers = ['entitled'] + await applyAwsMarketplaceContainerLicense() + expect(enterpriseEnabled()).toBe(true) + expect(checkoutRequests).toEqual([ + expect.objectContaining({ + ProductSKU: 'product-placeholder', + CheckoutType: 'PROVISIONAL', + KeyFingerprint: 'aws:294406891311:AWS/Marketplace:issuer-fingerprint', + Entitlements: [{ Name: 'enterprise_license', Unit: 'None' }], + }), + ]) + }) + + it('runs without Enterprise when no license grants the entitlement', async () => { + answers = ['NoEntitlementsAllowedException'] + await applyAwsMarketplaceContainerLicense() + expect(enterpriseEnabled()).toBe(false) + }) + + it('runs without Enterprise when its IAM role cannot check out', async () => { + answers = ['AccessDeniedException'] + await applyAwsMarketplaceContainerLicense() + expect(enterpriseEnabled()).toBe(false) + }) + + it.each(['NoEntitlementsAllowedException', 'ResourceNotFoundException'])( + 'restarts without Enterprise once a second re-check confirms the license ended (%s)', + async (answer) => { + answers = ['entitled', answer] + await applyAwsMarketplaceContainerLicense() + await vi.advanceTimersByTimeAsync(16 * MINUTE) + expect(kill).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(16 * MINUTE) + expect(kill).toHaveBeenCalledWith(process.pid, 'SIGTERM') + } + ) + + it.each(['entitled', 'ServerInternalException'])( + 'keeps Enterprise on when a lone not-entitled answer is followed by %s', + async (answer) => { + answers = [ + 'entitled', + 'NoEntitlementsAllowedException', + answer, + 'NoEntitlementsAllowedException', + 'entitled', + ] + await applyAwsMarketplaceContainerLicense() + await vi.advanceTimersByTimeAsync(80 * MINUTE) + expect(kill).not.toHaveBeenCalled() + } + ) + + it('restarts into Enterprise once the license becomes active', async () => { + answers = ['NoEntitlementsAllowedException', 'entitled'] + await applyAwsMarketplaceContainerLicense() + expect(enterpriseEnabled()).toBe(false) + await vi.advanceTimersByTimeAsync(16 * MINUTE) + expect(kill).toHaveBeenCalledWith(process.pid, 'SIGTERM') + }) + + it.each(['ServerInternalException', 'AccessDeniedException'])( + 'never restarts a running Enterprise deployment over a failed check (%s)', + async (answer) => { + answers = ['entitled', answer] + await applyAwsMarketplaceContainerLicense() + await vi.advanceTimersByTimeAsync(6 * 60 * MINUTE) + expect(kill).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/billing/aws-marketplace/container-license.ts b/apps/sim/lib/billing/aws-marketplace/container-license.ts index fa42f646256..fd4d53d6ab9 100644 --- a/apps/sim/lib/billing/aws-marketplace/container-license.ts +++ b/apps/sim/lib/billing/aws-marketplace/container-license.ts @@ -1,10 +1,7 @@ -import { - type GrantedLicense, - LicenseManagerClient, - ListReceivedLicensesCommand, -} from '@aws-sdk/client-license-manager' +import { CheckoutLicenseCommand, LicenseManagerClient } from '@aws-sdk/client-license-manager' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import { randomInt } from '@sim/utils/random' import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' @@ -12,20 +9,19 @@ import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' * Enterprise entitlement for Sim installed from the AWS Marketplace container listing. * * The listing sells an annual contract whose one dimension is `enterprise_license`. AWS Marketplace - * grants the buyer a license in AWS License Manager, and the running product reads it. While the - * license is active, the Enterprise suite is on (`ENTERPRISE_ENABLED`). Without one, Sim runs as the - * open-source core, so a lapsed renewal or an unreachable License Manager never locks a buyer out - * of their deployment. - * - * The check reads the license rather than checking a unit out of it. A contract usually covers the - * whole deployment with one unit, so checking it out from every replica would make them compete - * for it, and a unit left checked out (a failed check-in holds it for an hour) would read exactly - * like an expired license. + * grants the buyer a license in AWS License Manager, and the running product checks it the way AWS + * documents for a contract that unlocks a tier of features rather than metering a quantity: a + * provisional `CheckoutLicense` of the entitlement with unit `None`. Such a checkout is not counted + * against the contract, so every replica can check at once, and it needs only the permission AWS's + * managed `AWSLicenseManagerConsumptionPolicy` grants. While the license is active, the Enterprise + * suite is on (`ENTERPRISE_ENABLED`). Without one, Sim runs as the open-source core, so a lapsed + * renewal or an unreachable License Manager never locks a buyer out of their deployment. * * Opt-in: nothing here runs unless {@link PRODUCT_SKU_ENV} is set, which only the Marketplace * Helm profile does. Enterprise flags are read once at startup by both the server and the - * browser, so instead of flipping them live, a re-check that sees the answer change asks the - * process to shut down and the orchestrator starts it again in the right mode. + * browser, so instead of flipping them live, a re-check that sees the answer change (a downgrade + * once confirmed) asks the process to shut down and the orchestrator starts it again in the right + * mode. */ const logger = createLogger('AwsMarketplaceContainerLicense') @@ -34,8 +30,8 @@ const logger = createLogger('AwsMarketplaceContainerLicense') const PRODUCT_SKU_ENV = 'AWS_MARKETPLACE_LICENSE_PRODUCT_SKU' /** - * Optional override for where the license is read. License Manager replicates a granted license to - * other Regions, so the default is the pod's own Region, the same chain AWS's EKS walkthrough uses. + * Optional override for where the license is checked. License Manager replicates a granted license + * to other Regions, so the default is the pod's own Region, the same chain AWS's EKS walkthrough uses. */ const LICENSE_REGION_ENV = 'AWS_MARKETPLACE_LICENSE_REGION' @@ -48,21 +44,33 @@ const ENTERPRISE_ENTITLEMENT = 'enterprise_license' /** AWS recommends re-checking every 15 minutes to pick up renewals, upgrades, and cancellations. */ const RECHECK_INTERVAL_MS = 15 * 60 * 1000 +/** Replicas start together; a random part of this added to each re-check keeps them apart. */ +const RECHECK_JITTER_MS = 2 * 60 * 1000 + /** - * Replicas start together, so their re-checks line up; each waits a random part of this before - * restarting, so a renewal or lapse never takes every replica down at once. + * Replicas see a change together; each waits a random part of this before restarting, so a renewal + * or lapse never takes every replica down at once. */ const RESTART_SPREAD_MS = 10 * 60 * 1000 /** - * How the buyer's account holds the license. A delegate grant only shows licenses distributed to - * other accounts, and a grant still awaiting acceptance, or rejected, disabled, or deleted by the - * license administrator, entitles nothing. No status at all is a license the account bought itself. + * Consecutive "not entitled" re-checks a running Enterprise deployment needs before it switches + * Enterprise off, so one spurious answer never restarts a paying customer's replicas. A real lapse + * takes effect one re-check later. */ -const USABLE_RECEIVED_STATUSES = new Set(['ACTIVE', 'WORKFLOW_COMPLETED']) +const DOWNGRADE_CONFIRMATIONS = 2 const ENTERPRISE_FLAGS = ['ENTERPRISE_ENABLED', 'NEXT_PUBLIC_ENTERPRISE_ENABLED'] as const +/** License Manager's answers when the account holds no active license with the entitlement. */ +const NO_ENTITLEMENT_ERRORS = new Set([ + 'NoEntitlementsAllowedException', + 'ResourceNotFoundException', +]) + +/** The ServiceAccount's IAM role does not allow the check: a setup problem, never a verdict. */ +const PERMISSION_ERRORS = new Set(['AccessDeniedException', 'AuthorizationException']) + /** `unknown` is a failed check (network, permissions, throttling), never a verdict on the license. */ type LicenseState = 'entitled' | 'not-entitled' | 'unknown' @@ -78,24 +86,24 @@ function createClient(): LicenseManagerClient { }) } -function isUsableGrant({ ReceivedMetadata: received }: GrantedLicense): boolean { - if (received?.GrantType === 'DelegateGrant') return false - return !received?.ReceivedStatus || USABLE_RECEIVED_STATUSES.has(received.ReceivedStatus) -} - -/** - * An AWS Marketplace license this product's buyer holds is current when License Manager reports it - * `AVAILABLE`, its issuer is AWS Marketplace, it carries the Enterprise entitlement, and the account - * holds it through a grant it may use. License Manager moves an ended, cancelled, or suspended - * license out of `AVAILABLE` itself. - */ -function isActiveEnterpriseLicense(license: GrantedLicense): boolean { - return ( - license.Status === 'AVAILABLE' && - isUsableGrant(license) && - license.Issuer?.KeyFingerprint === MARKETPLACE_KEY_FINGERPRINT && - (license.Entitlements ?? []).some((entitlement) => entitlement.Name === ENTERPRISE_ENTITLEMENT) - ) +function classifyFailure(error: unknown): LicenseState { + const name = error instanceof Error ? error.name : undefined + if (name && NO_ENTITLEMENT_ERRORS.has(name)) { + logger.info('AWS License Manager reports no active Enterprise entitlement', { error: name }) + return 'not-entitled' + } + if (name && PERMISSION_ERRORS.has(name)) { + logger.error( + 'The IAM role cannot check the AWS Marketplace license; allow license-manager:CheckoutLicense', + { error: name } + ) + return 'unknown' + } + logger.warn('Could not check the AWS Marketplace Enterprise license', { + error: name, + message: getErrorMessage(error, 'Unknown error'), + }) + return 'unknown' } async function checkLicense( @@ -103,26 +111,21 @@ async function checkLicense( productSku: string ): Promise { try { - let nextToken: string | undefined - do { - const page = await client.send( - new ListReceivedLicensesCommand({ - Filters: [ - { Name: 'ProductSKU', Values: [productSku] }, - { Name: 'Fingerprint', Values: [MARKETPLACE_KEY_FINGERPRINT] }, - ], - NextToken: nextToken, - }) - ) - if ((page.Licenses ?? []).some(isActiveEnterpriseLicense)) return 'entitled' - nextToken = page.NextToken - } while (nextToken) - return 'not-entitled' + const checkout = await client.send( + new CheckoutLicenseCommand({ + ProductSKU: productSku, + CheckoutType: 'PROVISIONAL', + KeyFingerprint: MARKETPLACE_KEY_FINGERPRINT, + Entitlements: [{ Name: ENTERPRISE_ENTITLEMENT, Unit: 'None' }], + ClientToken: generateId(), + }) + ) + const allowed = (checkout.EntitlementsAllowed ?? []).some( + (entitlement) => entitlement.Name === ENTERPRISE_ENTITLEMENT + ) + return allowed ? 'entitled' : 'not-entitled' } catch (error) { - logger.warn('Could not check the AWS Marketplace Enterprise license', { - message: getErrorMessage(error, 'Unknown error'), - }) - return 'unknown' + return classifyFailure(error) } } @@ -130,6 +133,15 @@ function setEnterpriseFlags(enabled: boolean): void { for (const flag of ENTERPRISE_FLAGS) process.env[flag] = enabled ? 'true' : 'false' } +function restartToApply(state: LicenseState): void { + const delayMs = randomInt(0, RESTART_SPREAD_MS) + logger.info('AWS Marketplace Enterprise license changed; restarting to apply it', { + state, + delayMs, + }) + setTimeout(() => process.kill(process.pid, 'SIGTERM'), delayMs) +} + /** * Resolves the Enterprise license before the server loads, so every Enterprise gate reads the * answer through `ENTERPRISE_ENABLED`, then keeps re-checking. Returns without doing anything on a @@ -150,17 +162,19 @@ export async function applyAwsMarketplaceContainerLicense(): Promise { { state: bootState } ) - const recheck = setInterval(async () => { + const scheduleRecheck = () => { + setTimeout(recheck, RECHECK_INTERVAL_MS + randomInt(0, RECHECK_JITTER_MS)).unref() + } + let consecutiveNotEntitled = 0 + const recheck = async () => { const state = await checkLicense(client, productSku) - const entitledNow = state === 'entitled' - if (state === 'unknown' || entitledNow === enterpriseAtBoot) return - clearInterval(recheck) - const delayMs = randomInt(0, RESTART_SPREAD_MS) - logger.info('AWS Marketplace Enterprise license changed; restarting to apply it', { - state, - delayMs, - }) - setTimeout(() => process.kill(process.pid, 'SIGTERM'), delayMs) - }, RECHECK_INTERVAL_MS) - recheck.unref() + // A failed check keeps the current mode. + consecutiveNotEntitled = state === 'not-entitled' ? consecutiveNotEntitled + 1 : 0 + const changed = + (state === 'entitled' && !enterpriseAtBoot) || + (enterpriseAtBoot && consecutiveNotEntitled >= DOWNGRADE_CONFIRMATIONS) + if (changed) return restartToApply(state) + scheduleRecheck() + } + scheduleRecheck() } diff --git a/apps/sim/lib/consent/scripts.ts b/apps/sim/lib/consent/scripts.ts index a75a4a7110a..6bdc867894d 100644 --- a/apps/sim/lib/consent/scripts.ts +++ b/apps/sim/lib/consent/scripts.ts @@ -1,7 +1,6 @@ import { ahrefsAnalytics } from '@c15t/scripts/ahrefs-analytics' import { gtag } from '@c15t/scripts/google-tag' import { xPixel } from '@c15t/scripts/x-pixel' -import { FREEBUFF_TAG_SRC, installFreebuffStub } from '@/lib/analytics/freebuff' import { getGooglePageLocation, updateGooglePageContext } from '@/lib/consent/google-context' import { isPathOrDescendant } from '@/lib/navigation/paths' @@ -53,19 +52,6 @@ const GLOBAL_CONSENT_SCRIPTS = [ }, }, ahrefsAnalytics({ key: AHREFS_ANALYTICS_KEY }), - /** - * Global rather than landing-only: the ad lands on a marketing page but the - * conversion fires from `/signup`. The tag recovers `?bfcid=` from the - * original navigation entry, so a client-side route change before consent - * resolves does not lose the click id. - */ - { - id: 'freebuff-tag', - src: FREEBUFF_TAG_SRC, - category: 'marketing', - async: true, - onBeforeLoad: installFreebuffStub, - }, ] as const /** Excludes embedded extension pages and restricts Google's loader to production origins. */ diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index b87c640130d..5606ea7b2c2 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -144,6 +144,8 @@ export const env = createEnv({ TRUSTED_ORIGINS: z.string().optional(), // Comma-separated additional origins to trust for auth (e.g., "https://app.example.com,https://www.example.com"). Merged into Better Auth trustedOrigins. BROWSER_EXTENSION_IDS: z.string().optional(), TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification + CAL_API_KEY: z.string().min(1).optional(), // Cal.com API key that confirms or declines /demo booking requests (unset leaves every request for manual review) + CAL_WEBHOOK_SECRET: z.string().min(1).optional(), // Secret Cal.com signs /demo booking webhooks with (unset disables the webhook) ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS) ENCRYPT_ACCOUNT_TOKENS: z.boolean().optional(), // Opt-out: OAuth access/refresh tokens are sealed on write (read by @sim/db) and backfilled by migrate whenever ENCRYPTION_KEY is 64 hex characters; false or 0 writes plaintext. Sealed tokens need v0.9.33+ on every process that reads them @@ -322,7 +324,6 @@ export const env = createEnv({ // Monitoring & Analytics TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint - FREEBUFF_API_KEY: z.string().min(1).optional(), // Freebuff Ads key for server-side conversion postbacks (unset disables them) COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development) GRAFANA_OTLP_ENDPOINT: z.string().url().optional(), // Grafana Cloud OTLP HTTP gateway base URL (e.g., https://otlp-gateway-prod-us-east-0.grafana.net/otlp). Trigger.dev exporters append /v1/traces, /v1/logs, /v1/metrics. @@ -666,7 +667,6 @@ export const env = createEnv({ AGENT_MEMORY_HISTORY: z.boolean().optional(), CREDENTIAL_GROUPS: z.boolean().optional(), // Enable Credential Groups on self-hosted (bypasses hosted requirements) KNOWLEDGE_MEMBER_ACCESS: z.boolean().optional(), // Enable per-member knowledge connectors and hybrid-by-default retrieval globally - KNOWLEDGE_CONNECTOR_SAVED_CREDENTIALS: z.boolean().optional(), // Fallback for the `knowledge-connector-saved-credentials` feature flag off AppConfig // Organizations - for self-hosted deployments ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index f2c79eb2d10..7e17073ae8b 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -108,17 +108,6 @@ const FEATURE_FLAGS = { 'Off-AppConfig falls back to PROJECTS.', fallback: 'PROJECTS', }, - 'knowledge-connector-saved-credentials': { - description: - 'Let a key-based knowledge connector that declares a saved-credential alternative (Amazon S3 ' + - 'with a saved AWS credential) be set up with that credential. Global on/off only; gates new ' + - 'setup and credential changes, never syncing of connectors already using a credential. Turn ' + - 'on only after the release that adds it is live on both the app and Trigger.dev workers. ' + - 'Once any connector uses a saved credential, rolling back to an earlier release is unsafe ' + - 'even with the flag off: older workers unschedule those connectors as credential-removed. ' + - 'Off-AppConfig falls back to KNOWLEDGE_CONNECTOR_SAVED_CREDENTIALS.', - fallback: 'KNOWLEDGE_CONNECTOR_SAVED_CREDENTIALS', - }, 'knowledge-member-access': { description: 'Organization Search (live) and the permission-aware workspace connector modes: members ' + diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index bb880026487..77c90decc36 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -119,8 +119,6 @@ const STATIC_SCRIPT_SRC = [ // X (Twitter) conversion pixel (landing pages) — the base code injects // uwt.js as a