Skip to content

Commit bfdfa4e

Browse files
Merge remote-tracking branch 'origin/staging' into feat/credential-v2-api
2 parents 91ce49f + 0650eab commit bfdfa4e

89 files changed

Lines changed: 21728 additions & 213 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/src/main/browser-agent/session.test.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface MockView {
2121
setPermissionCheckHandler: ReturnType<typeof vi.fn>
2222
}
2323
on: ReturnType<typeof vi.fn>
24+
setUserAgent: ReturnType<typeof vi.fn>
2425
setWindowOpenHandler: ReturnType<typeof vi.fn>
2526
loadURL: ReturnType<typeof vi.fn>
2627
reload: ReturnType<typeof vi.fn>
@@ -169,6 +170,18 @@ describe('browser-agent session', () => {
169170
expect(onTabNavigated).toHaveBeenCalledWith(contents, true)
170171
})
171172

173+
it('gives every tab a user agent with no Electron token in it', () => {
174+
const first = session.ensureTab()
175+
const second = session.addTab()
176+
177+
for (const tab of [first, second]) {
178+
const contents = (tab.view as unknown as MockView).webContents
179+
const agent = contents.setUserAgent.mock.calls.at(-1)?.[0] as string | undefined
180+
expect(agent).toMatch(/^Mozilla\/5\.0 \(.+\) .*Chrome\/\d+\.0\.0\.0 Safari\/537\.36$/)
181+
expect(agent).not.toMatch(/Electron|Sim\//)
182+
}
183+
})
184+
172185
it('settles the tab spinner when only subresources are still loading', () => {
173186
const tab = session.ensureTab()
174187
const contents = (tab.view as unknown as MockView).webContents
@@ -1757,20 +1770,34 @@ describe('browser-agent session', () => {
17571770
expect(event.preventDefault).toHaveBeenCalledOnce()
17581771
})
17591772

1760-
it('permission handlers deny every request on the agent partition', () => {
1773+
it('permission handlers deny every request on the agent partition but the copy button', () => {
17611774
const tab = session.ensureTab()
17621775
const ses = (tab.view as unknown as MockView).webContents.session
17631776
const requestHandler = ses.setPermissionRequestHandler.mock.calls[0][0] as (
17641777
wc: unknown,
17651778
permission: string,
17661779
callback: (granted: boolean) => void
17671780
) => void
1768-
const callback = vi.fn()
1769-
requestHandler(null, 'media', callback)
1770-
expect(callback).toHaveBeenCalledWith(false)
1781+
const checkHandler = ses.setPermissionCheckHandler.mock.calls[0][0] as (
1782+
wc: unknown,
1783+
permission: string
1784+
) => boolean
1785+
1786+
// Reading the clipboard would leak whatever the user last copied anywhere
1787+
// else, so it stays denied alongside everything a page could spy through.
1788+
for (const permission of ['media', 'geolocation', 'notifications', 'clipboard-read']) {
1789+
const callback = vi.fn()
1790+
requestHandler(null, permission, callback)
1791+
expect(callback).toHaveBeenCalledWith(false)
1792+
expect(checkHandler(null, permission)).toBe(false)
1793+
}
17711794

1772-
const checkHandler = ses.setPermissionCheckHandler.mock.calls[0][0] as () => boolean
1773-
expect(checkHandler()).toBe(false)
1795+
// Chromium routes navigator.clipboard.writeText through this one; denying
1796+
// it silently broke every copy button that does not use execCommand.
1797+
const writeCallback = vi.fn()
1798+
requestHandler(null, 'clipboard-sanitized-write', writeCallback)
1799+
expect(writeCallback).toHaveBeenCalledWith(true)
1800+
expect(checkHandler(null, 'clipboard-sanitized-write')).toBe(true)
17741801
})
17751802

17761803
it('leaves nothing of the signed-out user behind in the browser profile', async () => {

apps/desktop/src/main/browser-agent/session.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
isBlockedSubresourceUrl,
6363
subresourceNeedsResolution,
6464
} from '@/main/browser-agent/url-guard'
65+
import { browserUserAgent } from '@/main/browser-agent/user-agent'
6566
import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store'
6667
import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads'
6768
import {
@@ -809,16 +810,40 @@ export async function importAgentCookies(
809810
return { imported, failed }
810811
}
811812

813+
/**
814+
* The single site permission a browsing surface cannot withhold: the one every
815+
* "Copy" button on the web goes through. Blanket-denying it made
816+
* `navigator.clipboard.writeText` reject with `NotAllowedError`, so those
817+
* buttons did nothing at all — no error, no copied text — while the legacy
818+
* `document.execCommand('copy')` path kept working, which is why only some
819+
* sites looked broken.
820+
*
821+
* Granting it hands the page no reach it lacked: Chromium still requires the
822+
* document to be focused and to hold a transient user activation, and a
823+
* sanitized write only places text the page already renders onto the clipboard.
824+
* Reading stays denied — that is the direction that would leak whatever the
825+
* user last copied from anywhere else.
826+
*/
827+
const ALLOWED_SITE_PERMISSIONS = new Set(['clipboard-sanitized-write'])
828+
812829
/**
813830
* Default-deny hardening for the agent partition. Site permissions remain
814-
* denied, while uploads use Chromium's native file chooser and downloads are
815-
* saved into the device-level browser download directory.
831+
* denied apart from ALLOWED_SITE_PERMISSIONS, while uploads use Chromium's
832+
* native file chooser and downloads are saved into the device-level browser
833+
* download directory.
816834
*/
817835
function configureAgentPartition(ses: Session): void {
818836
if (configuredPartitions.has(ses)) return
819837
configuredPartitions.add(ses)
820-
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
821-
ses.setPermissionCheckHandler(() => false)
838+
ses.setPermissionRequestHandler((_wc, permission, callback) =>
839+
callback(ALLOWED_SITE_PERMISSIONS.has(permission))
840+
)
841+
ses.setPermissionCheckHandler((_wc, permission) => ALLOWED_SITE_PERMISSIONS.has(permission))
842+
// Service workers do not inherit a tab's user agent. With only the tab's set,
843+
// the document request carries the browser string while the worker's own
844+
// script request still announces Electron — and on a site that routes its
845+
// fetches through a worker, that is the one the server sees.
846+
ses.setUserAgent(browserUserAgent())
822847
// SSRF choke point for the agent partition. Document navigations (top-level +
823848
// iframes) get the full DNS-resolving check — the one seam every navigation
824849
// passes through, including page-initiated ones the driver never sees (server
@@ -1101,6 +1126,10 @@ function createTabView(): WebContentsView {
11011126
const contents = view.webContents
11021127
registerAgentWebContents(contents)
11031128
configureAgentPartition(contents.session)
1129+
// The session default does not reach a WebContents that already exists, and
1130+
// the first tab is what brings the session into being, so each tab sets its
1131+
// own as well — otherwise tab one browses as Electron and the rest as Chrome.
1132+
contents.setUserAgent(browserUserAgent())
11041133
attachAgentContextMenu(contents, {
11051134
addToChat: (text) => withBrowserScope(scopeId, () => addPageSelectionToChat(contents, text)),
11061135
openTab: (url) => withBrowserScope(scopeId, () => openTabWithUrl(url, false)),
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { app } from 'electron'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { browserUserAgent, stockChromeUserAgent } from '@/main/browser-agent/user-agent'
4+
5+
vi.mock('electron', () => import('@/test/electron-mock'))
6+
7+
const ELECTRON_DEFAULT =
8+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36'
9+
10+
describe('stockChromeUserAgent', () => {
11+
it('drops the application and Electron tokens a browser allowlist rejects', () => {
12+
const agent = stockChromeUserAgent(ELECTRON_DEFAULT)
13+
expect(agent).not.toMatch(/Electron/)
14+
expect(agent).not.toMatch(/Sim\//)
15+
})
16+
17+
it('reproduces the desktop string Chrome sends under user-agent reduction', () => {
18+
expect(stockChromeUserAgent(ELECTRON_DEFAULT)).toBe(
19+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
20+
)
21+
})
22+
23+
it('keeps the platform token of the machine it is running on', () => {
24+
const windowsDefault =
25+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36'
26+
expect(stockChromeUserAgent(windowsDefault)).toContain('(Windows NT 10.0; Win64; x64)')
27+
})
28+
29+
it('passes through a string that is not a Chromium user agent', () => {
30+
expect(stockChromeUserAgent('curl/8.4.0')).toBe('curl/8.4.0')
31+
expect(stockChromeUserAgent('')).toBe('')
32+
})
33+
})
34+
35+
describe('browserUserAgent', () => {
36+
it('derives from the string Electron would otherwise have sent', () => {
37+
app.userAgentFallback = ELECTRON_DEFAULT
38+
39+
expect(browserUserAgent()).toBe(
40+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
41+
)
42+
})
43+
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* The user agent the browser resource presents to sites.
3+
*
4+
* Electron's default string carries two tokens no browser sends —
5+
* `Sim/<version>` and `Electron/<version>`. Chromium's own token sits right
6+
* beside them, but that does not save it: the detection libraries sites gate on
7+
* test for Electron BEFORE Chrome (bowser matches `/electron/i` several
8+
* descriptors ahead of its Chrome one, ua-parser-js reports `Electron` as the
9+
* browser name), so the browser reads as "Electron", which is on nobody's
10+
* supported list. Ashby warns "Ashby does not support this browser"; stricter
11+
* sites refuse to render at all.
12+
*
13+
* Reporting stock Chrome is accurate rather than a disguise — the engine is the
14+
* Chromium build the token already names, and Electron's user-agent client
15+
* hints (`Sec-CH-UA`, `navigator.userAgentData`) only ever carried a Chromium
16+
* brand, so dropping the token makes the header and the hints agree instead of
17+
* contradicting each other.
18+
*/
19+
import { app } from 'electron'
20+
21+
/** Platform token, then the Chromium major version, in the order a Chromium user agent lists them. */
22+
const CHROMIUM_USER_AGENT = /^Mozilla\/5\.0 \(([^)]*)\).* Chrome\/(\d+)\./
23+
24+
/**
25+
* Rebuilds the default user agent as the string Chrome itself sends. Chrome's
26+
* user-agent reduction fixes the desktop form at
27+
* `Mozilla/5.0 (<platform>) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/<major>.0.0.0 Safari/537.36`,
28+
* so keeping the platform token and the Chromium major version — and zeroing
29+
* the rest — reproduces it exactly, with no room left for an application or
30+
* Electron token. A string that is not a Chromium user agent is returned
31+
* unchanged rather than replaced with a guess.
32+
*/
33+
export function stockChromeUserAgent(defaultUserAgent: string): string {
34+
const match = defaultUserAgent.match(CHROMIUM_USER_AGENT)
35+
if (!match) return defaultUserAgent
36+
const [, platform, chromeMajor] = match
37+
return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeMajor}.0.0.0 Safari/537.36`
38+
}
39+
40+
/**
41+
* Derived from the string Electron would otherwise have sent, so the reported
42+
* Chromium version tracks whatever Chromium the app actually ships.
43+
*/
44+
export function browserUserAgent(): string {
45+
return stockChromeUserAgent(app.userAgentFallback)
46+
}

apps/desktop/src/test/electron-mock.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { vi } from 'vitest'
1111
export const app = {
1212
name: 'Sim',
1313
isPackaged: false,
14+
userAgentFallback:
15+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36',
1416
getVersion: vi.fn(() => '1.0.0'),
1517
getName: vi.fn(() => 'Sim'),
1618
setName: vi.fn(),
@@ -152,6 +154,7 @@ function createWebContentsMock() {
152154
findInPage: vi.fn(() => 1),
153155
stopFindInPage: vi.fn(),
154156
setBackgroundThrottling: vi.fn(),
157+
setUserAgent: vi.fn(),
155158
setIgnoreMenuShortcuts: vi.fn(),
156159
getZoomFactor: vi.fn(() => 1),
157160
setZoomFactor: vi.fn(),
@@ -185,6 +188,7 @@ function createWebContentsMock() {
185188
session: {
186189
setPermissionRequestHandler: vi.fn(),
187190
setPermissionCheckHandler: vi.fn(),
191+
setUserAgent: vi.fn(),
188192
webRequest: { onBeforeRequest: vi.fn() },
189193
on: vi.fn(),
190194
},

apps/docs/content/docs/en/integrations/logrocket.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Summarize sessions, manage users, and tag releases in LogRocket
55

66
import { BlockInfoCard } from "@/components/ui/block-info-card"
77

8-
<BlockInfoCard
8+
<BlockInfoCard
99
type="logrocket"
1010
color="#764ABC"
1111
/>
@@ -187,3 +187,4 @@ Register a release version in LogRocket so uploaded source maps can decode stack
187187
| Parameter | Type | Description |
188188
| --------- | ---- | ----------- |
189189
| `version` | string | Release version that was registered |
190+

apps/docs/content/docs/en/platform/credentials.mdx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,16 @@ When a workspace secret and a personal secret share the same key name, the **wor
124124

125125
When a workflow runs, secrets resolve in this order:
126126

127-
1. **Workspace secrets** are checked first
128-
2. **Personal secrets** are used as a fallback — from the user who triggered the run (manual) or the workflow owner (automated runs via API, webhook, or schedule)
127+
1. **Workspace secrets** are checked first, and always resolve against the identity running the workflow — the caller when one can be identified, otherwise the workspace's billing account. A run only sees the workspace secrets that identity is allowed to use.
128+
2. **Personal secrets** are used as a fallback, from whichever identity is running:
129+
130+
| Run started by | Personal secrets come from |
131+
| --- | --- |
132+
| Clicking Run, or a personal API key | The person running it |
133+
| A workspace API key, schedule, or webhook | The workflow owner |
134+
| A public API URL with no authentication | Nobody — personal secrets do not resolve |
135+
136+
The workflow owner is the fallback only where nobody can be identified but somebody in the workspace set the trigger up, since those workflows are usually built against the owner's own keys. A public URL can be called by anyone, so it never borrows a person's keys at all — put every secret such a workflow needs in **Workspace**.
129137

130138
## Best Practices
131139

@@ -138,7 +146,7 @@ When a workflow runs, secrets resolve in this order:
138146
{ question: "Are my secrets encrypted at rest?", answer: "Yes. Values saved under Secrets are encrypted before being stored in the database." },
139147
{ question: "Can a saved secret still appear in a workflow result?", answer: "Yes. Functional workflow data is not rewritten, so the raw value can still reach downstream blocks and tools and can appear in workflow execution responses, streams, or callbacks if your workflow deliberately returns or prints it. Log-facing views and read APIs receive a protected copy after a successful {{KEY}} resolution. Before content is sent to a model, exact values from the run's authorized secret catalog are replaced with placeholders, but encoded or otherwise transformed values remain outside that protection." },
140148
{ question: "What happens if both a workspace secret and a personal secret have the same key name?", answer: "Among secrets available to the execution actor, the workspace secret takes precedence and the personal secret is the fallback. An inaccessible workspace secret does not shadow an authorized personal value." },
141-
{ question: "Who determines which personal secret is used for automated runs?", answer: "For manual runs, the personal secrets of the user who clicked Run are used as fallback. For automated runs triggered by API, webhook, or schedule, the personal secrets of the workflow owner are used instead." },
149+
{ question: "Who determines which personal secret is used for automated runs?", answer: "Whoever is running it, when that can be identified. Clicking Run or calling with a personal API key uses that person's personal secrets. A workspace API key, schedule, or webhook has no identifiable caller, so it falls back to the workflow owner's — those triggers are set up inside the workspace and the workflow is usually built against the owner's own keys. A public API URL with no authentication can be called by anyone, so no personal secrets resolve at all — those workflows run on workspace secrets only." },
142150
{ question: "Can I import secrets from a .env file?", answer: "Yes. Paste .env-style content (KEY=VALUE format) into any key or value field and the secrets will be auto-populated. The parser supports export KEY=VALUE, quoted values, and inline comments." },
143151
{ question: "What happens if I delete a secret that is used in a workflow?", answer: "The workflow will fail at any block that references the deleted secret during execution because the value cannot be resolved. Update any references before deleting a secret." },
144152
]} />

apps/docs/openapi-v2-tables.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6806,7 +6806,7 @@
68066806
"enum": ["live", "deployed"]
68076807
},
68086808
"type": {
6809-
"description": "Replacement workflow-group producer type.",
6809+
"description": "Workflow-group producer type. Must match the group's stored type — a group's producer cannot be changed after creation.",
68106810
"type": "string",
68116811
"enum": ["manual", "enrichment"]
68126812
},

apps/sim/app/_styles/globals.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212
:root {
1313
--sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */
14-
--sidebar-collapsed-width: 51px; /* icon rail on web; desktop overrides to 0 before first paint */
14+
--sidebar-collapsed-width: 48px; /* icon rail on web; desktop overrides to 0 before first paint */
1515
--sidebar-expanded-width: 238px; /* SIDEBAR_WIDTH.DEFAULT; the width to restore to, held even while collapsed */
1616
--desktop-title-bar-height: 0px; /* macOS traffic-light lane; desktop overrides before first paint */
1717
--workspace-content-title-bar-inset: 0px; /* lane the content pane must leave clear; only non-zero when the pane, not the sidebar, sits under it */

apps/sim/app/api/cron/cleanup-stale-executions/route.test.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ function flattenConditions(condition: unknown): MockCondition[] {
3737
return [node, ...(node.conditions?.flatMap((child) => flattenConditions(child)) ?? [])]
3838
}
3939

40+
function hasToSQL(value: unknown): value is { toSQL: () => { sql: string; params: unknown[] } } {
41+
return typeof value === 'object' && value !== null && 'toSQL' in value
42+
}
43+
44+
/**
45+
* Collects the leaves of a nested `sql` expression. The duration expression is
46+
* built by a shared helper, so the values it binds sit one level below the
47+
* fragment this route assembles rather than directly in its own params.
48+
*/
49+
function flattenSqlParams(expression: { sql: string; params: unknown[] }): unknown[] {
50+
return expression.params.flatMap((param) =>
51+
hasToSQL(param) ? flattenSqlParams(param.toSQL()) : [param]
52+
)
53+
}
54+
4055
function createRequest() {
4156
return createMockRequest(
4257
'GET',
@@ -96,11 +111,7 @@ describe('stale execution cleanup deadline grace', () => {
96111
'toSQL' in value &&
97112
value.toSQL().sql.includes('EXTRACT(EPOCH')
98113
)
99-
const totalDurationExpression = update.totalDurationMs.toSQL()
100-
const cleanupTimestamp = totalDurationExpression.params.find(
101-
(value): value is { toSQL: () => { sql: string; params: unknown[] } } =>
102-
typeof value === 'object' && value !== null && 'toSQL' in value
103-
)
114+
const totalDurationLeaves = flattenSqlParams(update.totalDurationMs.toSQL())
104115

105116
expect(errorExpression.sql).toContain('CASE')
106117
expect(errorExpression.sql).toContain('IS NOT NULL')
@@ -111,11 +122,9 @@ describe('stale execution cleanup deadline grace', () => {
111122
)
112123
expect(staleDurationExpression?.toSQL().sql).toContain('ROUND')
113124
expect(staleDurationExpression?.toSQL().params).toContain(workflowExecutionLogs.startedAt)
114-
expect(totalDurationExpression.sql).toContain('LEAST')
115-
expect(totalDurationExpression.sql).toContain('ROUND')
116-
expect(totalDurationExpression.params).toContain(2_147_483_647)
117-
expect(totalDurationExpression.params).toContain(workflowExecutionLogs.startedAt)
118-
expect(cleanupTimestamp?.toSQL().params).toEqual([new Date('2026-08-03T12:10:00.000Z')])
125+
expect(totalDurationLeaves).toContain(2_147_483_647)
126+
expect(totalDurationLeaves).toContain(workflowExecutionLogs.startedAt)
127+
expect(totalDurationLeaves).toContainEqual(new Date('2026-08-03T12:10:00.000Z'))
119128
expect(update.endedAt).toEqual(new Date('2026-08-03T12:10:00.000Z'))
120129
} finally {
121130
vi.useRealTimers()

0 commit comments

Comments
 (0)