From f5b7a370c84756e20df12ecf3e342c07343e3c34 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 29 Jul 2026 12:12:41 -0700 Subject: [PATCH 1/5] fix(desktop): fix release --- apps/desktop/package.json | 2 +- apps/desktop/scripts/channels.ts | 82 ++++++++++++ apps/desktop/scripts/package-share.ts | 124 ++++++++++++++++++ .../desktop/src/main/channel-identity.test.ts | 55 ++++++++ apps/desktop/src/main/config.test.ts | 42 ++++++ apps/desktop/src/main/config.ts | 49 ++++++- 6 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/scripts/channels.ts create mode 100644 apps/desktop/scripts/package-share.ts create mode 100644 apps/desktop/src/main/channel-identity.test.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0ac8f76658f..c8431b946ae 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,7 +18,7 @@ "start": "electron .", "package:dir": "bun run build && electron-builder --mac dir --publish never", "package:mac": "bun run build && electron-builder --mac --publish never", - "package:share": "bun run build && electron-builder --mac --publish never -c.mac.timestamp=none", + "package:share": "bun run scripts/package-share.ts", "install:local": "bun run scripts/install-local.ts", "type-check": "tsc --noEmit", "lint": "biome check --write --unsafe .", diff --git a/apps/desktop/scripts/channels.ts b/apps/desktop/scripts/channels.ts new file mode 100644 index 00000000000..f12fa55b408 --- /dev/null +++ b/apps/desktop/scripts/channels.ts @@ -0,0 +1,82 @@ +/** + * Build-time channel identity, derived from the origin a build is baked to. + * + * Must stay in sync with APP_NAME_FOR_CHANNEL / channelForOrigin in + * src/main/config.ts. That pair is the RUNTIME source of truth — the app calls + * app.setName() from its baked origin at startup, which decides the userData + * directory and single-instance lock — and this is what the packager stamps + * into the bundle. When the two disagree, the bundle on disk and the running + * app disagree about which app they are: a dev-pointed build packaged as + * "Sim.app" with the production bundle id installs over a real production + * install while naming itself "Sim Dev" at runtime. + */ + +/** Canonical no-redirect origins per environment. */ +export const PROD_ORIGIN = 'https://www.sim.ai' +export const STAGING_ORIGIN = 'https://www.staging.sim.ai' +export const DEV_ORIGIN = 'https://www.dev.sim.ai' +export const LOCAL_ORIGIN = 'http://localhost:3000' + +export interface ChannelIdentity { + /** Display + bundle name; also the userData directory name. */ + name: string + appId: string + /** Baked default origin + persisted settings origin. */ + origin: string + /** + * Artifact filename stem, and the per-channel scratch directory name. + * Space-free for the same reason electron-builder.yml's artifactName is: + * GitHub rewrites asset names containing spaces, which desyncs the + * electron-updater manifest from the uploaded files. + */ + slug: string +} + +export const PROD: ChannelIdentity = { + name: 'Sim', + appId: 'ai.sim.desktop', + origin: PROD_ORIGIN, + slug: 'sim', +} +export const STAGING: ChannelIdentity = { + name: 'Sim Staging', + appId: 'ai.sim.desktop.staging', + origin: STAGING_ORIGIN, + slug: 'sim-staging', +} +export const DEV: ChannelIdentity = { + name: 'Sim Dev', + appId: 'ai.sim.desktop.dev', + origin: DEV_ORIGIN, + slug: 'sim-dev', +} +export const LOCAL: ChannelIdentity = { + name: 'Sim Local', + appId: 'ai.sim.desktop.local', + origin: LOCAL_ORIGIN, + slug: 'sim-local', +} + +/** Every channel, in the order a full run builds them. */ +export const ALL_CHANNELS: readonly ChannelIdentity[] = [PROD, STAGING, DEV, LOCAL] + +/** + * Resolves the identity a build baked to `origin` must carry. Mirrors + * channelForOrigin in src/main/config.ts, including its fall-through to + * production for unrecognized hosts (self-hosted origins are production + * builds pointed elsewhere). An empty origin means "unset", which the app + * resolves to DEFAULT_ORIGIN — production. + */ +export function identityForOrigin(origin: string): ChannelIdentity { + if (!origin) return PROD + let host: string + try { + host = new URL(origin).hostname.toLowerCase() + } catch { + return PROD + } + if (host === 'localhost' || host === '127.0.0.1') return LOCAL + if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) return DEV + if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) return STAGING + return PROD +} diff --git a/apps/desktop/scripts/package-share.ts b/apps/desktop/scripts/package-share.ts new file mode 100644 index 00000000000..a58811291a7 --- /dev/null +++ b/apps/desktop/scripts/package-share.ts @@ -0,0 +1,124 @@ +/** + * Share build: packages distributable .dmg/.zip artifacts from the current + * checkout, signed with whatever identity is on the machine (no notarization, + * no trusted timestamps) — the "send someone a build to try" loop. + * + * bun run package:share # production only + * bun run package:share --all # all four channels + * bun run package:share --staging --dev # just these two + * bun run package:share --dir # skip dmg/zip, package the .app only (fast) + * SIM_DESKTOP_DEFAULT_ORIGIN=https://sim.acme.example bun run package:share + * + * Each channel lands in release// with artifacts named for it — sim, + * sim-staging, sim-dev, sim-local. electron-builder.yml's artifactName is a + * single literal ("Sim-${version}-${arch}"), so without the override every + * channel writes the same filename and the last build silently wins. Only this + * path overrides it: the release workflow publishes one channel per GitHub + * release, where the flat name is what electron-updater expects. + * + * The baked origin decides the bundle identity, exactly as it decides the + * runtime one. This used to shell straight into electron-builder, which took + * productName/appId from electron-builder.yml — always the production pair — so + * a dev-pointed share packaged as "Sim.app" with the production bundle id while + * naming itself "Sim Dev" at runtime. + * + * Channels build ONE AT A TIME on purpose. scripts/build.ts writes the bundle + * to dist/ and the app icon to build/generated-icon.icns, both fixed paths, so + * concurrent channels would overwrite each other's bundle mid-package and ship + * a dmg whose baked origin belongs to a different environment — invisible until + * someone signs in. Giving each channel its own bundle directory is what would + * make concurrency safe, and the flag that redirects the app entry point + * (-c.extraMetadata.main) rewrites this package.json IN THE SOURCE TREE, + * stripping scripts and devDependencies. If parallelism is worth it later, the + * way to get it is a per-channel project directory (electron-builder --project) + * — not extraMetadata. + */ +import { spawnSync } from 'node:child_process' +import { rmSync } from 'node:fs' +import { ALL_CHANNELS, type ChannelIdentity, identityForOrigin } from './channels' + +const FLAG_TO_CHANNEL: Record = Object.fromEntries( + ALL_CHANNELS.map((channel) => [ + `--${channel.slug.replace(/^sim-/, '').replace(/^sim$/, 'prod')}`, + channel, + ]) +) + +const args = process.argv.slice(2) +const dirOnly = args.includes('--dir') +const bakedOriginOverride = process.env.SIM_DESKTOP_DEFAULT_ORIGIN ?? '' + +function selectedChannels(): ChannelIdentity[] { + if (args.includes('--all')) return [...ALL_CHANNELS] + const picked = args.filter((arg) => arg in FLAG_TO_CHANNEL).map((arg) => FLAG_TO_CHANNEL[arg]) + if (picked.length > 0) return picked + // No channel flags: honour an explicit origin (self-hosted shares resolve to + // the production identity), otherwise plain production. + return [identityForOrigin(bakedOriginOverride)] +} + +const channels = selectedChannels() +// An explicit origin only makes sense for a single-channel run; with several +// channels each one supplies its own. +const originFor = (channel: ChannelIdentity): string => + channels.length === 1 && bakedOriginOverride ? bakedOriginOverride : channel.origin + +function run(command: string, commandArgs: string[], env?: Record): void { + const result = spawnSync(command, commandArgs, { + stdio: 'inherit', + env: env ? { ...process.env, ...env } : process.env, + }) + if (result.status !== 0) { + console.error(`\n✖ ${command} ${commandArgs.join(' ')} failed`) + process.exit(result.status ?? 1) + } +} + +function buildChannel(channel: ChannelIdentity): void { + const origin = originFor(channel) + console.log(`\n• ${channel.slug}: ${channel.name} (${channel.appId}) → ${origin}`) + + // electron-builder only writes the output dir for the CURRENT target/arch, so + // an app left by an earlier run with different settings would survive + // alongside the new one. + for (const dir of ['mac-universal', 'mac-arm64', 'mac']) { + rmSync(`release/${channel.slug}/${dir}`, { recursive: true, force: true }) + } + // electron-builder's `files: dist/**` packages whatever is in dist/, not just + // what this build wrote. Anything left there by an earlier run — another + // channel's bundle, scratch from an experiment — rides along inside the dmg, + // so a production artifact can end up carrying a dev-pointed bundle. Dead + // weight rather than a live risk (the app boots package.json's `main`), but + // not something to hand to anyone. build.ts rewrites the directory on the + // next line. + rmSync('dist', { recursive: true, force: true }) + + run('bun', ['run', 'scripts/build.ts'], { SIM_DESKTOP_DEFAULT_ORIGIN: origin }) + run('bunx', [ + 'electron-builder', + '--mac', + ...(dirOnly ? ['dir'] : []), + '--publish', + 'never', + // Trusted timestamps make codesign do a network round trip to Apple per + // file (hundreds inside the Electron framework). Distribution builds need + // them; a share build does not, and it turns signing into a long stall. + '-c.mac.timestamp=none', + `-c.productName=${channel.name}`, + `-c.appId=${channel.appId}`, + `-c.directories.output=release/${channel.slug}`, + // The ${...} placeholders are electron-builder's own templating, expanded + // by it at packaging time — escaped here so JS leaves them alone. + `-c.artifactName=${channel.slug}-\${version}-\${arch}.\${ext}`, + ]) + console.log(`✔ ${channel.slug}: release/${channel.slug}/`) +} + +// Shared node_modules state, and the one step every channel has in common. +run('bun', ['run', 'scripts/ensure-pty-prebuilds.ts']) +console.log( + `• Building ${channels.length} channel(s)${dirOnly ? ' (dir only)' : ''}: ${channels.map((c) => c.slug).join(', ')}` +) +for (const channel of channels) { + buildChannel(channel) +} diff --git a/apps/desktop/src/main/channel-identity.test.ts b/apps/desktop/src/main/channel-identity.test.ts new file mode 100644 index 00000000000..094f01e9c78 --- /dev/null +++ b/apps/desktop/src/main/channel-identity.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { APP_NAME_FOR_CHANNEL, channelForOrigin, DEFAULT_ORIGIN } from '@/main/config' +import { classifyNavigation } from '@/main/navigation' +import { DEV, identityForOrigin, LOCAL, PROD, STAGING } from '../../scripts/channels' + +/** + * The packager stamps a bundle name/id from scripts/channels.ts while the app + * names itself at runtime from config.ts. Nothing linked the two, so they were + * free to drift — which is how the share build ended up packaging a + * dev-pointed app under the production identity. + */ +describe('channel identity', () => { + it('packages every channel under the name it gives itself at runtime', () => { + for (const identity of [PROD, STAGING, DEV, LOCAL]) { + expect(APP_NAME_FOR_CHANNEL[channelForOrigin(identity.origin)]).toBe(identity.name) + } + }) + + it('resolves an unset baked origin to the same channel as the app default', () => { + expect(identityForOrigin('')).toBe(PROD) + expect(PROD.origin).toBe(DEFAULT_ORIGIN) + expect(channelForOrigin(DEFAULT_ORIGIN)).toBe('prod') + }) + + it('treats an unrecognized (self-hosted) origin as production', () => { + expect(identityForOrigin('https://sim.acme-corp.example')).toBe(PROD) + }) +}) + +/** + * The shipped default must be an origin the environment SERVES. When it was + * the apex (which 301s to www) the window sat one origin away from appOrigin, + * so `fromAuthSurface` was never true and social login was misread as an + * integration connect — the "finish connecting in your browser" dialog + * instead of the login handoff. + */ +describe('social login from the shipped default origin', () => { + it('hands off to the system browser instead of offering a connect', () => { + expect( + classifyNavigation('https://accounts.google.com/o/oauth2/v2/auth?client_id=x', { + appOrigin: DEFAULT_ORIGIN, + currentUrl: `${DEFAULT_ORIGIN}/login`, + }) + ).toBe('idp-system-login') + }) + + it('still offers the connect dialog for an integration connect mid-workspace', () => { + expect( + classifyNavigation('https://accounts.google.com/o/oauth2/v2/auth?client_id=x', { + appOrigin: DEFAULT_ORIGIN, + currentUrl: `${DEFAULT_ORIGIN}/workspace/ws1/w/wf1`, + }) + ).toBe('idp-system-connect') + }) +}) diff --git a/apps/desktop/src/main/config.test.ts b/apps/desktop/src/main/config.test.ts index 11133620b9e..ad992210b3d 100644 --- a/apps/desktop/src/main/config.test.ts +++ b/apps/desktop/src/main/config.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { APP_NAME_FOR_CHANNEL, + canonicalOrigin, channelForOrigin, createConfigStore, DEFAULT_ORIGIN, @@ -58,6 +59,27 @@ describe('partitionForOrigin', () => { }) }) +describe('canonicalOrigin', () => { + it('rewrites the pre-www production origin', () => { + expect(canonicalOrigin('https://sim.ai')).toBe('https://www.sim.ai') + }) + + it('leaves every other origin alone', () => { + expect(canonicalOrigin('https://www.sim.ai')).toBe('https://www.sim.ai') + expect(canonicalOrigin('https://www.staging.sim.ai')).toBe('https://www.staging.sim.ai') + expect(canonicalOrigin('https://sim.acme-corp.example')).toBe('https://sim.acme-corp.example') + expect(canonicalOrigin('http://localhost:3000')).toBe('http://localhost:3000') + }) + + it('keeps a rewritten install on its existing cookie partition', () => { + // The whole point of rewriting rather than leaving the apex in place: the + // apex no longer equals DEFAULT_ORIGIN, so an un-rewritten install would + // be moved to a fresh empty jar and silently signed out on update. + expect(partitionForOrigin(canonicalOrigin('https://sim.ai'))).toBe('persist:sim') + expect(partitionForOrigin('https://sim.ai')).not.toBe('persist:sim') + }) +}) + describe('isSafeInternalPath', () => { it('accepts absolute same-origin paths with query', () => { expect(isSafeInternalPath('/workspace/ws1?tab=logs')).toBe(true) @@ -76,6 +98,26 @@ describe('isSafeInternalPath', () => { }) describe('createConfigStore', () => { + it('rewrites a stored pre-www production origin and persists it immediately', () => { + const filePath = tempSettingsPath() + writeFileSync(filePath, JSON.stringify({ origin: 'https://sim.ai', zoomLevel: 1.5 })) + + const store = createConfigStore(filePath, {}) + + expect(store.getOrigin()).toBe('https://www.sim.ai') + expect(store.get('zoomLevel')).toBe(1.5) + // Written without waiting for the debounce, so a crash cannot leave the + // file pointing somewhere the running app is not. + expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe('https://www.sim.ai') + }) + + it('leaves a deliberately configured origin untouched', () => { + const filePath = tempSettingsPath() + writeFileSync(filePath, JSON.stringify({ origin: 'https://sim.acme-corp.example' })) + + expect(createConfigStore(filePath, {}).getOrigin()).toBe('https://sim.acme-corp.example') + }) + it('round-trips settings through disk', () => { const filePath = tempSettingsPath() const store = createConfigStore(filePath, {}) diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 0c679fd10b8..44df3fa6644 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -22,6 +22,9 @@ const logger = createLogger('DesktopConfig') * origins for exact equality — so an apex origin here silently leaves every * page off-origin, which reclassifies social login as an integration connect * and strands the sign-in in the browser with no way back. + * + * Changing this does NOT reach installs that already persisted the old value — + * see {@link canonicalOrigin}. */ function isValidBakedOrigin(origin: string | undefined): origin is string { if (!origin) return false @@ -149,6 +152,33 @@ export function validateOriginInput(raw: string): OriginValidation { return { ok: false, error: 'Server URL must use HTTPS (HTTP is allowed for localhost only)' } } +/** + * Stored origins that must be rewritten on load, because the app can no + * longer work correctly while pointed at them. + * + * Every install that shipped before DEFAULT_ORIGIN moved to www persisted the + * apex on first launch, and a build-time change alone never reaches them — + * the stored value wins over the default. Two things break if they keep it: + * social login is misclassified as an integration connect (see DEFAULT_ORIGIN), + * and, because the apex no longer equals DEFAULT_ORIGIN, partitionForOrigin + * would move them off `persist:sim` onto a fresh, empty jar — signing out + * every existing user on update. Rewriting to the canonical origin fixes the + * first and avoids the second: www.sim.ai IS the new DEFAULT_ORIGIN, so the + * partition stays `persist:sim` and the session survives. + * + * Keyed on the exact stored string, not a host pattern: this rewrites what a + * previous BUILD wrote, never a deliberate operator choice like a self-hosted + * origin that happens to redirect. + */ +const ORIGIN_REWRITES: Readonly> = { + 'https://sim.ai': 'https://www.sim.ai', +} + +/** Applies ORIGIN_REWRITES to a validated origin. */ +export function canonicalOrigin(origin: string): string { + return ORIGIN_REWRITES[origin] ?? origin +} + /** * Maps a server origin to its cookie/storage partition. Each origin gets an * isolated persistent partition so sessions never leak across instances. @@ -229,11 +259,20 @@ export function createConfigStore( env: NodeJS.ProcessEnv = process.env ): ConfigStore { let settings: DesktopSettings = { ...DEFAULT_SETTINGS } + let rewroteOrigin = false try { const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial settings = { ...DEFAULT_SETTINGS, ...parsed } const validated = validateOriginInput(settings.origin) - settings.origin = validated.ok ? validated.origin : DEFAULT_ORIGIN + const loaded = validated.ok ? validated.origin : DEFAULT_ORIGIN + settings.origin = canonicalOrigin(loaded) + rewroteOrigin = settings.origin !== loaded + if (rewroteOrigin) { + logger.info('Rewrote stored server origin to its canonical form', { + from: loaded, + to: settings.origin, + }) + } } catch { settings = { ...DEFAULT_SETTINGS } } @@ -271,6 +310,14 @@ export function createConfigStore( saveTimer.unref?.() } + // Persist a rewrite immediately rather than waiting for the next debounced + // write: the partition and every navigation check already use the canonical + // value from here on, so a crash before the next save must not leave the + // file claiming an origin the running app is no longer using. + if (rewroteOrigin) { + writeNow() + } + return { filePath, getOrigin() { From 8350ce8be07a3ec603fbc7a1765fe08d296f6790 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 29 Jul 2026 12:35:26 -0700 Subject: [PATCH 2/5] fix --- apps/desktop/src/main/index.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 1b22fd23771..f93f9ee9c1c 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -412,9 +412,19 @@ function main(): void { }) void app.whenReady().then(async () => { - // Use the same high-resolution source in packaged and unpackaged apps so - // macOS renders every environment marker consistently in the Dock. - if (process.platform === 'darwin') { + // Unpackaged runs only: `electron .` has no bundle, so macOS shows the + // default Electron atom in the Dock unless we set the mark ourselves. + // + // A PACKAGED app must NOT do this. It already carries the per-channel + // build/icon*.icns, which Finder, the dmg, Launchpad, Cmd-Tab and the Dock + // all read — and the icns holds every representation up to 1024px, while + // these PNGs are a single 512px copy of the same artwork with no @2x. A + // lone PNG loads at scale factor 1, so macOS treated 512px as 512 POINTS + // and upscaled it 2x on Retina: the running app's Dock icon came out + // visibly softer than the identical icon shown for the same app when it + // was closed. Overriding a correct multi-resolution icon with a low-res + // copy of itself is all this ever did once packaged. + if (process.platform === 'darwin' && !app.isPackaged) { const channel = channelForOrigin(config.getOrigin()) app.dock?.setIcon(join(__dirname, '..', 'static', DOCK_ICON_FOR_CHANNEL[channel])) } From 46ed2b8a2fd804508a2bf36326923641efba15c8 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 29 Jul 2026 15:59:17 -0700 Subject: [PATCH 3/5] Updates --- apps/desktop/src/main/index.ts | 16 +++----------- apps/sim/lib/copilot/vfs/operations.test.ts | 24 +++++++++++++++++++++ apps/sim/lib/copilot/vfs/operations.ts | 22 +++++++++++++++++-- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index f93f9ee9c1c..1b22fd23771 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -412,19 +412,9 @@ function main(): void { }) void app.whenReady().then(async () => { - // Unpackaged runs only: `electron .` has no bundle, so macOS shows the - // default Electron atom in the Dock unless we set the mark ourselves. - // - // A PACKAGED app must NOT do this. It already carries the per-channel - // build/icon*.icns, which Finder, the dmg, Launchpad, Cmd-Tab and the Dock - // all read — and the icns holds every representation up to 1024px, while - // these PNGs are a single 512px copy of the same artwork with no @2x. A - // lone PNG loads at scale factor 1, so macOS treated 512px as 512 POINTS - // and upscaled it 2x on Retina: the running app's Dock icon came out - // visibly softer than the identical icon shown for the same app when it - // was closed. Overriding a correct multi-resolution icon with a low-res - // copy of itself is all this ever did once packaged. - if (process.platform === 'darwin' && !app.isPackaged) { + // Use the same high-resolution source in packaged and unpackaged apps so + // macOS renders every environment marker consistently in the Dock. + if (process.platform === 'darwin') { const channel = channelForOrigin(config.getOrigin()) app.dock?.setIcon(join(__dirname, '..', 'static', DOCK_ICON_FOR_CHANNEL[channel])) } diff --git a/apps/sim/lib/copilot/vfs/operations.test.ts b/apps/sim/lib/copilot/vfs/operations.test.ts index e626b702842..e2e0f87a3ba 100644 --- a/apps/sim/lib/copilot/vfs/operations.test.ts +++ b/apps/sim/lib/copilot/vfs/operations.test.ts @@ -67,6 +67,30 @@ describe('grep', () => { expect(matches[1]).toMatchObject({ path: 'a.txt', line: 3, content: 'hello' }) }) + it('truncates oversized matched lines so a minified single-line file cannot return whole', () => { + const giantLine = `{"status":"error"}${'x'.repeat(50_000)}` + const files = vfsFromEntries([['internal/tool-results/run.json', giantLine]]) + const matches = grep(files, 'status', undefined, { outputMode: 'content' }) as Array<{ + content: string + }> + expect(matches).toHaveLength(1) + expect(matches[0].content.length).toBeLessThan(2_200) + expect(matches[0].content).toContain('[line truncated: 50018 chars total]') + }) + + it('truncates oversized context lines around a match', () => { + const files = vfsFromEntries([['a.txt', `before${'y'.repeat(10_000)}\nneedle\nafter`]]) + const matches = grep(files, 'needle', undefined, { + outputMode: 'content', + context: 1, + }) as Array<{ + content: string + }> + expect(matches).toHaveLength(3) + expect(matches[0].content.length).toBeLessThan(2_200) + expect(matches[1].content).toBe('needle') + }) + it('strips CR before end-of-line matching on CRLF content', () => { const files = vfsFromEntries([['x.txt', 'foo\r\n']]) const matches = grep(files, 'foo$', undefined, { outputMode: 'content' }) diff --git a/apps/sim/lib/copilot/vfs/operations.ts b/apps/sim/lib/copilot/vfs/operations.ts index 624707b43e9..c3ee423cd7d 100644 --- a/apps/sim/lib/copilot/vfs/operations.ts +++ b/apps/sim/lib/copilot/vfs/operations.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { truncate } from '@sim/utils/string' import micromatch from 'micromatch' import { compileLinearRegex, @@ -9,6 +10,23 @@ import { const logger = createLogger('VfsOperations') +/** + * Maximum characters returned for one matched (or context) line in grep + * `content` mode. Minified single-line files (workflow JSON, persisted tool + * results) make one match the entire file otherwise — a single grep can then + * blow through the inline tool-result budget and the caller's context window. + */ +const GREP_MATCH_MAX_CHARS = 2_000 + +/** + * Truncates one grep match line to {@link GREP_MATCH_MAX_CHARS}, noting the + * original length so the caller knows the line continues. + */ +function capGrepMatchContent(line: string): string { + if (line.length <= GREP_MATCH_MAX_CHARS) return line + return truncate(line, GREP_MATCH_MAX_CHARS, ` … [line truncated: ${line.length} chars total]`) +} + export interface GrepMatch { path: string line: number @@ -221,14 +239,14 @@ export function grep( matches.push({ path: filePath, line: showLineNumbers ? j + 1 : 0, - content: lines[j], + content: capGrepMatchContent(lines[j]), }) } } else { matches.push({ path: filePath, line: showLineNumbers ? i + 1 : 0, - content: lines[i], + content: capGrepMatchContent(lines[i]), }) } if (matches.length >= maxResults) return matches From 9db576c9aac65aff911255c68bb786077453ec29 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 29 Jul 2026 16:11:37 -0700 Subject: [PATCH 4/5] chore(copilot): sync generated mothership contract mirrors (user_table select descriptions from mothership #373) --- apps/sim/lib/copilot/generated/tool-catalog-v1.ts | 4 ++-- apps/sim/lib/copilot/generated/tool-schemas-v1.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 88159f0d49b..bcdadb752fb 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -4735,7 +4735,7 @@ export const UserTable: ToolCatalogEntry = { newType: { type: 'string', description: - "New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn't match one of them.", + 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, offset: { type: 'number', @@ -4744,7 +4744,7 @@ export const UserTable: ToolCatalogEntry = { options: { type: 'array', description: - 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list: options kept by name keep their cells, and cells holding a removed option are cleared. Max 100.', + 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', items: { type: 'string' }, }, outputColumnNames: { diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index e527c57b544..825443e2447 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -4386,7 +4386,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { newType: { type: 'string', description: - "New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn't match one of them.", + 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, offset: { type: 'number', @@ -4395,7 +4395,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { options: { type: 'array', description: - 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list: options kept by name keep their cells, and cells holding a removed option are cleared. Max 100.', + 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', items: { type: 'string', }, From 16c011834f99c140453f46a96741e22d00da10e2 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 29 Jul 2026 16:14:37 -0700 Subject: [PATCH 5/5] fix(desktop): canonicalize the apex origin in setOrigin, not just on load --- apps/desktop/src/main/config.test.ts | 14 ++++++++++++++ apps/desktop/src/main/config.ts | 22 +++++++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/config.test.ts b/apps/desktop/src/main/config.test.ts index ad992210b3d..f42c25cb9e5 100644 --- a/apps/desktop/src/main/config.test.ts +++ b/apps/desktop/src/main/config.test.ts @@ -145,6 +145,20 @@ describe('createConfigStore', () => { expect(reloaded.getOrigin()).toBe('https://self-hosted.example') }) + it('canonicalizes the apex production origin on setOrigin, not just on load', () => { + // Entering https://sim.ai mid-session must not persist the apex: the + // running session would use the wrong cookie partition and misclassify + // social login until the next launch's load-time rewrite repaired it. + const filePath = tempSettingsPath() + const store = createConfigStore(filePath, {}) + const result = store.setOrigin('https://sim.ai') + expect(result).toEqual({ ok: true, origin: 'https://www.sim.ai' }) + expect(store.getOrigin()).toBe('https://www.sim.ai') + + const reloaded = createConfigStore(filePath, {}) + expect(reloaded.getOrigin()).toBe('https://www.sim.ai') + }) + it('recovers from a corrupted settings file', () => { const filePath = tempSettingsPath() writeFileSync(filePath, '{not json') diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 44df3fa6644..009b05c2bf7 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -328,14 +328,22 @@ export function createConfigStore( }, setOrigin(raw: string) { const validated = validateOriginInput(raw) - if (validated.ok) { - settings.origin = validated.origin - // Not debounced: changing the origin tears the session down and - // reloads, so a pending write could be lost on the way out — and this - // is the one setting whose loss strands the app on the wrong server. - writeNow() + if (!validated.ok) { + return validated } - return validated + // Same canonicalization the load path applies (ORIGIN_REWRITES). + // Without it, entering the apex origin mid-session persists it and + // immediately drives the wrong cookie partition and social-login + // classification for the rest of the session — the load-time rewrite + // only repairs it on the next launch. The canonical origin is also + // returned so the caller sees what was actually stored. + const origin = canonicalOrigin(validated.origin) + settings.origin = origin + // Not debounced: changing the origin tears the session down and + // reloads, so a pending write could be lost on the way out — and this + // is the one setting whose loss strands the app on the wrong server. + writeNow() + return { ok: true, origin } }, get(key) { return settings[key]