Skip to content

Commit 9b1b022

Browse files
committed
Merge remote-tracking branch 'origin/staging' into staging-v11
# Conflicts: # apps/sim/blocks/types.ts # apps/sim/lib/core/config/env.ts # apps/sim/lib/execution/remote-sandbox/conformance.test.ts # apps/sim/lib/execution/remote-sandbox/index.ts # apps/sim/lib/execution/remote-sandbox/types.ts
2 parents b18d312 + 1a23438 commit 9b1b022

102 files changed

Lines changed: 10900 additions & 439 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/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"start": "electron .",
1919
"package:dir": "bun run build && electron-builder --mac dir --publish never",
2020
"package:mac": "bun run build && electron-builder --mac --publish never",
21-
"package:share": "bun run build && electron-builder --mac --publish never -c.mac.timestamp=none",
21+
"package:share": "bun run scripts/package-share.ts",
2222
"install:local": "bun run scripts/install-local.ts",
2323
"type-check": "tsc --noEmit",
2424
"lint": "biome check --write --unsafe .",

apps/desktop/scripts/channels.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Build-time channel identity, derived from the origin a build is baked to.
3+
*
4+
* Must stay in sync with APP_NAME_FOR_CHANNEL / channelForOrigin in
5+
* src/main/config.ts. That pair is the RUNTIME source of truth — the app calls
6+
* app.setName() from its baked origin at startup, which decides the userData
7+
* directory and single-instance lock — and this is what the packager stamps
8+
* into the bundle. When the two disagree, the bundle on disk and the running
9+
* app disagree about which app they are: a dev-pointed build packaged as
10+
* "Sim.app" with the production bundle id installs over a real production
11+
* install while naming itself "Sim Dev" at runtime.
12+
*/
13+
14+
/** Canonical no-redirect origins per environment. */
15+
export const PROD_ORIGIN = 'https://www.sim.ai'
16+
export const STAGING_ORIGIN = 'https://www.staging.sim.ai'
17+
export const DEV_ORIGIN = 'https://www.dev.sim.ai'
18+
export const LOCAL_ORIGIN = 'http://localhost:3000'
19+
20+
export interface ChannelIdentity {
21+
/** Display + bundle name; also the userData directory name. */
22+
name: string
23+
appId: string
24+
/** Baked default origin + persisted settings origin. */
25+
origin: string
26+
/**
27+
* Artifact filename stem, and the per-channel scratch directory name.
28+
* Space-free for the same reason electron-builder.yml's artifactName is:
29+
* GitHub rewrites asset names containing spaces, which desyncs the
30+
* electron-updater manifest from the uploaded files.
31+
*/
32+
slug: string
33+
}
34+
35+
export const PROD: ChannelIdentity = {
36+
name: 'Sim',
37+
appId: 'ai.sim.desktop',
38+
origin: PROD_ORIGIN,
39+
slug: 'sim',
40+
}
41+
export const STAGING: ChannelIdentity = {
42+
name: 'Sim Staging',
43+
appId: 'ai.sim.desktop.staging',
44+
origin: STAGING_ORIGIN,
45+
slug: 'sim-staging',
46+
}
47+
export const DEV: ChannelIdentity = {
48+
name: 'Sim Dev',
49+
appId: 'ai.sim.desktop.dev',
50+
origin: DEV_ORIGIN,
51+
slug: 'sim-dev',
52+
}
53+
export const LOCAL: ChannelIdentity = {
54+
name: 'Sim Local',
55+
appId: 'ai.sim.desktop.local',
56+
origin: LOCAL_ORIGIN,
57+
slug: 'sim-local',
58+
}
59+
60+
/** Every channel, in the order a full run builds them. */
61+
export const ALL_CHANNELS: readonly ChannelIdentity[] = [PROD, STAGING, DEV, LOCAL]
62+
63+
/**
64+
* Resolves the identity a build baked to `origin` must carry. Mirrors
65+
* channelForOrigin in src/main/config.ts, including its fall-through to
66+
* production for unrecognized hosts (self-hosted origins are production
67+
* builds pointed elsewhere). An empty origin means "unset", which the app
68+
* resolves to DEFAULT_ORIGIN — production.
69+
*/
70+
export function identityForOrigin(origin: string): ChannelIdentity {
71+
if (!origin) return PROD
72+
let host: string
73+
try {
74+
host = new URL(origin).hostname.toLowerCase()
75+
} catch {
76+
return PROD
77+
}
78+
if (host === 'localhost' || host === '127.0.0.1') return LOCAL
79+
if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) return DEV
80+
if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) return STAGING
81+
return PROD
82+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* Share build: packages distributable .dmg/.zip artifacts from the current
3+
* checkout, signed with whatever identity is on the machine (no notarization,
4+
* no trusted timestamps) — the "send someone a build to try" loop.
5+
*
6+
* bun run package:share # production only
7+
* bun run package:share --all # all four channels
8+
* bun run package:share --staging --dev # just these two
9+
* bun run package:share --dir # skip dmg/zip, package the .app only (fast)
10+
* SIM_DESKTOP_DEFAULT_ORIGIN=https://sim.acme.example bun run package:share
11+
*
12+
* Each channel lands in release/<slug>/ with artifacts named for it — sim,
13+
* sim-staging, sim-dev, sim-local. electron-builder.yml's artifactName is a
14+
* single literal ("Sim-${version}-${arch}"), so without the override every
15+
* channel writes the same filename and the last build silently wins. Only this
16+
* path overrides it: the release workflow publishes one channel per GitHub
17+
* release, where the flat name is what electron-updater expects.
18+
*
19+
* The baked origin decides the bundle identity, exactly as it decides the
20+
* runtime one. This used to shell straight into electron-builder, which took
21+
* productName/appId from electron-builder.yml — always the production pair — so
22+
* a dev-pointed share packaged as "Sim.app" with the production bundle id while
23+
* naming itself "Sim Dev" at runtime.
24+
*
25+
* Channels build ONE AT A TIME on purpose. scripts/build.ts writes the bundle
26+
* to dist/ and the app icon to build/generated-icon.icns, both fixed paths, so
27+
* concurrent channels would overwrite each other's bundle mid-package and ship
28+
* a dmg whose baked origin belongs to a different environment — invisible until
29+
* someone signs in. Giving each channel its own bundle directory is what would
30+
* make concurrency safe, and the flag that redirects the app entry point
31+
* (-c.extraMetadata.main) rewrites this package.json IN THE SOURCE TREE,
32+
* stripping scripts and devDependencies. If parallelism is worth it later, the
33+
* way to get it is a per-channel project directory (electron-builder --project)
34+
* — not extraMetadata.
35+
*/
36+
import { spawnSync } from 'node:child_process'
37+
import { rmSync } from 'node:fs'
38+
import { ALL_CHANNELS, type ChannelIdentity, identityForOrigin } from './channels'
39+
40+
const FLAG_TO_CHANNEL: Record<string, ChannelIdentity> = Object.fromEntries(
41+
ALL_CHANNELS.map((channel) => [
42+
`--${channel.slug.replace(/^sim-/, '').replace(/^sim$/, 'prod')}`,
43+
channel,
44+
])
45+
)
46+
47+
const args = process.argv.slice(2)
48+
const dirOnly = args.includes('--dir')
49+
const bakedOriginOverride = process.env.SIM_DESKTOP_DEFAULT_ORIGIN ?? ''
50+
51+
function selectedChannels(): ChannelIdentity[] {
52+
if (args.includes('--all')) return [...ALL_CHANNELS]
53+
const picked = args.filter((arg) => arg in FLAG_TO_CHANNEL).map((arg) => FLAG_TO_CHANNEL[arg])
54+
if (picked.length > 0) return picked
55+
// No channel flags: honour an explicit origin (self-hosted shares resolve to
56+
// the production identity), otherwise plain production.
57+
return [identityForOrigin(bakedOriginOverride)]
58+
}
59+
60+
const channels = selectedChannels()
61+
// An explicit origin only makes sense for a single-channel run; with several
62+
// channels each one supplies its own.
63+
const originFor = (channel: ChannelIdentity): string =>
64+
channels.length === 1 && bakedOriginOverride ? bakedOriginOverride : channel.origin
65+
66+
function run(command: string, commandArgs: string[], env?: Record<string, string>): void {
67+
const result = spawnSync(command, commandArgs, {
68+
stdio: 'inherit',
69+
env: env ? { ...process.env, ...env } : process.env,
70+
})
71+
if (result.status !== 0) {
72+
console.error(`\n✖ ${command} ${commandArgs.join(' ')} failed`)
73+
process.exit(result.status ?? 1)
74+
}
75+
}
76+
77+
function buildChannel(channel: ChannelIdentity): void {
78+
const origin = originFor(channel)
79+
console.log(`\n• ${channel.slug}: ${channel.name} (${channel.appId}) → ${origin}`)
80+
81+
// electron-builder only writes the output dir for the CURRENT target/arch, so
82+
// an app left by an earlier run with different settings would survive
83+
// alongside the new one.
84+
for (const dir of ['mac-universal', 'mac-arm64', 'mac']) {
85+
rmSync(`release/${channel.slug}/${dir}`, { recursive: true, force: true })
86+
}
87+
// electron-builder's `files: dist/**` packages whatever is in dist/, not just
88+
// what this build wrote. Anything left there by an earlier run — another
89+
// channel's bundle, scratch from an experiment — rides along inside the dmg,
90+
// so a production artifact can end up carrying a dev-pointed bundle. Dead
91+
// weight rather than a live risk (the app boots package.json's `main`), but
92+
// not something to hand to anyone. build.ts rewrites the directory on the
93+
// next line.
94+
rmSync('dist', { recursive: true, force: true })
95+
96+
run('bun', ['run', 'scripts/build.ts'], { SIM_DESKTOP_DEFAULT_ORIGIN: origin })
97+
run('bunx', [
98+
'electron-builder',
99+
'--mac',
100+
...(dirOnly ? ['dir'] : []),
101+
'--publish',
102+
'never',
103+
// Trusted timestamps make codesign do a network round trip to Apple per
104+
// file (hundreds inside the Electron framework). Distribution builds need
105+
// them; a share build does not, and it turns signing into a long stall.
106+
'-c.mac.timestamp=none',
107+
`-c.productName=${channel.name}`,
108+
`-c.appId=${channel.appId}`,
109+
`-c.directories.output=release/${channel.slug}`,
110+
// The ${...} placeholders are electron-builder's own templating, expanded
111+
// by it at packaging time — escaped here so JS leaves them alone.
112+
`-c.artifactName=${channel.slug}-\${version}-\${arch}.\${ext}`,
113+
])
114+
console.log(`✔ ${channel.slug}: release/${channel.slug}/`)
115+
}
116+
117+
// Shared node_modules state, and the one step every channel has in common.
118+
run('bun', ['run', 'scripts/ensure-pty-prebuilds.ts'])
119+
console.log(
120+
`• Building ${channels.length} channel(s)${dirOnly ? ' (dir only)' : ''}: ${channels.map((c) => c.slug).join(', ')}`
121+
)
122+
for (const channel of channels) {
123+
buildChannel(channel)
124+
}

apps/desktop/src/main/browser-agent/page-functions.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,20 @@ describe('secret-field detection', () => {
140140
],
141141
['new-password field', '<input type="text" autocomplete="new-password" />'],
142142
['uppercase autocomplete token', '<input type="text" autocomplete="Current-Password" />'],
143+
// The spec allows space-separated detail tokens and WebAuthn recommends
144+
// this exact value, so whole-string equality missed it.
145+
[
146+
'WebAuthn multi-token autocomplete',
147+
'<input type="text" autocomplete="current-password webauthn" />',
148+
],
149+
[
150+
'section-scoped autocomplete',
151+
'<input type="text" autocomplete="section-login current-password" />',
152+
],
153+
[
154+
'multi-token new-password with surrounding whitespace',
155+
'<input type="text" autocomplete=" new-password webauthn " />',
156+
],
143157
]
144158

145159
it.each(secretCases)('clickElement refuses a %s', (_label, html) => {
@@ -276,6 +290,24 @@ describe('collectSnapshot', () => {
276290
expect(outline).not.toContain('value=')
277291
})
278292

293+
it.each([
294+
['a one-time code', 'one-time-code', '123456'],
295+
['a card number', 'cc-number', '4111111111111111'],
296+
['a card security code', 'cc-csc', '737'],
297+
['a card expiry', 'cc-exp', '12/29'],
298+
])('withholds the value of %s while still listing the field', (_label, token, value) => {
299+
document.body.innerHTML = `<input type="text" autocomplete="${token}" value="${value}" aria-label="Field" />`
300+
visible(document.querySelector('input') as HTMLInputElement)
301+
302+
const outline = outlineOf(collectSnapshot())
303+
304+
// Not reported as a password-field: the agent must still be able to fill
305+
// these, it just never learns what is already there.
306+
expect(outline).not.toContain('password-field')
307+
expect(outline).not.toContain(value)
308+
expect(outline).toContain('value-withheld')
309+
})
310+
279311
it('withholds the value of a revealed password field', () => {
280312
document.body.innerHTML =
281313
'<input type="text" autocomplete="current-password" value="hunter2" aria-label="Password" />'
@@ -317,6 +349,25 @@ describe('readActiveElementState', () => {
317349
expect(readActiveElementState()).toMatchObject({ redacted: true, valuePreview: '' })
318350
})
319351

352+
it.each([
353+
['a one-time code', 'one-time-code', '123456'],
354+
['a card number', 'cc-number', '4111111111111111'],
355+
['a card security code', 'cc-csc', '737'],
356+
])('withholds %s on readback but still confirms the fill', (_label, token, value) => {
357+
document.body.innerHTML = `<input type="text" autocomplete="${token}" value="${value}" />`
358+
setActiveElement(document, document.querySelector('input'))
359+
360+
// valueLength is kept: without it a successful type reads as "still empty"
361+
// and the agent types the code a second time.
362+
expect(readActiveElementState()).toEqual({
363+
activeElement: 'input',
364+
selectedChars: 0,
365+
valueLength: value.length,
366+
valuePreview: '',
367+
redacted: true,
368+
})
369+
})
370+
320371
it('reports ordinary fields in full', () => {
321372
document.body.innerHTML = '<input type="text" value="tokyo" />'
322373
setActiveElement(document, document.querySelector('input'))
@@ -343,6 +394,35 @@ describe('readActiveElementState', () => {
343394
})
344395
})
345396

397+
describe('XHTML lower-case tagName', () => {
398+
/** An element whose tagName reads lower-case, as it does in an XHTML document. */
399+
function lowerCaseTagInput(html: string): HTMLInputElement {
400+
document.body.innerHTML = html
401+
const input = document.querySelector('input') as HTMLInputElement
402+
Object.defineProperty(input, 'tagName', { configurable: true, get: () => 'input' })
403+
return input
404+
}
405+
406+
it('still refuses a password field whose tagName is lower-case', () => {
407+
const input = lowerCaseTagInput('<input type="password" />')
408+
register(visible(input))
409+
410+
expect(typeIntoElement(0, 'hunter2', false)).toEqual({ error: 'password' })
411+
expect(input.value).toBe('')
412+
})
413+
414+
it('still withholds the value of a lower-case-tagName credential field', () => {
415+
const input = lowerCaseTagInput(
416+
'<input type="password" value="hunter2" aria-label="Password" />'
417+
)
418+
visible(input)
419+
420+
const outline = outlineOf(collectSnapshot())
421+
422+
expect(outline).not.toContain('hunter2')
423+
})
424+
})
425+
346426
describe('activeElementSecrecy', () => {
347427
it('reports safe for an ordinary field', () => {
348428
document.body.innerHTML = '<input type="text" />'
@@ -386,6 +466,46 @@ describe('activeElementSecrecy', () => {
386466
expect(activeElementSecrecy()).toBe('opaque')
387467
})
388468

469+
it('reports opaque for a password field inside a CLOSED shadow root', () => {
470+
const host = document.createElement('div')
471+
document.body.append(host)
472+
const shadow = host.attachShadow({ mode: 'closed' })
473+
shadow.innerHTML = '<input type="password" />'
474+
// Focus inside a closed root retargets to the host and `shadowRoot` reads
475+
// null, which is exactly what the browser reports and what made this 'safe'.
476+
setActiveElement(document, host)
477+
478+
expect(host.shadowRoot).toBeNull()
479+
expect(activeElementSecrecy()).toBe('opaque')
480+
})
481+
482+
it('reports opaque for a closed shadow root on a custom element', () => {
483+
const host = document.createElement('my-login')
484+
document.body.append(host)
485+
host.attachShadow({ mode: 'closed' }).innerHTML = '<input autocomplete="new-password" />'
486+
setActiveElement(document, host)
487+
488+
expect(activeElementSecrecy()).toBe('opaque')
489+
})
490+
491+
it('still reports safe for a focused element that is focusable in its own right', () => {
492+
// The false-positive guard: a div the page made focusable is focused
493+
// itself, not hiding a shadow tree, so keystrokes are not refused.
494+
document.body.innerHTML = '<div tabindex="0">menu</div>'
495+
setActiveElement(document, document.querySelector('div'))
496+
497+
expect(activeElementSecrecy()).toBe('safe')
498+
})
499+
500+
it('still reports safe for a focused contenteditable', () => {
501+
document.body.innerHTML = '<div contenteditable="true">note</div>'
502+
const editable = document.querySelector('div') as HTMLElement
503+
Object.defineProperty(editable, 'isContentEditable', { get: () => true })
504+
setActiveElement(document, editable)
505+
506+
expect(activeElementSecrecy()).toBe('safe')
507+
})
508+
389509
it('descends into a same-origin frame instead of calling it opaque', () => {
390510
const frame = document.createElement('iframe')
391511
document.body.append(frame)

0 commit comments

Comments
 (0)