From a4819eba4964da70e5916122b09c5625abc92d9f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:27:46 -0700 Subject: [PATCH 01/67] feat(desktop): package an NSIS x64 installer for Windows - electron-builder: per-user one-click NSIS target with per-channel .ico, Authenticode for native addons, and platform-scoped node-pty prebuilds; the Computer Use helper moves under mac. - node-pty win32 prebuilds pinned beside the darwin ones; ensure-pty-prebuilds fetches per host platform (ConPTY binaries on Windows) with Windows' own tar. - build-native skips on Windows; install:local and package:share refuse to run off macOS. - Committed icon*.ico generated from the per-channel dock icons by scripts/generate-windows-icons.ts; build.ts selects one per baked origin. --- apps/desktop/.gitignore | 1 + apps/desktop/build/icon-dev.ico | Bin 0 -> 47138 bytes apps/desktop/build/icon-local.ico | Bin 0 -> 46667 bytes apps/desktop/build/icon-staging.ico | Bin 0 -> 46531 bytes apps/desktop/build/icon.ico | Bin 0 -> 42118 bytes apps/desktop/electron-builder.yml | 39 +++++- apps/desktop/package.json | 6 +- apps/desktop/scripts/build-native.ts | 6 + apps/desktop/scripts/build.ts | 7 +- apps/desktop/scripts/channels.ts | 12 ++ apps/desktop/scripts/ensure-pty-prebuilds.ts | 59 ++++++-- .../desktop/scripts/generate-windows-icons.ts | 127 ++++++++++++++++++ apps/desktop/scripts/install-local.ts | 7 + apps/desktop/scripts/package-share.ts | 7 + bun.lock | 2 + 15 files changed, 250 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/build/icon-dev.ico create mode 100644 apps/desktop/build/icon-local.ico create mode 100644 apps/desktop/build/icon-staging.ico create mode 100644 apps/desktop/build/icon.ico create mode 100644 apps/desktop/scripts/generate-windows-icons.ts diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index e7ec8f6297f..07ddd3ecbbf 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -1,5 +1,6 @@ dist/ release/ build/generated-icon.icon +build/generated-icon.ico playwright-report/ test-results/ diff --git a/apps/desktop/build/icon-dev.ico b/apps/desktop/build/icon-dev.ico new file mode 100644 index 0000000000000000000000000000000000000000..ddb93b4348d36f50da62718a55fba6c3075d1e06 GIT binary patch literal 47138 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 Lrgv+;mGA!pX5}K+ literal 0 HcmV?d00001 diff --git a/apps/desktop/build/icon-staging.ico b/apps/desktop/build/icon-staging.ico new file mode 100644 index 0000000000000000000000000000000000000000..ed35ea963d968f712c6af64cadaa9170b38bf70a GIT binary patch literal 46531 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 E0hI{1ZU6uP literal 0 HcmV?d00001 diff --git a/apps/desktop/build/icon.ico b/apps/desktop/build/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e2dfb57cef10dddb3faaf31f6ae39b5ca31869fb GIT binary patch literal 42118 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 u4GYQY;jZ-UZ;45 literal 0 HcmV?d00001 diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index c9219a003db..d44deddbf10 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -16,15 +16,11 @@ files: filter: - SeasonSansUprightsVF.woff2 -extraResources: - - from: dist/native/Sim Computer Use.app - to: Sim Computer Use.app - asar: true # Native modules cannot be dlopen'd from inside an asar. The Help-search addon -# is already universal; `scripts/ensure-pty-prebuilds.ts` guarantees both pty -# prebuilds are present before packaging. +# is already universal; `scripts/ensure-pty-prebuilds.ts` guarantees the host +# platform's pty prebuilds are present before packaging. asarUnpack: - "**/node_modules/@lydell/node-pty-*/prebuilds/**" - "**/dist/native/*.node" @@ -46,6 +42,12 @@ electronFuses: mac: category: public.app-category.developer-tools + # Each platform ships only its own node-pty prebuilds. + files: + - "!**/node_modules/@lydell/node-pty-win32-*/**" + extraResources: + - from: dist/native/Sim Computer Use.app + to: Sim Computer Use.app target: - target: dmg arch: [universal] @@ -96,6 +98,31 @@ dmg: type: link path: /Applications +# One-click per-user NSIS installer, x64 only for now. CI passes the channel's +# slug as -c.extraMetadata.name: one-click installs are keyed by the package +# name (install directory and updater cache), not productName, so without it +# every channel would install over the same directory. +win: + target: + - target: nsis + arch: [x64] + icon: build/generated-icon.ico + # .exe files are always signed; also sign the native addons (node-pty) so + # every binary Sim loads carries the publisher's Authenticode signature. + signExts: [".node"] + files: + - "!**/node_modules/@lydell/node-pty-darwin-*/**" + # ~10 MB of debug symbols nothing loads at runtime. + - "!**/node_modules/@lydell/node-pty-win32-*/prebuilds/**/*.pdb" + +nsis: + oneClick: true + perMachine: false + # One installer per architecture; never a combined multi-arch stub. + buildUniversalInstaller: false + # Uninstalling keeps the user's sessions, settings, and downloads. + deleteAppDataOnUninstall: false + # node-pty ships pure N-API prebuilds, which are ABI-stable across Node and # Electron versions, so there is nothing to rebuild against Electron's ABI. npmRebuild: false diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 92826c3aec4..3fcf3c9c534 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "license": "Apache-2.0", - "description": "Sim desktop app for macOS \u2014 Electron shell around the hosted web app", + "description": "Sim desktop app for macOS and Windows \u2014 Electron shell around the hosted web app", "author": "Sim ", "homepage": "https://sim.ai", "type": "module", @@ -18,6 +18,8 @@ "start": "electron .", "package:dir": "bun run build && electron-builder --mac dir --publish never", "package:mac": "bun run build && electron-builder --mac --publish never", + "package:win": "bun run build && electron-builder --win --x64 --publish never", + "package:win:dir": "bun run build && electron-builder --win dir --x64 --publish never", "package:share": "bun run scripts/package-share.ts", "install:local": "bun run scripts/install-local.ts", "type-check": "tsc --noEmit", @@ -33,6 +35,8 @@ "@lydell/node-pty": "1.2.0-beta.12", "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", + "@lydell/node-pty-win32-x64": "1.2.0-beta.12", "@sim/browser-protocol": "workspace:*", "@sim/desktop-bridge": "workspace:*", "@sim/logger": "workspace:*", diff --git a/apps/desktop/scripts/build-native.ts b/apps/desktop/scripts/build-native.ts index 8980683f31f..b188017e1f5 100644 --- a/apps/desktop/scripts/build-native.ts +++ b/apps/desktop/scripts/build-native.ts @@ -4,6 +4,12 @@ import { dirname, join } from 'node:path' import { createLogger } from '@sim/logger' const logger = createLogger('DesktopNativeBuild') +if (process.platform === 'win32') { + // Both modules back features the Windows build disables (native local-file + // tools and macOS Help search), so there is nothing to compile. + logger.info('Skipping native desktop modules on Windows: their features are macOS/Linux only') + process.exit(0) +} if (process.platform !== 'darwin' && process.platform !== 'linux') { throw new Error('Native desktop modules require macOS or Linux.') } diff --git a/apps/desktop/scripts/build.ts b/apps/desktop/scripts/build.ts index 73dff48279f..e0c6c7d2084 100644 --- a/apps/desktop/scripts/build.ts +++ b/apps/desktop/scripts/build.ts @@ -28,11 +28,12 @@ if (bakedDefaultOrigin) { console.log(`• Baking default server origin: ${bakedDefaultOrigin}`) } -const appIcon = identityForOrigin(bakedDefaultOrigin).icon +const identity = identityForOrigin(bakedDefaultOrigin) const generatedIcon = 'build/generated-icon.icon' rmSync(generatedIcon, { force: true, recursive: true }) -cpSync(appIcon, generatedIcon, { recursive: true }) -console.log(`• Selecting desktop icon: ${appIcon}`) +cpSync(identity.icon, generatedIcon, { recursive: true }) +cpSync(identity.windowsIcon, 'build/generated-icon.ico') +console.log(`• Selecting desktop icon: ${identity.icon} / ${identity.windowsIcon}`) /** Bundle the native helper; packaged builds sign it with the desktop distribution identity. */ function compileComputerUse(): void { diff --git a/apps/desktop/scripts/channels.ts b/apps/desktop/scripts/channels.ts index 1ff32b5ab0e..074ef40578e 100644 --- a/apps/desktop/scripts/channels.ts +++ b/apps/desktop/scripts/channels.ts @@ -25,6 +25,10 @@ export interface ChannelIdentity { origin: string /** Native Icon Composer source copied into the packager's generated path. */ icon: string + /** Multi-size Windows icon copied into the packager's generated path. */ + windowsIcon: string + /** 512px source the Windows icon is generated from (`scripts/generate-windows-icons.ts`). */ + dockIcon: string /** * Artifact filename stem, and the per-channel scratch directory name. * Space-free for the same reason electron-builder.yml's artifactName is: @@ -39,6 +43,8 @@ export const PROD: ChannelIdentity = { appId: 'ai.sim.desktop', origin: PROD_ORIGIN, icon: 'build/icon.icon', + windowsIcon: 'build/icon.ico', + dockIcon: 'static/dock-icon.png', slug: 'sim', } export const STAGING: ChannelIdentity = { @@ -46,6 +52,8 @@ export const STAGING: ChannelIdentity = { appId: 'ai.sim.desktop.staging', origin: STAGING_ORIGIN, icon: 'build/icon-staging.icon', + windowsIcon: 'build/icon-staging.ico', + dockIcon: 'static/dock-icon-staging.png', slug: 'sim-staging', } export const DEV: ChannelIdentity = { @@ -53,6 +61,8 @@ export const DEV: ChannelIdentity = { appId: 'ai.sim.desktop.dev', origin: DEV_ORIGIN, icon: 'build/icon-dev.icon', + windowsIcon: 'build/icon-dev.ico', + dockIcon: 'static/dock-icon-dev.png', slug: 'sim-dev', } export const LOCAL: ChannelIdentity = { @@ -60,6 +70,8 @@ export const LOCAL: ChannelIdentity = { appId: 'ai.sim.desktop.local', origin: LOCAL_ORIGIN, icon: 'build/icon-local.icon', + windowsIcon: 'build/icon-local.ico', + dockIcon: 'static/dock-icon-local.png', slug: 'sim-local', } diff --git a/apps/desktop/scripts/ensure-pty-prebuilds.ts b/apps/desktop/scripts/ensure-pty-prebuilds.ts index a08856a51f2..417245153b9 100644 --- a/apps/desktop/scripts/ensure-pty-prebuilds.ts +++ b/apps/desktop/scripts/ensure-pty-prebuilds.ts @@ -1,6 +1,7 @@ /** - * Fetches the node-pty prebuilt binaries for every architecture the macOS - * universal build ships. + * Fetches the node-pty prebuilt binaries for every architecture the host + * platform's package ships: both halves of the macOS universal build, or the + * x64 Windows build. * * `@lydell/node-pty` selects its native binary at runtime from a per-arch * package (`@lydell/node-pty-darwin-arm64`, `-darwin-x64`), each declaring a @@ -12,6 +13,9 @@ * Both binaries live at distinct paths, so `@electron/universal` never has to * lipo them together — it sees byte-identical trees in both halves and keeps * one. That is why this build needs no `x64ArchFiles` rule. + * + * Windows packages are x64-only for now. The Windows prebuild ships ConPTY + * (`conpty.node` plus its bundled `conpty/` helper) instead of `pty.node`. */ import { execFileSync } from 'node:child_process' import { createHash, timingSafeEqual } from 'node:crypto' @@ -24,7 +28,6 @@ import { rmSync, writeFileSync, } from 'node:fs' -import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { createLogger } from '@sim/logger' @@ -32,7 +35,19 @@ import { getErrorMessage } from '@sim/utils/errors' const logger = createLogger('DesktopPtyPrebuilds') -const REQUIRED_ARCHES = ['darwin-arm64', 'darwin-x64'] as const +interface RequiredPrebuild { + arch: string + /** The binary whose presence proves the package was fully extracted. */ + binary: string +} + +const REQUIRED_PREBUILDS: Partial> = { + darwin: [ + { arch: 'darwin-arm64', binary: 'pty.node' }, + { arch: 'darwin-x64', binary: 'pty.node' }, + ], + win32: [{ arch: 'win32-x64', binary: 'conpty.node' }], +} const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))) const workspaceRoot = dirname(dirname(desktopDir)) @@ -92,16 +107,18 @@ async function fetchPrebuild(arch: string, version: string): Promise { throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`) } - const staging = mkdtempSync(join(tmpdir(), 'sim-pty-prebuild-')) + const target = packageDir(arch) + mkdirSync(dirname(target), { recursive: true }) + // Staged beside the target, not in the OS temp dir: a CI workspace on + // another drive (D:\a on Windows runners) would make the rename fail. + const staging = mkdtempSync(join(dirname(target), '.sim-pty-prebuild-')) try { const tarball = join(staging, 'package.tgz') const bytes = Buffer.from(await response.arrayBuffer()) verifyIntegrity(bytes, expectedIntegrity(arch, version), packageName) writeFileSync(tarball, bytes) - execFileSync('tar', ['-xzf', tarball, '-C', staging], { stdio: 'pipe' }) + execFileSync(tarExecutable(), ['-xzf', tarball, '-C', staging], { stdio: 'pipe' }) - const target = packageDir(arch) - mkdirSync(dirname(target), { recursive: true }) rmSync(target, { recursive: true, force: true }) renameSync(join(staging, 'package'), target) } finally { @@ -109,18 +126,34 @@ async function fetchPrebuild(arch: string, version: string): Promise { } } +/** + * Windows' own bsdtar. Under Git Bash, a bare `tar` resolves to GNU tar, which + * reads the `C:` in an absolute path as a remote host. + */ +function tarExecutable(): string { + if (process.platform !== 'win32') return 'tar' + return join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') +} + async function run(): Promise { + const required = REQUIRED_PREBUILDS[process.platform] + if (!required) { + logger.info('No packaged node-pty prebuilds for this platform', { + platform: process.platform, + }) + return + } const version = await pinnedVersion() - for (const arch of REQUIRED_ARCHES) { - const dir = packageDir(arch) - if (existsSync(join(dir, 'prebuilds', arch, 'pty.node'))) { + for (const { arch, binary } of required) { + const binaryPath = join(packageDir(arch), 'prebuilds', arch, binary) + if (existsSync(binaryPath)) { logger.info('node-pty prebuild present', { arch }) continue } logger.info('Fetching node-pty prebuild', { arch, version }) await fetchPrebuild(arch, version) - if (!existsSync(join(dir, 'prebuilds', arch, 'pty.node'))) { - throw new Error(`Downloaded @lydell/node-pty-${arch} but pty.node is missing`) + if (!existsSync(binaryPath)) { + throw new Error(`Downloaded @lydell/node-pty-${arch} but ${binary} is missing`) } } } diff --git a/apps/desktop/scripts/generate-windows-icons.ts b/apps/desktop/scripts/generate-windows-icons.ts new file mode 100644 index 00000000000..c7704ba3a71 --- /dev/null +++ b/apps/desktop/scripts/generate-windows-icons.ts @@ -0,0 +1,127 @@ +/** + * Regenerates the committed Windows icons (`build/icon*.ico`) from the + * per-channel 512px dock icons in `static/`, which already carry each + * channel's outline color. + * + * Run from `apps/desktop` after a dock icon changes: + * + * bun run scripts/generate-windows-icons.ts + * + * The output is committed rather than generated per build so packaging needs + * no image toolchain on the Windows runner. `sharp` resolves from the + * workspace root, where `apps/sim` hoists it. + * + * Each `.ico` holds 16–64px frames as 32-bit BMPs and the 256px frame as PNG — + * the layout Explorer, the taskbar, resedit, and NSIS all read. The macOS + * squircle's transparent margin is trimmed so the shape fills the Windows + * icon grid instead of rendering visibly smaller than neighboring apps. + */ +import { writeFileSync } from 'node:fs' +import { createLogger } from '@sim/logger' +import sharp from 'sharp' +import { ALL_CHANNELS } from './channels' + +const logger = createLogger('DesktopWindowsIcons') + +const FRAME_SIZES = [16, 24, 32, 48, 64, 256] as const +/** Frames at or above this size are stored as PNG; smaller ones as BMP. */ +const PNG_FRAME_MIN_SIZE = 256 +/** Breathing room around the trimmed squircle, as a fraction of the frame. */ +const FRAME_PADDING = 0.04 + +const ICONDIR_BYTES = 6 +const ICONDIRENTRY_BYTES = 16 +const BITMAPINFOHEADER_BYTES = 40 + +interface IconFrame { + size: number + data: Buffer +} + +async function renderFrame(trimmed: Buffer, size: number): Promise { + const inner = Math.max(1, Math.round(size * (1 - FRAME_PADDING * 2))) + const offset = Math.floor((size - inner) / 2) + const resized = sharp(trimmed) + .resize(inner, inner, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .extend({ + top: offset, + left: offset, + bottom: size - inner - offset, + right: size - inner - offset, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + if (size >= PNG_FRAME_MIN_SIZE) { + return { size, data: await resized.png({ compressionLevel: 9 }).toBuffer() } + } + const rgba = await resized.ensureAlpha().raw().toBuffer() + return { size, data: encodeBmpFrame(rgba, size) } +} + +/** + * A 32bpp bottom-up DIB with the doubled height ICO frames declare, followed by + * an all-zero AND mask (alpha already carries transparency). + */ +function encodeBmpFrame(rgba: Buffer, size: number): Buffer { + const maskRowBytes = Math.ceil(size / 32) * 4 + const pixelBytes = size * size * 4 + const frame = Buffer.alloc(BITMAPINFOHEADER_BYTES + pixelBytes + maskRowBytes * size) + frame.writeUInt32LE(BITMAPINFOHEADER_BYTES, 0) + frame.writeInt32LE(size, 4) + frame.writeInt32LE(size * 2, 8) + frame.writeUInt16LE(1, 12) + frame.writeUInt16LE(32, 14) + frame.writeUInt32LE(0, 16) + frame.writeUInt32LE(pixelBytes, 20) + for (let y = 0; y < size; y++) { + const sourceRow = (size - 1 - y) * size * 4 + const targetRow = BITMAPINFOHEADER_BYTES + y * size * 4 + for (let x = 0; x < size; x++) { + const source = sourceRow + x * 4 + const target = targetRow + x * 4 + frame[target] = rgba[source + 2] + frame[target + 1] = rgba[source + 1] + frame[target + 2] = rgba[source] + frame[target + 3] = rgba[source + 3] + } + } + return frame +} + +function encodeIco(frames: readonly IconFrame[]): Buffer { + const header = Buffer.alloc(ICONDIR_BYTES + ICONDIRENTRY_BYTES * frames.length) + header.writeUInt16LE(0, 0) + header.writeUInt16LE(1, 2) + header.writeUInt16LE(frames.length, 4) + let offset = header.length + frames.forEach((frame, index) => { + const entry = ICONDIR_BYTES + ICONDIRENTRY_BYTES * index + // A 256px dimension is encoded as 0. + header.writeUInt8(frame.size >= 256 ? 0 : frame.size, entry) + header.writeUInt8(frame.size >= 256 ? 0 : frame.size, entry + 1) + header.writeUInt8(0, entry + 2) + header.writeUInt8(0, entry + 3) + header.writeUInt16LE(1, entry + 4) + header.writeUInt16LE(32, entry + 6) + header.writeUInt32LE(frame.data.length, entry + 8) + header.writeUInt32LE(offset, entry + 12) + offset += frame.data.length + }) + return Buffer.concat([header, ...frames.map((frame) => frame.data)]) +} + +async function run(): Promise { + for (const channel of ALL_CHANNELS) { + const trimmed = await sharp(channel.dockIcon).trim().png().toBuffer() + const frames: IconFrame[] = [] + for (const size of FRAME_SIZES) { + frames.push(await renderFrame(trimmed, size)) + } + writeFileSync(channel.windowsIcon, encodeIco(frames)) + logger.info('Wrote Windows icon', { channel: channel.slug, path: channel.windowsIcon }) + } +} + +run().catch((error) => { + logger.error('Could not generate Windows icons', { error }) + process.exit(1) +}) diff --git a/apps/desktop/scripts/install-local.ts b/apps/desktop/scripts/install-local.ts index b320c708cbb..3620ec8ce4e 100644 --- a/apps/desktop/scripts/install-local.ts +++ b/apps/desktop/scripts/install-local.ts @@ -23,6 +23,13 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node import { homedir } from 'node:os' import { dirname, join } from 'node:path' +if (process.platform !== 'darwin') { + console.error( + '✖ install:local installs into /Applications and only runs on macOS. On Windows, run `bun run package:win` and launch release/Sim--x64.exe.' + ) + process.exit(1) +} + interface ChannelIdentity { /** Display + bundle name; also the userData directory name. */ name: string diff --git a/apps/desktop/scripts/package-share.ts b/apps/desktop/scripts/package-share.ts index 3700c866171..21355dcba4e 100644 --- a/apps/desktop/scripts/package-share.ts +++ b/apps/desktop/scripts/package-share.ts @@ -37,6 +37,13 @@ import { spawnSync } from 'node:child_process' import { rmSync } from 'node:fs' import { ALL_CHANNELS, type ChannelIdentity, identityForOrigin } from './channels' +if (process.platform !== 'darwin') { + console.error( + '✖ package:share builds macOS dmg/zip artifacts and only runs on macOS. On Windows, use `bun run package:win`.' + ) + process.exit(1) +} + const FLAG_TO_CHANNEL: Record = Object.fromEntries( ALL_CHANNELS.map((channel) => [ `--${channel.slug.replace(/^sim-/, '').replace(/^sim$/, 'prod')}`, diff --git a/bun.lock b/bun.lock index 2650e445146..5bffb10fcfb 100644 --- a/bun.lock +++ b/bun.lock @@ -50,6 +50,8 @@ "@lydell/node-pty": "1.2.0-beta.12", "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", + "@lydell/node-pty-win32-x64": "1.2.0-beta.12", "@sim/auth-ui": "workspace:*", "@sim/browser-protocol": "workspace:*", "@sim/desktop-bridge": "workspace:*", From 1babb21a219ee62464b28b0e7ba7e98de5b3c78a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:28:08 -0700 Subject: [PATCH 02/67] feat(desktop): self-update Windows shells through electron-updater's NSIS updater A per-platform descriptor picks the manifest (latest-mac.yml / latest.yml), the exact release artifacts a feed may offer, and whether a native stage follows the download. Windows has none: electron-updater's update-downloaded fires after the installer's publisher signature is verified, so it drives ready directly, and the confirmed restart installs silently and relaunches. Self-update on Windows requires the packaged app-update.yml; otherwise the manual-download pipeline offers the installer. --- apps/desktop/src/main/updater.test.ts | 165 +++++++++++++++++++++- apps/desktop/src/main/updater.ts | 195 ++++++++++++++++++-------- 2 files changed, 303 insertions(+), 57 deletions(-) diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 5ed6241ccfb..5d73929956b 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -16,6 +16,7 @@ const autoUpdaterMock = { autoDownload: true, autoInstallOnAppQuit: false, autoRunAppAfterInstall: true, + disableWebInstaller: false, logger: null as unknown, on: vi.fn(), setFeedURL: vi.fn(), @@ -112,6 +113,7 @@ describe('initUpdater state machine', () => { probeOriginFeed?: (feedUrl: string) => Promise beforeInstall?: () => Promise setRelaunchPending?: (pending: boolean) => void + platform?: NodeJS.Platform }) { const states: DesktopUpdateState[] = [] const handle = initUpdater({ @@ -124,7 +126,7 @@ describe('initUpdater state machine', () => { autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'], probeOriginFeed: options?.probeOriginFeed ?? (async () => options?.feedAvailable ?? false), canSelfUpdate: async () => true, - platform: 'darwin', + platform: options?.platform ?? 'darwin', beforeInstall: options?.beforeInstall, setRelaunchPending: options?.setRelaunchPending, }) @@ -143,6 +145,8 @@ describe('initUpdater state machine', () => { autoUpdaterMock.downloadUpdate.mockClear() autoUpdaterMock.quitAndInstall.mockClear() autoUpdaterMock.autoRunAppAfterInstall = false + autoUpdaterMock.autoInstallOnAppQuit = false + autoUpdaterMock.disableWebInstaller = false updaterChannel = '' vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, checkboxChecked: false }) }) @@ -620,6 +624,115 @@ describe('initUpdater state machine', () => { await vi.advanceTimersByTimeAsync(0) expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) }) + + describe('on Windows (NSIS)', () => { + const windowsAsset = (version: string) => + `https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-x64.exe` + + it('offers a verified download for restart without waiting for a native stage', async () => { + const { handle, states } = await createUpdater({ platform: 'win32' }) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + emit('update-downloaded', { version: '2.0.0' }) + + expect(states.at(-1)).toEqual({ status: 'ready', version: '2.0.0' }) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) + expect(autoUpdaterMock.disableWebInstaller).toBe(true) + expect(events.record).toHaveBeenCalledWith('update_downloaded', { version: '2.0.0' }) + expect(squirrelUpdater.on).not.toHaveBeenCalled() + }) + + it('installs silently and relaunches after the restart is confirmed', async () => { + const { handle } = await createUpdater({ platform: 'win32' }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + emit('update-downloaded', { version: '2.0.0' }) + + vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ + response: 1, + checkboxChecked: false, + }) + handle.install() + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(true, true) + }) + + it('replaces a ready update with a newer verified release', async () => { + const { handle } = await createUpdater({ platform: 'win32' }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + emit('update-downloaded', { version: '2.0.0' }) + + await vi.advanceTimersByTimeAsync(10_000) + emit('update-available', { version: '2.1.0' }) + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.1.0' }) + emit('update-downloaded', { version: '2.1.0' }) + + expect(handle.getState()).toEqual({ status: 'ready', version: '2.1.0' }) + }) + + it('keeps a ready update when a later download fails, but not when the installer fails', async () => { + const { handle } = await createUpdater({ platform: 'win32' }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + emit('update-downloaded', { version: '2.0.0' }) + + await vi.advanceTimersByTimeAsync(10_000) + autoUpdaterMock.downloadUpdate.mockRejectedValueOnce(new Error('download interrupted')) + emit('error', new Error('download interrupted')) + emit('update-available', { version: '2.1.0' }) + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) + + vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ + response: 1, + checkboxChecked: false, + }) + handle.install() + await vi.advanceTimersByTimeAsync(0) + emit('error', new Error('No update filepath provided')) + + expect(handle.getState()).toEqual({ status: 'error', version: '2.0.0' }) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) + }) + + it('accepts the Windows installer from an origin feed', async () => { + const probeOriginFeed = vi.fn(async () => true) + const { handle } = await createUpdater({ platform: 'win32', probeOriginFeed }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { + version: '2.0.0', + files: [{ url: windowsAsset('2.0.0'), sha512: 'checksum' }], + }) + + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.0.0' }) + }) + + it('blocks a macOS artifact offered to a Windows shell', async () => { + const { handle } = await createUpdater({ platform: 'win32', feedAvailable: true }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { + version: '2.0.0', + files: [ + { + url: 'https://github.com/simstudioai/sim/releases/download/v2.0.0/Sim-2.0.0-universal.zip', + sha512: 'checksum', + }, + ], + }) + + expect(handle.getState()).toEqual({ status: 'idle' }) + expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled() + }) + }) }) describe('readUpdateManifest', () => { @@ -782,6 +895,56 @@ describe('initUpdater manual mode (no Developer ID signature)', () => { expect(shell.openExternal).not.toHaveBeenCalled() }) + it('offers the Windows installer from the Windows manifest', async () => { + const windowsManifest = [ + 'version: 9.9.9', + 'files:', + ' - url: https://github.com/simstudioai/sim/releases/download/v9.9.9/Sim-9.9.9-x64.exe', + ' sha512: abc', + "releaseDate: '2026-07-23T00:00:00.000Z'", + ].join('\n') + const fetchManifest = vi.fn(async () => windowsManifest) + const handle = initUpdater({ + getWindow: () => null, + events, + appOrigin: () => 'https://www.dev.sim.ai', + canSelfUpdate: async () => false, + fetchManifest, + platform: 'win32', + }) + await vi.advanceTimersByTimeAsync(0) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(fetchManifest).toHaveBeenCalledWith( + 'https://www.dev.sim.ai/api/desktop/update/latest.yml' + ) + handle.install() + + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/simstudioai/sim/releases/download/v9.9.9/Sim-9.9.9-x64.exe' + ) + }) + + it('never offers a macOS download to a Windows shell', async () => { + const handle = initUpdater({ + getWindow: () => null, + events, + appOrigin: () => 'https://www.dev.sim.ai', + canSelfUpdate: async () => false, + fetchManifest: async () => manifest('9.9.9'), + platform: 'win32', + }) + await vi.advanceTimersByTimeAsync(0) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + + expect(handle.getState()).toMatchObject({ status: 'error', manual: true }) + handle.install() + expect(shell.openExternal).not.toHaveBeenCalled() + }) + it('refuses assets from other repositories on github.com', async () => { const offRepository = manifest('9.9.9', 'simstudioai/not-desktop-releases') const { handle } = await createManualUpdater(async () => offRepository) diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 05e786dd541..b2b55ffaf0e 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -1,5 +1,6 @@ import { execFile } from 'node:child_process' -import { unlinkSync } from 'node:fs' +import { existsSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' import type { DesktopUpdateState } from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' import { toStringOrNull } from '@sim/utils/coerce' @@ -59,7 +60,7 @@ export async function readUpdateManifest(response: Response): Promise = { dev: 'simstudioai/sim-desktop-releases', } -function isReleaseAssetUrl(rawUrl: string, version: string, channel: UpdateChannel): boolean { +/** + * What differs between the platforms that self-update: the manifest + * electron-updater requests, the artifacts a release may point it at, and how a + * downloaded update becomes installable. + */ +interface UpdatePlatform { + /** The manifest electron-updater requests from the feed for this platform. */ + manifest: 'latest-mac.yml' | 'latest.yml' + /** Every artifact a manifest for `version` may reference, most human-installable first. */ + assetNames(version: string): readonly string[] + /** + * macOS hands the downloaded zip to Squirrel.Mac, which stages it and only + * then reports `update-downloaded` on Electron's native updater. NSIS has no + * native step: electron-updater's own `update-downloaded` fires after it has + * verified the installer's Authenticode publisher. + */ + nativeStaging: boolean +} + +const MAC_UPDATE_PLATFORM: UpdatePlatform = { + manifest: 'latest-mac.yml', + assetNames: (version) => [`Sim-${version}-universal.dmg`, `Sim-${version}-universal.zip`], + nativeStaging: true, +} + +const WINDOWS_UPDATE_PLATFORM: UpdatePlatform = { + manifest: 'latest.yml', + assetNames: (version) => [`Sim-${version}-x64.exe`], + nativeStaging: false, +} + +/** The update pipeline for a platform, or null where the shell does not update itself. */ +function updatePlatformFor(platform: NodeJS.Platform): UpdatePlatform | null { + if (platform === 'darwin') return MAC_UPDATE_PLATFORM + if (platform === 'win32') return WINDOWS_UPDATE_PLATFORM + return null +} + +function isReleaseAssetUrl( + rawUrl: string, + version: string, + channel: UpdateChannel, + platform: UpdatePlatform +): boolean { if (!isSafeExternalUrl(rawUrl)) return false try { const url = new URL(rawUrl) @@ -96,10 +140,7 @@ function isReleaseAssetUrl(rawUrl: string, version: string, channel: UpdateChann const repository = RELEASE_REPOSITORIES[channel] const releasePath = `/${repository}/releases/download/v${normalizedVersion}/` const assetName = decodeURIComponent(url.pathname.slice(releasePath.length)) - const expectedAssetNames = new Set([ - `Sim-${normalizedVersion}-universal.dmg`, - `Sim-${normalizedVersion}-universal.zip`, - ]) + const expectedAssetNames = new Set(platform.assetNames(normalizedVersion)) return ( url.origin === RELEASE_ASSET_ORIGIN && url.username === '' && @@ -243,13 +284,13 @@ export interface UpdaterDeps { loadAutoUpdater?: () => typeof import('electron-updater')['autoUpdater'] /** Test seam: overrides the origin feed availability probe. */ probeOriginFeed?: (feedUrl: string) => Promise - /** Test seam: overrides Applications-folder and Developer ID eligibility detection. */ + /** Test seam: overrides the platform's self-update eligibility detection. */ canSelfUpdate?: () => Promise /** Test seam: overrides the manual-mode manifest fetch (body or null). */ fetchManifest?: (url: string) => Promise - /** Test seam for the macOS-only updater gate. */ + /** Test seam for the platform gate: macOS and Windows update, other platforms do not. */ platform?: NodeJS.Platform - /** Flushes desktop-owned state before Squirrel terminates the process. */ + /** Flushes desktop-owned state before the installer terminates the process. */ beforeInstall?: () => Promise /** Bypasses renderer unload guards only after the user confirms a relaunch. */ setRelaunchPending?: (pending: boolean) => void @@ -301,12 +342,15 @@ function isValidUpdateCandidate(candidateVersion: string, currentVersion: string /** * Squirrel.Mac can update only an app installed under /Applications whose - * running bundle carries a Developer ID TeamIdentifier. Other packaged builds - * use the manual-download pipeline. + * running bundle carries a Developer ID TeamIdentifier. On Windows the NSIS + * updater needs the `app-update.yml` the installer build writes into + * resources — it carries the publisher name the downloaded installer's + * signature is checked against. Other packaged builds use the manual-download + * pipeline. */ -async function detectSelfUpdateCapability(): Promise { - if (process.platform !== 'darwin') { - return true +async function detectSelfUpdateCapability(platform: NodeJS.Platform): Promise { + if (platform === 'win32') { + return app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')) } if (!app.isInApplicationsFolder()) { return false @@ -353,7 +397,9 @@ interface UpdateEngine { * as manual downloads. */ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { - if ((deps.platform ?? process.platform) !== 'darwin') { + const platformName = deps.platform ?? process.platform + const platform = updatePlatformFor(platformName) + if (!platform) { return NOOP_UPDATER_HANDLE } if (!app.isPackaged && !deps.loadAutoUpdater && !deps.canSelfUpdate) { @@ -422,8 +468,10 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { let autoDownloadEnabled = deps.autoDownload?.() ?? true // Prevents the library from fetching a candidate before Sim validates its asset URLs. autoUpdater.autoDownload = false - // Explicit Update actions must reopen Sim after Squirrel swaps the bundle. + // Explicit Update actions must reopen Sim after the installer swaps the app. autoUpdater.autoRunAppAfterInstall = true + // Releases ship a full installer; there is no web-installer package to fetch. + if (!platform.nativeStaging) autoUpdater.disableWebInstaller = true // Never install without vetting the downloaded version first. Enabled per // download in the update-downloaded handler, but only for accepted updates // — so a blocked/downgrade build that was already downloaded is never @@ -456,7 +504,12 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { from: currentVersion, version: state.version ?? '', }) - autoUpdater.quitAndInstall() + if (platform.nativeStaging) { + autoUpdater.quitAndInstall() + } else { + // Silent, then relaunch: the one-click installer shows no UI to confirm. + autoUpdater.quitAndInstall(true, true) + } }) .catch((error) => { deps.setRelaunchPending?.(false) @@ -592,7 +645,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const validOriginAssets = !originFeedConfigured || (info.files.length > 0 && - info.files.every((file) => isReleaseAssetUrl(file.url, info.version, channel))) + info.files.every((file) => isReleaseAssetUrl(file.url, info.version, channel, platform))) const validCandidate = validOriginAssets && isValidUpdateCandidate(info.version, currentVersion) if (state.status === 'ready') { @@ -646,9 +699,24 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) }) + /** A verified update is installable: persist the checkpoint and offer the restart. */ + const markReady = (version: string) => { + stagedVersion = version + if (deps.installStatePath) { + try { + writeJsonFileAtomicallySync(deps.installStatePath, { version }) + } catch (error) { + logger.warn('Could not persist update installation checkpoint', { error }) + } + } + deps.events.record('update_downloaded', { version }) + setState({ status: 'ready', version }) + } + autoUpdater.on('update-downloaded', (info) => { if (state.status !== 'downloading') return - // MacUpdater has replaced the native feed before emitting this event. + // MacUpdater has replaced the native feed before emitting this event, and + // NsisUpdater has replaced the cached installer. stagedVersion = null clearInstallState() if ( @@ -662,37 +730,16 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { return } acceptedUpdateVersion = null - pendingStagingVersion = info.version autoUpdater.autoInstallOnAppQuit = true - setState({ status: 'downloading', version: info.version, percent: 100 }) - }) - - squirrelUpdater.on('update-downloaded', () => { - const version = pendingStagingVersion - if (version === null || state.status !== 'downloading') return - pendingStagingVersion = null - stagedVersion = version - if (deps.installStatePath) { - try { - writeJsonFileAtomicallySync(deps.installStatePath, { version }) - } catch (error) { - logger.warn('Could not persist update installation checkpoint', { error }) - } + if (!platform.nativeStaging) { + markReady(info.version) + return } - deps.events.record('update_downloaded', { version }) - setState({ status: 'ready', version }) + pendingStagingVersion = info.version + setState({ status: 'downloading', version: info.version, percent: 100 }) }) - /** Network failures belong to their request promises; native errors invalidate staging. */ - squirrelUpdater.on('error', (error) => { - if ( - state.status !== 'downloading' && - state.status !== 'ready' && - !installInFlight && - !relaunchRequested - ) { - return - } + const failNativeInstall = (error: unknown) => { installInFlight = false relaunchRequested = false stagedVersion = null @@ -704,13 +751,44 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { logger.warn('Native update failed', { message }) deps.events.record('update_error', { message }) setState({ status: 'error', version: state.version }) - }) + } + + if (platform.nativeStaging) { + squirrelUpdater.on('update-downloaded', () => { + const version = pendingStagingVersion + if (version === null || state.status !== 'downloading') return + pendingStagingVersion = null + markReady(version) + }) + + /** Network failures belong to their request promises; native errors invalidate staging. */ + squirrelUpdater.on('error', (error) => { + if ( + state.status !== 'downloading' && + state.status !== 'ready' && + !installInFlight && + !relaunchRequested + ) { + return + } + failNativeInstall(error) + }) + } else { + /** + * Without a native stage, the only installer failure left is launching + * it; download and signature failures reject their request promises. + */ + autoUpdater.on('error', (error) => { + if (!installInFlight && !relaunchRequested) return + failNativeInstall(error) + }) + } /** * Prefer the per-environment feed served by the configured origin; fall * back to the packaged GitHub feed when the origin doesn't serve one (an * older or partial deployment). The origin feed is channel-resolved - * server-side, so the client always requests plain `latest-mac.yml`. + * server-side, so the client always requests the plain platform manifest. * * The fallback is stable-only: prerelease (dev/staging) builds exist * solely on their origin feed, and the GitHub feed could only offer them @@ -721,7 +799,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const probeOriginFeed = deps.probeOriginFeed ?? (async (feedUrl: string) => { - const response = await net.fetch(`${feedUrl}/latest-mac.yml`, { + const response = await net.fetch(`${feedUrl}/${platform.manifest}`, { signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS), }) if (response.ok) return true @@ -885,7 +963,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }, UPDATE_CHECK_TIMEOUT_MS) try { const feedUrl = feedUrlForOrigin(deps.appOrigin()) - const manifest = feedUrl ? await fetchManifest(`${feedUrl}/latest-mac.yml`) : null + const manifest = feedUrl ? await fetchManifest(`${feedUrl}/${platform.manifest}`) : null if (activeCheckId !== checkId) return const version = manifest ? (/^version:\s*(\S+)\s*$/m.exec(manifest)?.[1] ?? null) : null if (!manifest || !version || !isValidUpdateCandidate(version, currentVersion)) { @@ -894,7 +972,8 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { } if (refreshing && !isNewerVersion(version, offeredVersion)) return // The feed rewrites manifest urls to absolute GitHub asset URLs; - // prefer the dmg for a human download. + // prefer the platform's most human-installable artifact (the dmg on + // macOS). // // Filtered before selection, not just before opening: the manifest is // whatever the configured origin served, so `smb://…/x.dmg` or a bare @@ -903,10 +982,14 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const urls = Array.from( manifest.matchAll(/^\s*(?:-\s*)?url:\s*(\S+)\s*$/gm), (m) => m[1] - ).filter((url) => isReleaseAssetUrl(url, version, resolveUpdateChannel(currentVersion))) + ).filter((url) => + isReleaseAssetUrl(url, version, resolveUpdateChannel(currentVersion), platform) + ) const nextDownloadUrl = - urls.find((url) => url.endsWith('.dmg')) ?? - urls.find((url) => url.endsWith('.zip')) ?? + platform + .assetNames(version.replace(/^v/, '')) + .map((name) => urls.find((url) => url.endsWith(`/${encodeURIComponent(name)}`))) + .find((url) => url !== undefined) ?? urls[0] ?? null if (!nextDownloadUrl) { @@ -961,7 +1044,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { let pendingAutoDownload: boolean | null = null let pendingInteractiveCheck = false - const canSelfUpdate = deps.canSelfUpdate ?? detectSelfUpdateCapability + const canSelfUpdate = deps.canSelfUpdate ?? (() => detectSelfUpdateCapability(platformName)) void canSelfUpdate() .catch((error) => { logger.warn('Could not detect self-update capability; using manual updates', { From 3783207d0394e5dd5c250fd0b4864979fcb50216 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:29:38 -0700 Subject: [PATCH 03/67] feat(desktop): advertise the shell platform and gate surfaces per platform The bridge now carries an optional platform (absent shells are macOS). One per-platform feature table drives the preload surfaces, the IPC table, the background runner, and device registration, so Windows does not advertise or accept the terminal, native file tools, computer use, terminal themes, Chrome import, or password copy. The web app feature-detects the terminal, folder grants, and password copy from the bridge, sends the platform with chat capabilities, labels the tray setting for Windows, and keys the title-bar lane on the bridge platform instead of the user agent. --- .../src/main/desktop-executor/runner.test.ts | 32 ++ .../src/main/desktop-executor/runner.ts | 20 +- .../src/main/desktop-executor/service.test.ts | 30 +- .../src/main/desktop-executor/service.ts | 11 +- apps/desktop/src/main/help-search.ts | 7 +- apps/desktop/src/main/ipc.test.ts | 29 ++ apps/desktop/src/main/ipc.ts | 22 ++ apps/desktop/src/preload/index.ts | 51 ++- apps/desktop/src/renderer/offline/index.tsx | 2 +- apps/desktop/src/shared/platform-features.ts | 64 ++++ apps/sim/app/_shell/desktop-title-bar.tsx | 2 +- apps/sim/app/layout.tsx | 3 +- .../password-detail/password-detail.tsx | 36 +- .../settings/components/desktop/desktop.tsx | 8 +- apps/sim/lib/desktop/index.ts | 19 +- apps/sim/lib/mothership/chat/post.ts | 2 + packages/desktop-bridge/contract-snapshot.ts | 355 ++++++++++++++---- packages/desktop-bridge/src/index.ts | 26 +- packages/desktop-bridge/src/title-bar.ts | 17 +- .../testing/src/mocks/lib-desktop.mock.ts | 9 +- 20 files changed, 609 insertions(+), 136 deletions(-) create mode 100644 apps/desktop/src/shared/platform-features.ts diff --git a/apps/desktop/src/main/desktop-executor/runner.test.ts b/apps/desktop/src/main/desktop-executor/runner.test.ts index e3df0c0bcf9..377173e604d 100644 --- a/apps/desktop/src/main/desktop-executor/runner.test.ts +++ b/apps/desktop/src/main/desktop-executor/runner.test.ts @@ -33,6 +33,7 @@ function terminalCall(toolCallId: string, operation: string): ClaimedDesktopCall function runner(overrides: Partial = {}) { return createDesktopToolRunner({ + platform: 'darwin', preferences: () => ({ browserEnabled: true, terminalEnabled: true }), accountDataAvailable: () => true, browser: { @@ -139,6 +140,37 @@ describe('background terminal calls', () => { }) }) +describe('calls for a surface the platform does not ship', () => { + it.each([ + ['terminal', { operation: 'run', args: { command: 'echo hi' } }], + ['read_local_file', { path: '~/notes.txt' }], + ['import_local_files', { path: '~/notes', targetWorkspaceId: 'ws-1' }], + ])('never starts %s on Windows', async (toolName, args) => { + const executeTool = vi.fn() + const request = vi.fn() + const completion = await runner({ + platform: 'win32', + terminal: { executeTool, cancelTool: vi.fn(async () => true) }, + localFiles: { request }, + }).run( + { + toolCallId: 'call-1', + toolName, + args, + chatId: 'chat-b', + workspaceId: 'ws-1', + executionToken: 'token-call-1', + }, + new AbortController().signal + ) + + expect(completion.data).toMatchObject({ notStarted: true }) + expect(completion.message).toContain('on this operating system') + expect(executeTool).not.toHaveBeenCalled() + expect(request).not.toHaveBeenCalled() + }) +}) + describe('local file calls', () => { it('names a passing storage state, not a setting, when local files are out of reach', async () => { const completion = await runner({ accountDataAvailable: () => false }).run( diff --git a/apps/desktop/src/main/desktop-executor/runner.ts b/apps/desktop/src/main/desktop-executor/runner.ts index 8f1f789e365..359a271d947 100644 --- a/apps/desktop/src/main/desktop-executor/runner.ts +++ b/apps/desktop/src/main/desktop-executor/runner.ts @@ -52,6 +52,7 @@ import type { DesktopImportEntryRequest, DesktopImportedEntry, } from '@/main/desktop-executor/protocol' +import { desktopFeatures } from '@/shared/platform-features' const logger = createLogger('DesktopExecutorRunner') @@ -78,7 +79,15 @@ function unsupported(toolName: string): DesktopToolCompletion { return { status: 'error', message, data: { error: message, notStarted: true } } } +/** The tool needs a surface this platform's shell does not ship, so no update helps. */ +function notOnThisPlatform(toolName: string): DesktopToolCompletion { + const message = `Not run: this action never started, because the Sim desktop app cannot run ${toolName} on this operating system. Nothing happened on the user’s computer. Do not retry it; continue without it.` + return { status: 'error', message, data: { error: message, notStarted: true } } +} + export interface DesktopToolRunnerDeps { + /** Selects which {@link desktopFeatures} may run. Defaults to the host. */ + platform?: NodeJS.Platform preferences: () => { browserEnabled: boolean; terminalEnabled: boolean } /** False while account-bearing local storage is unavailable; local tools then refuse. */ accountDataAvailable: () => boolean @@ -158,6 +167,7 @@ async function withDeadline( } export function createDesktopToolRunner(deps: DesktopToolRunnerDeps): DesktopToolRunner { + const features = desktopFeatures(deps.platform ?? process.platform) /** Each chat's last terminal operation, until it actually settles. */ const unsettledTerminalWork = new Map>() @@ -335,7 +345,15 @@ export function createDesktopToolRunner(deps: DesktopToolRunnerDeps): DesktopToo async run(call, signal) { try { if (isCurrentBrowserToolName(call.toolName)) return await runBrowser(call, call.toolName) - if (call.toolName === 'terminal') return await runTerminal(call, signal) + if (call.toolName === 'terminal') { + return features.terminal ? await runTerminal(call, signal) : notOnThisPlatform('terminal') + } + if ( + !features.localFiles && + (call.toolName === 'read_local_file' || call.toolName === 'import_local_files') + ) { + return notOnThisPlatform(call.toolName) + } if (!deps.accountDataAvailable()) return localAccessUnavailable() if (call.toolName === 'read_local_file') { return localFileReadCompletion( diff --git a/apps/desktop/src/main/desktop-executor/service.test.ts b/apps/desktop/src/main/desktop-executor/service.test.ts index 9542c34ff5e..c1bbae124bf 100644 --- a/apps/desktop/src/main/desktop-executor/service.test.ts +++ b/apps/desktop/src/main/desktop-executor/service.test.ts @@ -44,10 +44,13 @@ function fakeSim(protocolVersion = 1) { * `'offline'` for a request that never reached Sim. */ const registrations: Array<(answer: boolean | number | 'offline') => void> = [] + /** The JSON body of each registration request, in order. */ + const registrationBodies: unknown[] = [] const fetch = vi.fn(async (url: string, init: RequestInit): Promise => { const path = new URL(url).pathname requests.push(`${init.method} ${path}`) if (path === '/api/desktop/devices') { + registrationBodies.push(JSON.parse(String(init.body))) const answer = await new Promise((resolve) => registrations.push(resolve) ) @@ -93,10 +96,14 @@ function fakeSim(protocolVersion = 1) { init.signal?.addEventListener('abort', () => reject(new Error('aborted'))) ) }) - return { fetch, requests, registrations, offered } + return { fetch, requests, registrations, registrationBodies, offered } } -async function service(protocolVersion = 1, userDataPath?: string) { +async function service( + protocolVersion = 1, + userDataPath?: string, + platform: NodeJS.Platform = 'darwin' +) { const sim = fakeSim(protocolVersion) /** What the sleep blocker was told, in order. */ const busy: boolean[] = [] @@ -117,6 +124,7 @@ async function service(protocolVersion = 1, userDataPath?: string) { cancel: async () => {}, }, onBusyChange: (value) => busy.push(value), + platform, }) return { sim, desktopExecutor, busy } } @@ -219,6 +227,24 @@ describe('results recovery will hand to the model', () => { }) describe('desktop executor registration', () => { + it.each([ + ['darwin', true], + ['win32', false], + ] as const)( + 'advertises the terminal and native file tools on %s: %s', + async (platform, shipped) => { + const { sim, desktopExecutor } = await service(1, undefined, platform) + desktopExecutor.start() + await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) + + expect(sim.registrationBodies[0]).toMatchObject({ + capabilities: { browser: true, terminal: shipped, localFiles: shipped }, + }) + sim.registrations[0]?.(false) + await desktopExecutor.signOut() + } + ) + it('offers the device for binding once Sim enables it', async () => { const { sim, desktopExecutor } = await service() desktopExecutor.start() diff --git a/apps/desktop/src/main/desktop-executor/service.ts b/apps/desktop/src/main/desktop-executor/service.ts index b51d5afcde2..c172d00e0f6 100644 --- a/apps/desktop/src/main/desktop-executor/service.ts +++ b/apps/desktop/src/main/desktop-executor/service.ts @@ -41,6 +41,7 @@ import { type DesktopImportEntryRequest, type DesktopImportedEntry, } from '@/main/desktop-executor/protocol' +import { desktopFeatures } from '@/shared/platform-features' const logger = createLogger('DesktopExecutorService') @@ -67,6 +68,8 @@ export interface DesktopExecutorServiceDeps { onResultDelivered?: (toolCallId: string) => void /** Sim is done with a call whose real result the model never got. */ onResultNotDelivered?: (toolCallId: string) => void + /** Selects which {@link desktopFeatures} the device advertises. Defaults to the host. */ + platform?: NodeJS.Platform } export interface DesktopExecutorService { @@ -320,6 +323,8 @@ export function createDesktopExecutorService( return } const preferences = deps.preferences() + const platform = deps.platform ?? process.platform + const features = desktopFeatures(platform) const registrationClient = createDesktopExecutorClient({ origin: deps.origin, fetch: fetchWithAppSession, @@ -330,12 +335,12 @@ export function createDesktopExecutorService( deviceId: id, name: deviceName(), appVersion: app.getVersion(), - platform: `${process.platform}-${process.arch}`, + platform: `${platform}-${process.arch}`, capabilities: { executor: DESKTOP_EXECUTOR_PROTOCOL_VERSION, browser: preferences.browserEnabled, - terminal: preferences.terminalEnabled, - localFiles: deps.accountDataAvailable(), + terminal: features.terminal && preferences.terminalEnabled, + localFiles: features.localFiles && deps.accountDataAvailable(), }, }) if (registrationGeneration !== generation || id !== deviceId) return diff --git a/apps/desktop/src/main/help-search.ts b/apps/desktop/src/main/help-search.ts index 8b9eca5810d..a624d3498aa 100644 --- a/apps/desktop/src/main/help-search.ts +++ b/apps/desktop/src/main/help-search.ts @@ -2,6 +2,7 @@ import { join } from 'node:path' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { DOCS_SEARCH_ENDPOINT } from '@/main/external-links' +import { desktopFeatures } from '@/shared/platform-features' const logger = createLogger('DesktopHelpSearch') @@ -12,7 +13,7 @@ interface NativeHelpSearchBridge { interface HelpSearchRuntime { isElectron: boolean - isMacOS: boolean + isSupported: boolean loadBridge: () => unknown } @@ -30,7 +31,7 @@ function loadNativeBridge(): unknown { const nativeRuntime: HelpSearchRuntime = { isElectron: Boolean(process.versions.electron), - isMacOS: process.platform === 'darwin', + isSupported: desktopFeatures(process.platform).helpSearch, loadBridge: loadNativeBridge, } @@ -39,7 +40,7 @@ const nativeRuntime: HelpSearchRuntime = { * The bridge is optional so non-macOS development and unit tests remain portable. */ export function installDocumentationHelpSearch(runtime = nativeRuntime): boolean { - if (!runtime.isMacOS || !runtime.isElectron) return false + if (!runtime.isSupported || !runtime.isElectron) return false try { const bridge = runtime.loadBridge() diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index aeece41f983..93f2609d5fc 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -305,6 +305,7 @@ describe('registerIpcHandlers', () => { deps = { appOrigin: () => APP, getExecutorDevice: () => null, + platform: 'darwin', allowHttpLocalhost: () => false, accountDataAvailable: () => true, isLocalPageUrl, @@ -368,6 +369,34 @@ describe('registerIpcHandlers', () => { for (const root of computerRoots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + it.each([ + 'terminal:execute-tool', + 'terminal:write', + 'terminal-themes:list-profiles', + 'computer-use:execute-tool', + 'browser-import:all', + 'desktop:local-files', + 'browser-credentials:copy', + ])('registers no handler on Windows for %s, which that shell does not ship', (channel) => { + expect(collectHandlers().invoke.has(channel) || collectHandlers().on.has(channel)).toBe(true) + vi.mocked(ipcMain.handle).mockClear() + vi.mocked(ipcMain.on).mockClear() + + registerIpcHandlers({ ...deps, platform: 'win32' }) + + const { invoke, on } = collectHandlers() + expect(invoke.has(channel) || on.has(channel)).toBe(false) + }) + + it('keeps the user-local folder grants and password reveal on Windows', () => { + vi.mocked(ipcMain.handle).mockClear() + registerIpcHandlers({ ...deps, platform: 'win32' }) + + const { invoke } = collectHandlers() + expect(invoke.has('desktop:local-filesystem')).toBe(true) + expect(invoke.has('browser-credentials:reveal')).toBe(true) + }) + it('opens validated external URLs only after recent user input', async () => { const { invoke } = collectHandlers() const handler = invoke.get('desktop:open-external') diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index ebd34d86e1f..c1156384f7a 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -98,6 +98,7 @@ import { isAppOrigin, openExternalSafe } from '@/main/navigation' import type { ScopedEventRouter } from '@/main/scoped-event-router' import type { TerminalRegistry } from '@/main/terminal/registry' import { findCachedTerminalThemeProfile, listTerminalThemeProfiles } from '@/main/terminal-themes' +import { type DesktopFeatures, desktopFeatures } from '@/shared/platform-features' const logger = createLogger('DesktopIpc') @@ -394,6 +395,23 @@ export interface IpcDeps { } /** The device a new chat turn may bind to, or null while Sim has not enabled one. */ getExecutorDevice: () => DesktopExecutorDevice | null + /** Selects the {@link desktopFeatures} whose channels are registered. Defaults to the host. */ + platform?: NodeJS.Platform +} + +/** + * The platform feature a channel belongs to, or null when every platform has it. + * The preload omits these surfaces where the platform lacks them; main refuses + * them too, so a renderer that calls one anyway finds no handler. + */ +function platformFeatureFor(channel: string): keyof DesktopFeatures | null { + if (channel.startsWith('terminal:')) return 'terminal' + if (channel.startsWith('terminal-themes:')) return 'terminalThemes' + if (channel.startsWith('computer-use:')) return 'computerUse' + if (channel.startsWith('browser-import:')) return 'browserImport' + if (channel === 'desktop:local-files') return 'localFiles' + if (channel === 'browser-credentials:copy') return 'passwordCopy' + return null } /** @@ -2136,7 +2154,11 @@ export function registerIpcHandlers(deps: IpcDeps): void { args: readonly unknown[] ): boolean => (typeof requirement === 'function' ? requirement(args) : requirement === true) + const features = desktopFeatures(deps.platform ?? process.platform) + for (const [channel, spec] of Object.entries(channels)) { + const platformFeature = platformFeatureFor(channel) + if (platformFeature && !features[platformFeature]) continue if (spec.kind === 'invoke') { ipcMain.handle(channel, async (event, ...args) => { if ( diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 811b07561f4..25b461612ef 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -35,6 +35,7 @@ import type { DesktopNotificationPayload, DesktopOAuthConnectResult, DesktopOAuthConnectScope, + DesktopPlatform, DesktopPreferenceKey, DesktopPreferences, DesktopServerChangeResult, @@ -45,6 +46,7 @@ import type { LocalFilesystemRequest, LocalFilesystemResponse, SimDesktopApi, + SimDesktopTerminalApi, TerminalShortcutCommand, TerminalThemeProfile, } from '@sim/desktop-bridge' @@ -59,6 +61,7 @@ import { } from '@sim/terminal-protocol' import { contextBridge, ipcRenderer } from 'electron' import { exposeShellTheme, observeAppTheme } from '@/preload/shell-theme' +import { desktopFeatures } from '@/shared/platform-features' exposeShellTheme() observeAppTheme() @@ -115,13 +118,21 @@ function shellVersion(): string { return version || '0.0.0' } +/** Every other platform keeps the Linux surface set, which is what it has always had. */ +function bridgePlatform(): DesktopPlatform { + return process.platform === 'darwin' || process.platform === 'win32' ? process.platform : 'linux' +} + +const features = desktopFeatures(process.platform) + /** * The narrow bridge exposed to pages. Every channel is validated and gated in * the main process — nothing here grants page code any privilege by itself. */ const api: SimDesktopApi = { version: shellVersion(), - ...(process.platform === 'darwin' + platform: bridgePlatform(), + ...(features.computerUse ? { computerUse: { getActivity: () => ipcRenderer.invoke('computer-use:get-activity'), @@ -179,8 +190,13 @@ const api: SimDesktopApi = { }, localFilesystem: (request: LocalFilesystemRequest): Promise => ipcRenderer.invoke('desktop:local-filesystem', request), - localFiles: (request: DesktopLocalFileRequest): Promise => - ipcRenderer.invoke('desktop:local-files', request), + ...(features.localFiles + ? { + localFiles: (request: DesktopLocalFileRequest): Promise => + ipcRenderer.invoke('desktop:local-files', request), + } + : {}), + // Claims cover user-local VFS reads too, which run on every platform. localReadClaims: true, onCommand: (callback: (command: DesktopCommand) => void): (() => void) => { const listener = (_event: unknown, command: DesktopCommand) => callback(command) @@ -416,11 +432,7 @@ const api: SimDesktopApi = { } }, }, - // Omitted entirely off macOS, so the web app's feature detection reflects - // whether an import can actually run rather than only whether the shell is - // new enough. Chrome's App-Bound Encryption on Windows is deliberately not - // worked around. - ...(process.platform === 'darwin' + ...(features.terminalThemes ? { terminalThemes: { listProfiles: (): Promise => @@ -428,6 +440,14 @@ const api: SimDesktopApi = { selectProfile: (profileId: string): Promise => ipcRenderer.invoke('terminal-themes:select-profile', profileId), }, + } + : {}), + // Omitted entirely where no importer runs, so the web app's feature detection + // reflects whether an import can actually run rather than only whether the + // shell is new enough. Chrome's App-Bound Encryption on Windows is + // deliberately not worked around. + ...(features.browserImport + ? { browserImport: { listChromeProfiles: (): Promise => ipcRenderer.invoke('browser-import:list-profiles'), @@ -455,7 +475,12 @@ const api: SimDesktopApi = { ipcRenderer.invoke('browser-credentials:forget-all'), reveal: (id: string): Promise => ipcRenderer.invoke('browser-credentials:reveal', id), - copy: (id: string): Promise => ipcRenderer.invoke('browser-credentials:copy', id), + ...(features.passwordCopy + ? { + copy: (id: string): Promise => + ipcRenderer.invoke('browser-credentials:copy', id), + } + : {}), importFromChrome: ( profileId?: string, policy?: BrowserCredentialConflictPolicy @@ -469,7 +494,11 @@ const api: SimDesktopApi = { ipcRenderer.invoke('browser-credentials:fill-selected', id, scopeId), onFillAvailability: subscribeFillAvailability, }, - terminal: { + ...(features.terminal ? { terminal: terminalApi() } : {}), +} + +function terminalApi(): SimDesktopTerminalApi { + return { restoreScope: (scopeId: string): Promise => ipcRenderer.invoke('terminal:restore-scope', scopeId), // The tool name rides alongside the call because the main process @@ -590,7 +619,7 @@ const api: SimDesktopApi = { ipcRenderer.removeListener('terminal:scope-suspended', listener) } }, - }, + } } contextBridge.exposeInMainWorld('simDesktop', api) diff --git a/apps/desktop/src/renderer/offline/index.tsx b/apps/desktop/src/renderer/offline/index.tsx index 45141ccba49..2ab5f8a6012 100644 --- a/apps/desktop/src/renderer/offline/index.tsx +++ b/apps/desktop/src/renderer/offline/index.tsx @@ -112,7 +112,7 @@ function OfflinePage({ isSimCloud }: OfflinePageProps) { const container = document.getElementById('root') if (!container) throw new Error('Offline page root is missing') { - observeDesktopTitleBar(document.documentElement, navigator.userAgent, bridge) + observeDesktopTitleBar(document.documentElement, bridge) const root = createRoot(container) root.render() void bridge?.server diff --git a/apps/desktop/src/shared/platform-features.ts b/apps/desktop/src/shared/platform-features.ts new file mode 100644 index 00000000000..ac6477fff6b --- /dev/null +++ b/apps/desktop/src/shared/platform-features.ts @@ -0,0 +1,64 @@ +/** + * What the shell ships on each operating system. The preload, the IPC table, + * the background executor, and device registration all read this one table, + * so a surface cannot be advertised on one side and refused on the other. + * + * Computer use additionally needs a recent macOS; that version check stays + * with the service that knows the OS release. + */ +export interface DesktopFeatures { + computerUse: boolean + /** The native macOS Help menu search field. */ + helpSearch: boolean + terminal: boolean + /** Terminal.app and iTerm2 color profiles. */ + terminalThemes: boolean + /** + * `read_local_file` and `import_local_files`. They open files beneath a + * granted directory's identity through the POSIX native module. The + * user-local VFS grants are separate and run on Node fs everywhere. + */ + localFiles: boolean + /** Chrome cookie and password import. */ + browserImport: boolean + /** + * Copying a saved password to the system clipboard. Off on Windows, where + * Clipboard History and the cloud clipboard keep the text after Sim clears it. + */ + passwordCopy: boolean +} + +const MAC_FEATURES: DesktopFeatures = { + computerUse: true, + helpSearch: true, + terminal: true, + terminalThemes: true, + localFiles: true, + browserImport: true, + passwordCopy: true, +} + +const WINDOWS_FEATURES: DesktopFeatures = { + computerUse: false, + helpSearch: false, + terminal: false, + terminalThemes: false, + localFiles: false, + browserImport: false, + passwordCopy: false, +} + +/** Linux is unpackaged; it keeps what the shell has always exposed there. */ +const LINUX_FEATURES: DesktopFeatures = { + ...MAC_FEATURES, + computerUse: false, + helpSearch: false, + terminalThemes: false, + browserImport: false, +} + +/** The desktop surfaces available on `platform`. */ +export function desktopFeatures(platform: NodeJS.Platform): DesktopFeatures { + if (platform === 'darwin') return MAC_FEATURES + return platform === 'win32' ? WINDOWS_FEATURES : LINUX_FEATURES +} diff --git a/apps/sim/app/_shell/desktop-title-bar.tsx b/apps/sim/app/_shell/desktop-title-bar.tsx index 79430d47a7d..1a55b41a9ab 100644 --- a/apps/sim/app/_shell/desktop-title-bar.tsx +++ b/apps/sim/app/_shell/desktop-title-bar.tsx @@ -30,7 +30,7 @@ export function DesktopTitleBarLane() { export function DesktopTitleBarController() { useEffect(() => { const bridge = getDesktopBridge() - return observeDesktopTitleBar(document.documentElement, navigator.userAgent, bridge) + return observeDesktopTitleBar(document.documentElement, bridge) }, []) return null diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 0a0269fe12e..bd36d899023 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -105,9 +105,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) // The macOS desktop shell overlays native traffic lights on the // workspace. Mark it before first paint so the sidebar reserves // its inset title-bar lane without a post-hydration layout shift. + // Shells that predate the platform field are all macOS. var collapsedSidebarWidth = 48; try { - if (window.simDesktop && /Mac/i.test(navigator.userAgent)) { + if (window.simDesktop && (window.simDesktop.platform || 'darwin') === 'darwin') { document.documentElement.setAttribute('data-sim-desktop-title-bar', 'inset'); collapsedSidebarWidth = 0; } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx index 8bcc7e97f5f..1657597b667 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx @@ -96,7 +96,7 @@ export function PasswordDetail({ credential, onBack, onForgotten }: PasswordDeta const copy = useCallback(async () => { const bridge = getDesktopBridge()?.browserCredentials - if (!bridge) return + if (!bridge?.copy) return setBusy(true) try { await bridge.copy(credential.id) @@ -123,6 +123,8 @@ export function PasswordDetail({ credential, onBack, onForgotten }: PasswordDeta }, [credential.id, hide, onBack, onForgotten]) const site = siteLabel(credential.origin) + // Absent where the OS clipboard keeps history Sim cannot clear (Windows). + const canCopy = typeof getDesktopBridge()?.browserCredentials.copy === 'function' return ( <> - - - - - Copy password - + {canCopy && ( + + + + + Copy password + + )} } /> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx index b96eae87fb4..395f4210815 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react' import type { DesktopPreferenceKey, DesktopPreferences } from '@sim/desktop-bridge' import { Label, Switch, toast } from '@sim/emcn' import { useParams, useRouter } from 'next/navigation' -import { getDesktopBridge, getDesktopShellVersion } from '@/lib/desktop' +import { getDesktopBridge, getDesktopPlatform, getDesktopShellVersion } from '@/lib/desktop' import { ComputerUseSettings } from '@/app/workspace/[workspaceId]/settings/components/desktop/computer-use' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -126,7 +126,11 @@ export function Desktop() { )} void updatePreference('trayEnabled', checked)} diff --git a/apps/sim/lib/desktop/index.ts b/apps/sim/lib/desktop/index.ts index 44ce2c97b81..4bb0ef52e5a 100644 --- a/apps/sim/lib/desktop/index.ts +++ b/apps/sim/lib/desktop/index.ts @@ -22,7 +22,7 @@ * capabilities consistently. */ import type { BrowserKnownSession } from '@sim/browser-protocol' -import type { DesktopPreferences, SimDesktopApi } from '@sim/desktop-bridge' +import type { DesktopPlatform, DesktopPreferences, SimDesktopApi } from '@sim/desktop-bridge' import { truncate } from '@sim/utils/string' import { requestJson } from '@/lib/api/client/request' import { @@ -47,7 +47,7 @@ export function isDesktopApp(): boolean { /** True when the shell can serve read-only local-directory grants. */ export function hasLocalFilesystem(): boolean { - return isDesktopApp() + return typeof getDesktopBridge()?.localFilesystem === 'function' } /** True when the shell hosts the embedded agent browser. */ @@ -57,7 +57,7 @@ export function hasBrowserAgent(): boolean { /** True when the shell can run an interactive local shell for the agent. */ export function hasTerminal(): boolean { - return isDesktopApp() + return getDesktopBridge()?.terminal !== undefined } /** True when the shell exposes device-level desktop preferences. */ @@ -139,6 +139,15 @@ export function getDesktopShellVersion(): string | undefined { return getDesktopBridge()?.version } +/** + * The shell's operating system, or undefined in a browser. Shells that predate + * the field are all macOS. + */ +export function getDesktopPlatform(): DesktopPlatform | undefined { + const bridge = getDesktopBridge() + return bridge ? (bridge.platform ?? 'darwin') : undefined +} + /** The shell updater surface, when the installed shell provides one. */ export function getDesktopUpdates(): SimDesktopApi['updates'] | undefined { return getDesktopBridge()?.updates @@ -165,6 +174,7 @@ export interface DesktopChatCapabilities { browser?: true terminal?: true computerUse?: true + platform?: DesktopPlatform browserSessions?: BrowserKnownSession[] terminals?: DesktopTerminalHint[] /** The desktop's background executor, offered to run this turn's desktop tools. */ @@ -207,7 +217,7 @@ export async function getDesktopChatCapabilities( // spending a tool call to ask — and, more importantly, so it notices a // terminal that is occupied instead of launching a second copy into it. const terminals: DesktopTerminalHint[] = - terminal && bridge + terminal && bridge?.terminal ? await bridge.terminal .getTabs(scopeId) .then((state) => @@ -250,6 +260,7 @@ export async function getDesktopChatCapabilities( ...(browser ? { browser: true as const } : {}), ...(terminal ? { terminal: true as const } : {}), ...(computerUse ? { computerUse: true as const } : {}), + ...(bridge ? { platform: bridge.platform ?? 'darwin' } : {}), ...(terminals.length > 0 ? { terminals } : {}), ...(browserSessions.length > 0 ? { browserSessions } : {}), ...(executorDevice diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index bb68ef96984..8831b5233fe 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -163,6 +163,8 @@ const ChatMessageSchema = z browser: z.boolean().optional(), terminal: z.boolean().optional(), computerUse: z.boolean().optional(), + /** Optional: shells that predate it are all macOS. */ + platform: z.enum(['darwin', 'win32', 'linux']).optional(), terminals: z .array( z.object({ diff --git a/packages/desktop-bridge/contract-snapshot.ts b/packages/desktop-bridge/contract-snapshot.ts index c52c10df42b..92492ebb244 100644 --- a/packages/desktop-bridge/contract-snapshot.ts +++ b/packages/desktop-bridge/contract-snapshot.ts @@ -47,6 +47,7 @@ export const CURRENT_BROWSER_TOOL_NAMES = [ 'browser_list_tabs', 'browser_list_sessions', 'browser_list_downloads', + 'browser_save_download', 'browser_wait_for', 'browser_snapshot', 'browser_find', @@ -57,11 +58,13 @@ export const CURRENT_BROWSER_TOOL_NAMES = [ 'browser_click_at', 'browser_type', 'browser_fill_form', + 'browser_batch', 'browser_insert_text', 'browser_press_key', 'browser_scroll', 'browser_select_option', 'browser_set_checked', + 'browser_upload_file', 'browser_hover', 'browser_drag', 'browser_zoom', @@ -69,28 +72,35 @@ export const CURRENT_BROWSER_TOOL_NAMES = [ export type CurrentBrowserToolName = (typeof CURRENT_BROWSER_TOOL_NAMES)[number] -export const RETIRED_BROWSER_TOOL_NAMES = ['browser_request_takeover'] as const +const RETIRED_BROWSER_TOOL_NAMES = ['browser_request_takeover'] as const -export const BROWSER_TOOL_NAMES = [ - ...CURRENT_BROWSER_TOOL_NAMES, - ...RETIRED_BROWSER_TOOL_NAMES, -] as const +const BROWSER_TOOL_NAMES = [...CURRENT_BROWSER_TOOL_NAMES, ...RETIRED_BROWSER_TOOL_NAMES] as const export type BrowserToolName = (typeof BROWSER_TOOL_NAMES)[number] -export const BROWSER_WAIT_FOR_DEFAULT_TIMEOUT_MS = 10_000 -export const BROWSER_WAIT_FOR_MAX_TIMEOUT_MS = 120_000 -export const BROWSER_WAIT_FOR_RENDERER_GRACE_MS = 15_000 +const BROWSER_WAIT_FOR_DEFAULT_TIMEOUT_MS = 10_000 +const BROWSER_WAIT_FOR_MAX_TIMEOUT_MS = 120_000 +const BROWSER_WAIT_FOR_RENDERER_GRACE_MS = 15_000 export const BROWSER_TOOL_AUTHORIZATION_TIMEOUT_MS = 8_000 export const BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS = 60_000 export const BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS = BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS const BROWSER_RENDERER_TRANSPORT_GRACE_MS = 2_000 -export const BROWSER_NAVIGATION_RENDERER_TIMEOUT_MS = +const BROWSER_NAVIGATION_RENDERER_TIMEOUT_MS = BROWSER_TOOL_AUTHORIZATION_TIMEOUT_MS + BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS + BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS + BROWSER_RENDERER_TRANSPORT_GRACE_MS +/** + * The desktop exchanges browser-agent file bytes with the app here: POST streams a file named by + * a claimed `browser_upload_file` call, PUT stores a completed download for `browser_save_download`. + */ +export const BROWSER_FILE_TRANSFER_PATH = '/api/desktop/tool/file' +/** Per-file byte ceiling for browser uploads and saved downloads, enforced on both sides. */ +export const BROWSER_FILE_TRANSFER_MAX_BYTES = 100 * 1024 * 1024 +/** Files one `browser_upload_file` call may attach. */ +export const BROWSER_UPLOAD_MAX_FILES = 10 + /** * Normalizes the model-visible `browser_wait_for.timeoutMs` consistently in * the renderer and desktop main process. @@ -119,6 +129,9 @@ export function browserToolRendererTimeoutMs( case 'browser_reload': case 'browser_open_tab': case 'browser_switch_tab': + case 'browser_upload_file': + case 'browser_save_download': + case 'browser_batch': return BROWSER_NAVIGATION_RENDERER_TIMEOUT_MS case 'browser_wait_for': return ( @@ -131,7 +144,7 @@ export function browserToolRendererTimeoutMs( } } -export const BROWSER_THEMES = ['system', 'light', 'dark'] as const +const BROWSER_THEMES = ['system', 'light', 'dark'] as const /** Sim appearance preference mirrored into browser-tab media queries. */ export type BrowserTheme = (typeof BROWSER_THEMES)[number] @@ -152,6 +165,46 @@ export function isCurrentBrowserToolName(name: string): name is CurrentBrowserTo return CURRENT_BROWSER_TOOL_NAME_SET.has(name) } +/** + * Which browser tools only observe the page (read it, list it, wait on it) and never act on it. + * Repeating one cannot cause a side effect, and one can never collide with what the user is doing. + */ +export const BROWSER_TOOL_OBSERVES_ONLY = { + browser_navigate: false, + browser_open_url: false, + browser_go_back: false, + browser_go_forward: false, + browser_reload: false, + browser_open_tab: false, + browser_switch_tab: false, + browser_close_tab: false, + browser_list_tabs: true, + browser_list_sessions: true, + browser_list_downloads: true, + browser_save_download: false, + browser_wait_for: true, + browser_snapshot: true, + browser_find: true, + browser_read_text: true, + browser_screenshot: true, + browser_extract: true, + browser_click: false, + browser_click_at: false, + browser_type: false, + browser_fill_form: false, + browser_batch: false, + browser_insert_text: false, + browser_press_key: false, + browser_scroll: false, + browser_select_option: false, + browser_set_checked: false, + browser_upload_file: false, + browser_hover: false, + browser_drag: false, + browser_zoom: false, + browser_request_takeover: false, +} as const satisfies Readonly> + export function isBrowserTheme(value: unknown): value is BrowserTheme { return typeof value === 'string' && BROWSER_THEME_SET.has(value) } @@ -254,6 +307,14 @@ export interface BrowserPanelAction { /** Compatibility response for installed shells with the retired navigation gate. */ | 'respond-site-permission' | 'takeover-done' + /** + * Declares that this renderer shows page dialogs. Until it arrives, the + * shell keeps answering them itself, so an older renderer never leaves a + * page blocked on a dialog nobody can see. + */ + | 'enable-page-dialogs' + /** Answers {@link BrowserPageState.dialog}; `allowed` is Leave. */ + | 'respond-dialog' /** Absolute URL for `navigate` (typed into the panel's URL bar). */ url?: string /** Stable tab id for `switch-tab` and `close-tab`. */ @@ -266,12 +327,18 @@ export interface BrowserPanelAction { claim?: boolean /** Optional free-text instruction submitted with `takeover-done`. */ takeoverResponse?: string - /** Exact pending permission request being answered. */ + /** Exact pending permission request or dialog being answered. */ requestId?: string - /** User decision for a permission response. */ + /** User decision for a permission or dialog response. */ allowed?: boolean } +/** A leave-site decision awaiting the user's answer in browser chrome. */ +export interface BrowserPageDialog { + requestId: string + kind: 'beforeunload' +} + export type BrowserMediaDevice = 'microphone' | 'camera' /** One document-scoped media request awaiting an explicit user decision. */ @@ -306,6 +373,8 @@ export interface BrowserPageState { mediaPermissionRequest?: BrowserMediaPermissionRequest /** Legacy request from installed shells that still require a site-origin prompt. */ sitePermissionRequest?: BrowserSitePermissionRequest + /** Page dialog awaiting the user's answer. Optional for older shells. */ + dialog?: BrowserPageDialog } /** A recoverable top-level page problem rendered by Sim instead of a blank native view. */ @@ -454,25 +523,6 @@ import { truncate } from '@sim/utils/string' /** The single tool the model calls; what it does is in `operation`. */ export const TERMINAL_TOOL_NAME = 'terminal' -/** - * Names this surface used to expose, one tool per operation. Kept so rows in - * conversations recorded before the consolidation still render with a real - * title instead of a humanized tool name. - */ -export const LEGACY_TERMINAL_TOOL_NAMES = [ - 'terminal_run', - 'terminal_input', - 'terminal_read', - 'terminal_kill', - 'terminal_cwd', - 'terminal_list', - 'terminal_new', - 'terminal_switch', - 'terminal_close', -] as const - -export type LegacyTerminalToolName = (typeof LEGACY_TERMINAL_TOOL_NAMES)[number] - /** * What one `terminal` call does. * @@ -482,7 +532,7 @@ export type LegacyTerminalToolName = (typeof LEGACY_TERMINAL_TOOL_NAMES)[number] * inspects them rather than Sim mirroring them into the tab strip. `handoff` * gives the terminal to the user and waits. */ -export const TERMINAL_OPERATIONS = [ +const TERMINAL_OPERATIONS = [ 'run', 'read', 'input', @@ -535,9 +585,17 @@ export const MAX_CAPTURE_CHARS = 512_000 * `terminal_read`. Successive reads are also how it tells progress from a * stall — output that stops changing is a command waiting on input or wedged. */ -export const DEFAULT_RUN_WAIT_MS = 30_000 +const DEFAULT_RUN_WAIT_MS = 30_000 -export const MAX_RUN_WAIT_MS = 120_000 +const MAX_RUN_WAIT_MS = 120_000 + +/** How long one `terminal_run` holds the turn, from the `waitSeconds` the model asked for. */ +export function resolveRunWaitMs(waitSeconds: unknown): number { + const requested = Number(waitSeconds) + return Number.isFinite(requested) && requested > 0 + ? Math.min(requested * 1000, MAX_RUN_WAIT_MS) + : DEFAULT_RUN_WAIT_MS +} /** * How long output must be silent, with the cursor left mid-line, before the @@ -558,7 +616,7 @@ export const PROMPT_IDLE_MS = 2_500 export const MAX_INPUT_KEYS = 20 /** Control keys the agent may send to a running foreground process. */ -export const TERMINAL_CONTROL_KEYS = [ +const TERMINAL_CONTROL_KEYS = [ 'ctrl-c', 'ctrl-d', 'ctrl-z', @@ -579,7 +637,7 @@ export function isTerminalControlKey(value: unknown): value is TerminalControlKe return typeof value === 'string' && TERMINAL_CONTROL_KEY_SET.has(value) } -export type TerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' +type TerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' /** * Arguments for every operation, flattened into one object. @@ -618,8 +676,8 @@ export interface TerminalToolArgs { */ terminalId?: string /** - * Which tmux pane to act on, as a tmux target (`session:window.pane`), for - * a terminal that has tmux attached. Omitting it uses that session's active + * Which tmux pane to act on, as a tmux target (`session:window.pane`, or a + * run's pane id `%N`), for a terminal that has tmux attached. Omitting it uses that session's active * pane. Ignored when the terminal is a plain shell. */ pane?: string @@ -627,17 +685,12 @@ export interface TerminalToolArgs { reason?: string } -export interface TerminalToolCall { - operation: TerminalOperation - args?: TerminalToolArgs -} - /** * How a `terminal_run` ended. Only `completed` means the command is finished * and the terminal is free; in every other case it is still running and still * holds the foreground. */ -export type TerminalRunStatus = +type TerminalRunStatus = /** Exited on its own. `exitCode` is set. */ | 'completed' /** @@ -660,7 +713,10 @@ export interface TerminalRunResult { durationMs: number cwd: string | null terminalId: string - /** Set when the command ran in tmux: the target it ran under. */ + /** + * Set when the command ran in tmux: its own pane's id (`%N`), a tmux target that `read`, + * `input`, `kill` and `close` accept as `pane`. + */ pane?: string /** True when output was elided to fit {@link MAX_TOOL_OUTPUT_CHARS}. */ truncated: boolean @@ -900,18 +956,14 @@ export type TerminalErrorCode = /** No pane with that target — the targets come from the `panes` operation. */ | 'NO_SUCH_PANE' | 'INVALID_REQUEST' + /** The call was stopped before its command started; nothing ran. */ + | 'CANCELLED' export interface TerminalStartOptions { cols: number rows: number } -/** One batch of PTY bytes, tagged with the terminal that produced it. */ -export interface TerminalOutputEvent { - terminalId: string - data: string -} - /** * Command lifecycle, used by the panel to attribute rows to the agent and to * show a running indicator. Emitted for user-typed commands too (no @@ -932,7 +984,80 @@ export interface ScopedTerminalCommandEvent extends TerminalCommandEvent { scopeId: string } -export const PENDING_DESKTOP_SCOPE_PREFIX = 'pending:' as const +import type { + ComputerUseInput, + ComputerUseNativeError, + ComputerUseResult, +} from './computer-use.generated' +import type { DesktopLocalFileRequest, DesktopLocalFileResponse } from './local-files' + +export type { + DesktopLocalFileEntry, + DesktopLocalFileManifest, + DesktopLocalFileRead, + DesktopLocalFileRequest, + DesktopLocalFileResponse, +} from './local-files' + +const PENDING_DESKTOP_SCOPE_PREFIX = 'pending:' as const + +/** Renderer budget for native authorization, app approval, queueing, and execution. */ +export const COMPUTER_USE_TOOL_TIMEOUT_MS = 90_000 + +/** Native work is bound to the server-authorized chat and tool call. */ +export interface ComputerUseActivity { + toolCallId: string + scopeId: string + bundleId?: string + appName?: string + action: string + startedAt: number + stopShortcutAvailable?: boolean +} + +/** Device permissions are independent of the server rollout flag. */ +export interface ComputerUseStatus { + supported: boolean + enabled: boolean + permissions: { accessibility: boolean; screenCapture: boolean } + activeAction: ComputerUseActivity | null +} + +export interface ComputerUseAppPermission { + bundleId: string + displayName: string +} + +/** Preserve native dispatch certainty as data across Electron's error serialization boundary. */ +export interface ComputerUseToolFailure { + kind: 'error' + error: ComputerUseNativeError +} + +/** Only validated helper replies may carry a confirmed pre-dispatch failure. */ +export class ComputerUseError extends Error { + constructor(readonly details: ComputerUseNativeError) { + super(details.message) + this.name = 'ComputerUseError' + } +} + +/** Optional so a new web deployment remains compatible with older desktop shells. */ +interface SimDesktopComputerUseApi { + /** Current activity without starting the helper; absent in older shells. */ + getActivity?(): Promise + getStatus(): Promise + setEnabled(enabled: boolean): Promise + requestPermission(permission: 'accessibility' | 'screenCapture'): Promise + listAppPermissions(): Promise + revokeApp(bundleId: string): Promise + executeTool( + toolCallId: string, + params: ComputerUseInput + ): Promise + cancel(toolCallId?: string): Promise + onActivity(callback: (activity: ComputerUseActivity | null) => void): () => void +} /** Boolean results preserve compatibility with older installed desktop shells. */ export type TerminalPasteResult = boolean | 'too-large' @@ -1204,7 +1329,7 @@ export interface SimDesktopBrowserAgentApi { onScopeSuspended(callback: (scopeId: string) => void): () => void } -export type BrowserDownloadState = 'progressing' | 'completed' | 'interrupted' | 'cancelled' +type BrowserDownloadState = 'progressing' | 'completed' | 'interrupted' | 'cancelled' /** Safe renderer metadata for a native browser download; host paths never cross the bridge. */ export interface BrowserDownloadInfo { @@ -1223,7 +1348,7 @@ export interface BrowserDownloadsState { } /** Renderer navigation requested by the native browser toolbar menu. */ -export type BrowserToolbarCommand = 'browser-settings' | 'import' +export type BrowserToolbarCommand = 'browser-settings' | 'import' | 'passwords' /** Selected text and live page identity handed from the native browser to Sim. */ export interface BrowserAddToChatPayload { @@ -1308,7 +1433,7 @@ export interface BrowserSiteInfo { importedAt?: string } -export interface SimDesktopBrowserImportApi { +interface SimDesktopBrowserImportApi { /** Chrome profiles detected on this device; empty when none are readable. */ listChromeProfiles(): Promise /** @@ -1406,7 +1531,7 @@ export interface BrowserFillAvailability { * management calls require the Sim app origin, filling additionally requires * a real user gesture, and no browser tool maps to these channels. */ -export interface SimDesktopBrowserCredentialsApi { +interface SimDesktopBrowserCredentialsApi { /** False when OS-backed encryption is unavailable and passwords are disabled. */ isAvailable(): Promise /** Saved credentials, without passwords. */ @@ -1421,7 +1546,7 @@ export interface SimDesktopBrowserCredentialsApi { * This is the only method on the entire bridge that can produce password * plaintext, and it is heavily conditioned: it requires an active user * gesture, the shell prompts for Touch ID (or a native confirmation where - * Touch ID is unavailable) on every call, and it returns exactly one + * Touch ID is unavailable, including every Windows machine) on every call, and it returns exactly one * password. Resolves null when the user declines or the credential is gone. */ reveal(id: string): Promise @@ -1429,8 +1554,11 @@ export interface SimDesktopBrowserCredentialsApi { * Copy one saved password to the clipboard. Same authorization as * {@link reveal}, but the password never enters the renderer: the shell * writes the clipboard itself and clears it again shortly after. + * + * Absent on Windows, where Clipboard History and the cloud clipboard keep a + * copy that clearing the clipboard does not remove. */ - copy(id: string): Promise + copy?(id: string): Promise /** Copy saved passwords out of a Chrome profile into the vault. */ importFromChrome( profileId?: string, @@ -1474,7 +1602,7 @@ export interface LocalFilesystemEntry { modifiedAt?: string } -export interface LocalFilesystemStat { +interface LocalFilesystemStat { name: string uri: string kind: LocalFilesystemEntryKind @@ -1482,7 +1610,7 @@ export interface LocalFilesystemStat { modifiedAt: string } -export interface LocalFilesystemReadResult { +interface LocalFilesystemReadResult { uri: string content: string startLine: number @@ -1567,6 +1695,10 @@ export type LocalFilesystemResponse = /** Outcome of an OAuth connect handoff, pushed when the browser flow finishes. */ export interface DesktopOAuthConnectResult { ok: boolean + /** Source request correlated by the shell, never taken from the browser callback. */ + sourceRequestId?: string + /** A GitHub setup selection; consumers verify current access before using it. */ + credentialId?: string /** OAuth error slug forwarded from the provider callback, when the flow failed. */ error?: string /** @@ -1680,7 +1812,7 @@ export const TERMINAL_DARK_THEME = { brightWhite: '#f0f6fc', } as const satisfies TerminalThemePalette -export type TerminalThemeSource = 'terminal' | 'iterm2' +type TerminalThemeSource = 'terminal' | 'iterm2' export interface TerminalSelectedProfile { /** Stable source profile id used to restore this selection. */ @@ -1763,12 +1895,14 @@ export function cloneTerminalSelectedProfile( } export interface DesktopPreferences { + /** Allows authorized local file tools to run without per-folder approval on this device. */ + fullFileAccess?: boolean notificationsEnabled: boolean notificationSounds: boolean notificationsOnlyWhenUnfocused: boolean launchAtLogin: boolean autoDownloadUpdates: boolean - /** Show the Sim status item (recent chats menu) in the macOS menu bar. */ + /** Show the Sim status item (recent chats menu) in the macOS menu bar or the Windows system tray. */ trayEnabled: boolean /** Let Chat drive the built-in agent browser on this device. */ browserEnabled: boolean @@ -1776,6 +1910,11 @@ export interface DesktopPreferences { browserSearchSuggestionsEnabled?: boolean /** Let Chat run commands in local shells. */ terminalEnabled: boolean + /** + * Keep the machine awake while a chat runs desktop work in the background. Optional for + * compatibility with installed shells that predate the background executor. + */ + preventSleepWhileRunning?: boolean /** * Appearance used by browser pages on this device. `app` follows Sim's * current preference; explicit values override it. @@ -1823,7 +1962,7 @@ export function resolveDesktopZoom( return Math.min(bounds.max, Math.max(bounds.min, next)) } -export const DESKTOP_APPEARANCE_THEMES = ['app', 'light', 'dark'] as const +const DESKTOP_APPEARANCE_THEMES = ['app', 'light', 'dark'] as const export type DesktopAppearanceTheme = (typeof DESKTOP_APPEARANCE_THEMES)[number] @@ -1849,10 +1988,16 @@ export interface DesktopNotificationPayload { body: string /** Optional in-app route opened when the notification is clicked. */ route?: string + /** + * A chat that finished in the background. In "only when unfocused" mode such a notification is + * held back only while the focused window shows its `route`; every other notification is held + * back whenever the window is focused. + */ + background?: boolean } /** Device-level settings owned by the desktop shell. */ -export interface SimDesktopSettingsApi { +interface SimDesktopSettingsApi { getPreferences(): Promise setPreference( key: K, @@ -1863,6 +2008,15 @@ export interface SimDesktopSettingsApi { * for compatibility with installed shells that predate live suggestions. */ setBrowserSearchSuggestionsEnabled?(enabled: boolean): Promise + /** + * Keeps the machine awake while a chat runs desktop work in the background. Optional for + * compatibility with installed shells that predate the background executor. + */ + setPreventSleepWhileRunning?(enabled: boolean): Promise + /** Optional for installed shells that predate the explicit full-file-access setting. */ + setFullFileAccess?(enabled: boolean): Promise + /** Keeps settings in sync when folder consent changes access in the native shell. */ + onFullFileAccessChanged?(callback: (preferences: DesktopPreferences) => void): () => void notify(payload: DesktopNotificationPayload): Promise /** Overrides the appearance requested by browser pages. */ setBrowserTheme(theme: DesktopAppearanceTheme): Promise @@ -1876,7 +2030,7 @@ export interface SimDesktopSettingsApi { setTerminalDefaultZoom(zoom: DesktopZoomPercent): Promise } -export interface SimDesktopTerminalThemesApi { +interface SimDesktopTerminalThemesApi { listProfiles(): Promise selectProfile(profileId: string): Promise } @@ -1886,13 +2040,7 @@ export interface SimDesktopTerminalThemesApi { * automatic downloads are disabled or the shell requires a manual installer; * self-updating shells with automatic downloads enabled move to `downloading`. */ -export type DesktopUpdateStatus = - | 'idle' - | 'checking' - | 'available' - | 'downloading' - | 'ready' - | 'error' +type DesktopUpdateStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'error' export interface DesktopUpdateState { status: DesktopUpdateStatus @@ -1909,7 +2057,7 @@ export interface DesktopUpdateState { } /** The shell updater surface. */ -export interface SimDesktopUpdatesApi { +interface SimDesktopUpdatesApi { getState(): Promise /** * Advances the pipeline: checks for an update, downloads an available @@ -1962,7 +2110,7 @@ export type DesktopServerChangeResult = * to must stay reachable when that server cannot be reached at all, and must * never be drivable by a page the current server serves. */ -export interface SimDesktopServerApi { +interface SimDesktopServerApi { /** Opens the shell's native server-selection window. */ open(): void getConfiguration(): Promise @@ -1973,9 +2121,29 @@ export interface SimDesktopServerApi { setOrigin(origin: string): Promise } +/** The device the desktop app registered for its background executor. */ +export interface DesktopExecutorDevice { + /** The install id Sim binds a turn to. */ + deviceId: string + /** The executor protocol version Sim accepted at registration. */ + protocolVersion: number +} + +/** The background executor in the desktop main process. */ +interface SimDesktopExecutorApi { + /** Null until the device registers, and whenever Sim reports the executor off. */ + getDevice(): Promise +} + +/** The operating system a desktop shell runs on. */ +export type DesktopPlatform = 'darwin' | 'win32' | 'linux' + export interface SimDesktopApi { + computerUse?: SimDesktopComputerUseApi /** Installed shell version (plain semver, e.g. `0.3.1`). */ version: string + /** The shell's operating system. Absent on shells that predate it, all of which are macOS. */ + platform?: DesktopPlatform openExternal(url: string): Promise /** Opens the operating system's microphone privacy settings when supported. */ openMicrophoneSettings?(): Promise @@ -1985,6 +2153,11 @@ export interface SimDesktopApi { * browser could not be opened. */ beginOAuthConnect(providerId: string, scope?: DesktopOAuthConnectScope): Promise + /** Starts an opaque source request in the browser without moving the desktop page. */ + prepareSourceConnect?(): Promise + beginSourceConnect?(requestId: string): Promise + /** Cancels only the matching pending source handoff. */ + cancelSourceConnect?(requestId: string): Promise /** * Subscribe to connect-handoff completions (the app is refocused just * before this fires). Returns an unsubscribe function. @@ -1997,6 +2170,17 @@ export interface SimDesktopApi { */ server?: SimDesktopServerApi localFilesystem(request: LocalFilesystemRequest): Promise + /** + * Optional so older installed shells do not advertise the new native tools. Also absent on + * platforms whose shell cannot run them (Windows today). + */ + localFiles?(request: DesktopLocalFileRequest): Promise + /** + * Present on shells that claim each local read (`read_local_file`, user-local VFS reads) through + * the server's tool authorization before reading, as they already do for imports. A turn started + * from such a shell persists those calls pending, so a read nobody picks up fails fast. + */ + localReadClaims?: true /** Subscribe to commands initiated by the native application menu. */ onCommand(callback: (command: DesktopCommand) => void): () => void windowState: SimDesktopWindowStateApi @@ -2005,12 +2189,29 @@ export interface SimDesktopApi { browserAgent: SimDesktopBrowserAgentApi /** * Local Chrome import for the built-in browser. Absent on platforms without - * a supported importer. + * a supported importer (everything but macOS today). */ browserImport?: SimDesktopBrowserImportApi /** Saved passwords and user-driven fill for the built-in browser. */ browserCredentials: SimDesktopBrowserCredentialsApi - terminal: SimDesktopTerminalApi - /** Reads and selects Terminal.app or iTerm2 color profiles on macOS. */ + /** Absent on platforms without the agent terminal (Windows today). */ + terminal?: SimDesktopTerminalApi + /** + * Runs desktop tools for chats the user is not viewing. Optional: shells without one leave + * desktop tools to the chat view. + */ + desktopExecutor?: SimDesktopExecutorApi + /** Reads and selects Terminal.app or iTerm2 color profiles. Present only on macOS. */ terminalThemes?: SimDesktopTerminalThemesApi } +export { + DESKTOP_IMPORT_TOKEN_HEADER, + isStorableImportName, + MAX_DESKTOP_IMPORT_FILE_BYTES, +} from './local-files' +export { + applyDesktopTitleBarMode, + DESKTOP_TITLE_BAR_ATTRIBUTE, + type DesktopTitleBarMode, + observeDesktopTitleBar, +} from './title-bar' diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index 248c84ce8c6..2feb254fba0 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -585,7 +585,7 @@ interface SimDesktopBrowserCredentialsApi { * This is the only method on the entire bridge that can produce password * plaintext, and it is heavily conditioned: it requires an active user * gesture, the shell prompts for Touch ID (or a native confirmation where - * Touch ID is unavailable) on every call, and it returns exactly one + * Touch ID is unavailable, including every Windows machine) on every call, and it returns exactly one * password. Resolves null when the user declines or the credential is gone. */ reveal(id: string): Promise @@ -593,8 +593,11 @@ interface SimDesktopBrowserCredentialsApi { * Copy one saved password to the clipboard. Same authorization as * {@link reveal}, but the password never enters the renderer: the shell * writes the clipboard itself and clears it again shortly after. + * + * Absent on Windows, where Clipboard History and the cloud clipboard keep a + * copy that clearing the clipboard does not remove. */ - copy(id: string): Promise + copy?(id: string): Promise /** Copy saved passwords out of a Chrome profile into the vault. */ importFromChrome( profileId?: string, @@ -938,7 +941,7 @@ export interface DesktopPreferences { notificationsOnlyWhenUnfocused: boolean launchAtLogin: boolean autoDownloadUpdates: boolean - /** Show the Sim status item (recent chats menu) in the macOS menu bar. */ + /** Show the Sim status item (recent chats menu) in the macOS menu bar or the Windows system tray. */ trayEnabled: boolean /** Let Chat drive the built-in agent browser on this device. */ browserEnabled: boolean @@ -1171,10 +1174,15 @@ interface SimDesktopExecutorApi { getDevice(): Promise } +/** The operating system a desktop shell runs on. */ +export type DesktopPlatform = 'darwin' | 'win32' | 'linux' + export interface SimDesktopApi { computerUse?: SimDesktopComputerUseApi /** Installed shell version (plain semver, e.g. `0.3.1`). */ version: string + /** The shell's operating system. Absent on shells that predate it, all of which are macOS. */ + platform?: DesktopPlatform openExternal(url: string): Promise /** Opens the operating system's microphone privacy settings when supported. */ openMicrophoneSettings?(): Promise @@ -1201,7 +1209,10 @@ export interface SimDesktopApi { */ server?: SimDesktopServerApi localFilesystem(request: LocalFilesystemRequest): Promise - /** Optional so older installed shells do not advertise the new native tools. */ + /** + * Optional so older installed shells do not advertise the new native tools. Also absent on + * platforms whose shell cannot run them (Windows today). + */ localFiles?(request: DesktopLocalFileRequest): Promise /** * Present on shells that claim each local read (`read_local_file`, user-local VFS reads) through @@ -1217,18 +1228,19 @@ export interface SimDesktopApi { browserAgent: SimDesktopBrowserAgentApi /** * Local Chrome import for the built-in browser. Absent on platforms without - * a supported importer. + * a supported importer (everything but macOS today). */ browserImport?: SimDesktopBrowserImportApi /** Saved passwords and user-driven fill for the built-in browser. */ browserCredentials: SimDesktopBrowserCredentialsApi - terminal: SimDesktopTerminalApi + /** Absent on platforms without the agent terminal (Windows today). */ + terminal?: SimDesktopTerminalApi /** * Runs desktop tools for chats the user is not viewing. Optional: shells without one leave * desktop tools to the chat view. */ desktopExecutor?: SimDesktopExecutorApi - /** Reads and selects Terminal.app or iTerm2 color profiles on macOS. */ + /** Reads and selects Terminal.app or iTerm2 color profiles. Present only on macOS. */ terminalThemes?: SimDesktopTerminalThemesApi } export { diff --git a/packages/desktop-bridge/src/title-bar.ts b/packages/desktop-bridge/src/title-bar.ts index 36ff3f0dd5c..b3cbf0cb3e3 100644 --- a/packages/desktop-bridge/src/title-bar.ts +++ b/packages/desktop-bridge/src/title-bar.ts @@ -1,10 +1,16 @@ -import type { SimDesktopWindowStateApi } from '@sim/desktop-bridge' +import type { DesktopPlatform, SimDesktopWindowStateApi } from '@sim/desktop-bridge' export type DesktopTitleBarMode = 'fullscreen' | 'inset' | null export const DESKTOP_TITLE_BAR_ATTRIBUTE = 'data-sim-desktop-title-bar' -function supportsDesktopTitleBar(userAgent: string, hasDesktopBridge: boolean): boolean { - return hasDesktopBridge && /Mac/i.test(userAgent) +interface TitleBarBridge { + platform?: DesktopPlatform + windowState?: SimDesktopWindowStateApi +} + +/** Only macOS draws traffic lights over the page; Windows keeps its native frame. */ +function supportsDesktopTitleBar(bridge: TitleBarBridge | undefined): boolean { + return bridge !== undefined && (bridge.platform ?? 'darwin') === 'darwin' } export function applyDesktopTitleBarMode( @@ -18,10 +24,9 @@ export function applyDesktopTitleBarMode( /** Shares native title-bar geometry and fullscreen transitions across hosted and offline pages. */ export function observeDesktopTitleBar( root: HTMLElement, - userAgent: string, - bridge?: { windowState?: SimDesktopWindowStateApi } + bridge?: TitleBarBridge ): (() => void) | undefined { - if (!supportsDesktopTitleBar(userAgent, Boolean(bridge))) { + if (!supportsDesktopTitleBar(bridge)) { applyDesktopTitleBarMode(root, null) return } diff --git a/packages/testing/src/mocks/lib-desktop.mock.ts b/packages/testing/src/mocks/lib-desktop.mock.ts index 3fba8d7dfac..c829afec644 100644 --- a/packages/testing/src/mocks/lib-desktop.mock.ts +++ b/packages/testing/src/mocks/lib-desktop.mock.ts @@ -2,6 +2,7 @@ import { vi } from 'vitest' interface MockDesktopBridge { version?: string + platform?: string updates?: unknown } @@ -21,7 +22,8 @@ function isDesktop(): boolean { * `getDesktopBridge() != null` (device switches read as enabled, the real unread default). The * real check is `!== undefined`; `null` is also treated as "no bridge" because several local * stubs returned `null` for the browser case. - * - `getDesktopShellVersion` returns `bridge?.version`, `getDesktopUpdates` returns `bridge?.updates`. + * - `getDesktopShellVersion` returns `bridge?.version`, `getDesktopUpdates` returns `bridge?.updates`, + * `getDesktopPlatform` returns `bridge?.platform`, defaulting a bridge without one to `'darwin'`. * - `mockSubscribeDesktopPreferences` returns a no-op unsubscribe; `mockSetDesktopPreferencesSnapshot` * is a no-op; `mockGetDesktopChatCapabilities` resolves `{}` (the web result). * @@ -50,6 +52,10 @@ export const libDesktopMockFns = { mockGetDesktopUpdates: vi.fn( (): unknown => (mockGetDesktopBridge() as MockDesktopBridge | undefined)?.updates ), + mockGetDesktopPlatform: vi.fn((): string | undefined => { + const bridge = mockGetDesktopBridge() as MockDesktopBridge | null | undefined + return bridge ? (bridge.platform ?? 'darwin') : undefined + }), mockGetDesktopChatCapabilities: vi.fn(async (_scopeId: string): Promise => ({})), } @@ -75,5 +81,6 @@ export const libDesktopMock = { isTerminalEnabled: libDesktopMockFns.mockIsTerminalEnabled, getDesktopShellVersion: libDesktopMockFns.mockGetDesktopShellVersion, getDesktopUpdates: libDesktopMockFns.mockGetDesktopUpdates, + getDesktopPlatform: libDesktopMockFns.mockGetDesktopPlatform, getDesktopChatCapabilities: libDesktopMockFns.mockGetDesktopChatCapabilities, } From b8dfb41aeff2c1c6795f0facc891629ef5e9443f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:29:39 -0700 Subject: [PATCH 04/67] feat(desktop): build a Windows File/Edit/View/Help menu The macOS app-menu shape (hide/hideOthers/unhide, a windowMenu whose Close item takes Ctrl+W off macOS) is wrong on Windows. Build a Windows template with the same items and accelerators, Exit via role quit, and hidden Alt+Left/Alt+Right for history. The macOS template is unchanged. --- apps/desktop/src/main/menu.test.ts | 59 +++++- apps/desktop/src/main/menu.ts | 323 ++++++++++++++++++----------- 2 files changed, 253 insertions(+), 129 deletions(-) diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts index 2eb8862dc32..63a77726ee7 100644 --- a/apps/desktop/src/main/menu.test.ts +++ b/apps/desktop/src/main/menu.test.ts @@ -6,7 +6,7 @@ import type { MenuItemConstructorOptions } from 'electron' import type { ConfigStore } from '@/main/config' import { buildMenuTemplate, type MenuDeps } from '@/main/menu' -function makeDeps(origin = 'https://sim.ai'): MenuDeps { +function makeDeps(platform: NodeJS.Platform = 'darwin', origin = 'https://sim.ai'): MenuDeps { return { config: { filePath: '/tmp/settings.json', @@ -29,20 +29,59 @@ function makeDeps(origin = 'https://sim.ai'): MenuDeps { signOut: vi.fn(), checkForUpdates: vi.fn(), openDiagnostics: vi.fn(), + platform, } } -function submenu( - template: MenuItemConstructorOptions[], - label: string -): MenuItemConstructorOptions[] { - return (template.find((item) => item.label === label || item.role === label.toLowerCase()) - ?.submenu ?? []) as MenuItemConstructorOptions[] +/** Every item at any depth, so hidden resource items count too. */ +function flatten(items: MenuItemConstructorOptions[]): MenuItemConstructorOptions[] { + return items.flatMap((item) => [ + item, + ...(Array.isArray(item.submenu) ? flatten(item.submenu as MenuItemConstructorOptions[]) : []), + ]) +} + +function accelerators(template: MenuItemConstructorOptions[]): Set { + return new Set( + flatten(template).flatMap((item) => + typeof item.accelerator === 'string' ? [item.accelerator] : [] + ) + ) } describe('buildMenuTemplate', () => { - it('never exposes developer tools in the application menu', () => { - const view = submenu(buildMenuTemplate(makeDeps()), 'View') - expect(view.some((item) => item.role === 'toggleDevTools')).toBe(false) + it.each(['darwin', 'win32'] as const)('never exposes developer tools on %s', (platform) => { + const items = flatten(buildMenuTemplate(makeDeps(platform))) + expect(items.some((item) => item.role === 'toggleDevTools')).toBe(false) + }) + + it('keeps every macOS shortcut working on Windows', () => { + const windows = accelerators(buildMenuTemplate(makeDeps('win32'))) + for (const accelerator of accelerators(buildMenuTemplate(makeDeps('darwin')))) { + expect(windows).toContain(accelerator) + } + expect(windows).toContain('Alt+Left') + expect(windows).toContain('Alt+Right') + }) + + it('builds the Windows menu without macOS-only app roles', () => { + // windowMenu is excluded too: off macOS it adds a Close item on Ctrl+W + // that would steal the hidden Close Tab accelerator. + const roles = flatten(buildMenuTemplate(makeDeps('win32'))).map((item) => item.role) + for (const role of ['hide', 'hideOthers', 'unhide', 'services', 'windowMenu', 'appMenu']) { + expect(roles).not.toContain(role) + } + expect(roles).toContain('editMenu') + expect(roles).toContain('quit') + }) + + it('binds each Windows accelerator to exactly one item', () => { + const counts = new Map() + for (const item of flatten(buildMenuTemplate(makeDeps('win32')))) { + if (typeof item.accelerator === 'string') { + counts.set(item.accelerator, (counts.get(item.accelerator) ?? 0) + 1) + } + } + expect([...counts].filter(([, count]) => count > 1)).toEqual([]) }) }) diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index b6bfb58fcf7..017803e29ff 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -36,12 +36,15 @@ export interface MenuDeps { signOut: () => void checkForUpdates: () => void openDiagnostics: () => void + /** Injectable so each platform's template is testable on any host. */ + platform?: NodeJS.Platform } /** - * Builds the role-based macOS menu. Edit roles are load-bearing — without - * them copy/paste/undo silently fail in web inputs. Zoom items are custom so - * the zoom level persists across launches. + * Builds the application menu: the macOS app-menu shape, or on Windows a + * File/Edit/View/Help bar. Edit roles are load-bearing — without them + * copy/paste/undo silently fail in web inputs on macOS. Zoom items are custom + * so the zoom level persists across launches. */ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { /** Utility windows must not redirect resource commands into the hidden main window. */ @@ -182,26 +185,202 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] ] viewSubmenu.push({ role: 'togglefullscreen' }) + const aboutItem: MenuItemConstructorOptions = { + label: `About ${app.name}`, + click: () => { + void showShellDialog({ + title: `About ${app.name}`, + type: 'info', + message: app.name, + detail: `Version ${app.getVersion()}`, + buttons: ['OK'], + }) + }, + } + const settingsItem: MenuItemConstructorOptions = { + label: 'Settings…', + accelerator: 'CmdOrCtrl+,', + click: deps.openSettings, + } + const serverItem: MenuItemConstructorOptions = { + label: 'Server…', + click: deps.openServerSettings, + } + const checkForUpdatesItem: MenuItemConstructorOptions = { + label: 'Check for Updates…', + click: deps.checkForUpdates, + } + const signOutItem: MenuItemConstructorOptions = { label: 'Sign Out', click: deps.signOut } + const folderAccessItem: MenuItemConstructorOptions = { + label: 'Folder Access…', + click: (_item, focusedWindow) => { + const win = focusedWindowOrMain(focusedWindow) + if (win) deps.openFolderAccess(win) + }, + } + const newWindowItem: MenuItemConstructorOptions = { + label: 'New Window', + accelerator: 'CmdOrCtrl+Shift+N', + click: deps.newWindow, + } + const newChatItem: MenuItemConstructorOptions = { + label: 'New Chat', + accelerator: 'CmdOrCtrl+N', + click: deps.newChat, + } + const closeWindowItem: MenuItemConstructorOptions = { + label: 'Close Window', + accelerator: 'CmdOrCtrl+Shift+W', + click: (_item, focusedWindow) => { + focusedWindowOrMain(focusedWindow)?.close() + }, + } + const helpItems: MenuItemConstructorOptions[] = [ + { + label: 'Sim Documentation', + click: () => void openExternalSafe(DOCS_URL, deps.allowHttpLocalhost()), + }, + // Omitted for a self-hosted shell, like the offline page's status + // button — see isSimCloudOrigin. + ...(isSimCloudOrigin(deps.config.getOrigin()) + ? [ + { + label: 'Sim Status', + click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()), + }, + ] + : []), + { type: 'separator' }, + { label: 'Show Diagnostic Logs', click: deps.openDiagnostics }, + ] + + const resourceFileItems: MenuItemConstructorOptions[] = [ + /** + * Resource-scoped shortcuts: these act on whichever Browser/Terminal + * panel is focused, not on the app, so they stay out of the visible + * File menu. The accelerators still fire — macOS registers a hidden + * item's accelerator (`acceleratorWorksWhenHidden` defaults to true), + * and Windows always does. + * The numbered tab items sit flat here rather than under a "Select + * Tab" submenu because children of a hidden submenu do not reliably + * register their accelerators. + */ + { + label: 'New Tab', + accelerator: 'CmdOrCtrl+T', + visible: false, + click: (_item, focusedWindow) => { + const win = focusedMainOrFallback(focusedWindow) + if (win) deps.handleFocusedResourceShortcut(win, 'new-tab') + }, + }, + { + label: 'Reopen Closed Tab', + accelerator: 'CmdOrCtrl+Shift+T', + visible: false, + click: (_item, focusedWindow) => { + const win = focusedMainOrFallback(focusedWindow) + if (win) deps.handleFocusedResourceShortcut(win, 'reopen-closed-tab') + }, + }, + { + label: 'Focus Address Bar', + accelerator: 'CmdOrCtrl+L', + visible: false, + click: resourceShortcut('focus-omnibox'), + }, + { + label: 'Next Tab', + accelerator: 'Ctrl+Tab', + visible: false, + click: resourceShortcut('next-tab'), + }, + { + label: 'Previous Tab', + accelerator: 'Ctrl+Shift+Tab', + visible: false, + click: resourceShortcut('previous-tab'), + }, + ...numberedTabItems, + { + label: 'Close Tab', + accelerator: 'CmdOrCtrl+W', + visible: false, + click: (_item, focusedWindow) => { + const win = focusedWindowOrMain(focusedWindow) + if (!win) return + if (deps.isMainWindow(win) && deps.handleFocusedResourceShortcut(win, 'close-tab')) { + return + } + win.close() + }, + }, + ] + + if ((deps.platform ?? process.platform) === 'win32') { + return [ + { + label: '&File', + submenu: [ + newWindowItem, + newChatItem, + { type: 'separator' }, + folderAccessItem, + settingsItem, + serverItem, + { type: 'separator' }, + signOutItem, + { type: 'separator' }, + closeWindowItem, + ...resourceFileItems, + { type: 'separator' }, + { role: 'quit', label: 'Exit' }, + ], + }, + { role: 'editMenu' }, + { + label: '&View', + submenu: [ + ...viewSubmenu, + // Alt+Left/Right are the Windows browser-history keys; hidden so View keeps one row each. + { + label: 'Back', + accelerator: 'Alt+Left', + visible: false, + click: traverseHistory('back'), + }, + { + label: 'Forward', + accelerator: 'Alt+Right', + visible: false, + click: traverseHistory('forward'), + }, + ], + }, + // No windowMenu role: off macOS it carries a Close item bound to Ctrl+W, + // which would steal the hidden Close Tab accelerator. + { + label: '&Help', + submenu: [ + ...helpItems, + { type: 'separator' }, + checkForUpdatesItem, + { type: 'separator' }, + aboutItem, + ], + }, + ] + } + return [ { label: app.name, submenu: [ - { - label: `About ${app.name}`, - click: () => { - void showShellDialog({ - title: `About ${app.name}`, - type: 'info', - message: app.name, - detail: `Version ${app.getVersion()}`, - buttons: ['OK'], - }) - }, - }, - { label: 'Settings…', accelerator: 'CmdOrCtrl+,', click: deps.openSettings }, - { label: 'Server…', click: deps.openServerSettings }, - { label: 'Check for Updates…', click: deps.checkForUpdates }, - { label: 'Sign Out', click: deps.signOut }, + aboutItem, + settingsItem, + serverItem, + checkForUpdatesItem, + signOutItem, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, @@ -213,113 +392,19 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { label: 'File', submenu: [ - { - label: 'Folder Access…', - click: (_item, focusedWindow) => { - const win = focusedWindowOrMain(focusedWindow) - if (win) deps.openFolderAccess(win) - }, - }, + folderAccessItem, { type: 'separator' }, - { - label: 'New Window', - accelerator: 'CmdOrCtrl+Shift+N', - click: deps.newWindow, - }, - { label: 'New Chat', accelerator: 'CmdOrCtrl+N', click: deps.newChat }, + newWindowItem, + newChatItem, { type: 'separator' }, - { - label: 'Close Window', - accelerator: 'CmdOrCtrl+Shift+W', - click: (_item, focusedWindow) => { - focusedWindowOrMain(focusedWindow)?.close() - }, - }, - /** - * Resource-scoped shortcuts: these act on whichever Browser/Terminal - * panel is focused, not on the app, so they stay out of the visible - * File menu. The accelerators still fire — macOS registers a hidden - * item's accelerator (`acceleratorWorksWhenHidden` defaults to true). - * The numbered tab items sit flat here rather than under a "Select - * Tab" submenu because children of a hidden submenu do not reliably - * register their accelerators. - */ - { - label: 'New Tab', - accelerator: 'CmdOrCtrl+T', - visible: false, - click: (_item, focusedWindow) => { - const win = focusedMainOrFallback(focusedWindow) - if (win) deps.handleFocusedResourceShortcut(win, 'new-tab') - }, - }, - { - label: 'Reopen Closed Tab', - accelerator: 'CmdOrCtrl+Shift+T', - visible: false, - click: (_item, focusedWindow) => { - const win = focusedMainOrFallback(focusedWindow) - if (win) deps.handleFocusedResourceShortcut(win, 'reopen-closed-tab') - }, - }, - { - label: 'Focus Address Bar', - accelerator: 'CmdOrCtrl+L', - visible: false, - click: resourceShortcut('focus-omnibox'), - }, - { - label: 'Next Tab', - accelerator: 'Ctrl+Tab', - visible: false, - click: resourceShortcut('next-tab'), - }, - { - label: 'Previous Tab', - accelerator: 'Ctrl+Shift+Tab', - visible: false, - click: resourceShortcut('previous-tab'), - }, - ...numberedTabItems, - { - label: 'Close Tab', - accelerator: 'CmdOrCtrl+W', - visible: false, - click: (_item, focusedWindow) => { - const win = focusedWindowOrMain(focusedWindow) - if (!win) return - if (deps.isMainWindow(win) && deps.handleFocusedResourceShortcut(win, 'close-tab')) { - return - } - win.close() - }, - }, + closeWindowItem, + ...resourceFileItems, ], }, { role: 'editMenu' }, { label: 'View', submenu: viewSubmenu }, { role: 'windowMenu' }, - { - role: 'help', - submenu: [ - { - label: 'Sim Documentation', - click: () => void openExternalSafe(DOCS_URL, deps.allowHttpLocalhost()), - }, - // Omitted for a self-hosted shell, like the offline page's status - // button — see isSimCloudOrigin. - ...(isSimCloudOrigin(deps.config.getOrigin()) - ? [ - { - label: 'Sim Status', - click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()), - }, - ] - : []), - { type: 'separator' }, - { label: 'Show Diagnostic Logs', click: deps.openDiagnostics }, - ], - }, + { role: 'help', submenu: helpItems }, ] } From 9fd011a4639fcff702c8b1d84aee84494206ecdc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:29:41 -0700 Subject: [PATCH 05/67] feat(desktop): full-colour Windows tray icon that opens Sim on click A template image is invisible on a dark Windows taskbar. Use the per-channel app icon with 1.25x/1.5x/2x representations, open Sim on a left-click (Windows only shows the menu on right-click), and flash the taskbar button when a download finishes in a background window. --- apps/desktop/src/main/config.ts | 24 ++++++++++++ apps/desktop/src/main/downloads.ts | 16 +++++++- apps/desktop/src/main/tray.test.ts | 30 +++++++++++++++ apps/desktop/src/main/tray.ts | 62 +++++++++++++++++++++++++----- 4 files changed, 121 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 3148c992577..853ffe3ea33 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -74,6 +74,30 @@ export const APP_NAME_FOR_CHANNEL: Record = { local: 'Sim Local', } +/** + * Per-channel installer app IDs. On Windows this is also the AppUserModelID: + * the running process must claim the same ID the installer stamped on its + * Start-menu shortcut, or toasts lose their attribution and taskbar pins + * group under a separate icon. + */ +export const APP_ID_FOR_CHANNEL: Record = { + prod: 'ai.sim.desktop', + staging: 'ai.sim.desktop.staging', + dev: 'ai.sim.desktop.dev', + local: 'ai.sim.desktop.local', +} + +/** + * Per-channel app icons under `static/`, for runs with no packaged bundle or + * executable icon to inherit, and for the Windows tray. + */ +export const APP_ICON_FOR_CHANNEL: Record = { + prod: 'dock-icon.png', + staging: 'dock-icon-staging.png', + dev: 'dock-icon-dev.png', + local: 'dock-icon-local.png', +} + export interface WindowBounds { x?: number y?: number diff --git a/apps/desktop/src/main/downloads.ts b/apps/desktop/src/main/downloads.ts index a26582f379b..c69c6cecaf9 100644 --- a/apps/desktop/src/main/downloads.ts +++ b/apps/desktop/src/main/downloads.ts @@ -3,7 +3,7 @@ import { basename, extname, join } from 'node:path' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import type { Session } from 'electron' -import { app } from 'electron' +import { app, BrowserWindow } from 'electron' import { isAgentWebContents } from '@/main/browser-agent/registry' import type { EventRecorder } from '@/main/observability' @@ -131,10 +131,20 @@ export async function uniqueDownloadPath( return null } +/** The Windows counterpart of the Dock bounce: a taskbar flash for a backgrounded window. */ +function flashUntilFocused(win: BrowserWindow | null): void { + if (!win || win.isDestroyed() || win.isFocused()) return + win.once('focus', () => { + if (!win.isDestroyed()) win.flashFrame(false) + }) + win.flashFrame(true) +} + /** * Wires will-download so exports, blob URLs, and presigned-URL downloads all * get a native save dialog with a sensible default name, and completed - * downloads bounce the Dock Downloads stack. + * downloads bounce the Dock Downloads stack (or flash the taskbar button on + * Windows). */ export function attachDownloadHandling(session: Session, events: EventRecorder): void { session.on('will-download', (_event, item, contents) => { @@ -148,6 +158,8 @@ export function attachDownloadHandling(session: Session, events: EventRecorder): logger.info('Download completed', { filename }) if (process.platform === 'darwin') { app.dock?.downloadFinished(item.getSavePath()) + } else if (process.platform === 'win32' && contents) { + flashUntilFocused(BrowserWindow.fromWebContents(contents)) } } else if (state === 'interrupted') { logger.warn('Download interrupted', { filename }) diff --git a/apps/desktop/src/main/tray.test.ts b/apps/desktop/src/main/tray.test.ts index d17830b2df7..1d1fe2b05b7 100644 --- a/apps/desktop/src/main/tray.test.ts +++ b/apps/desktop/src/main/tray.test.ts @@ -13,6 +13,7 @@ function makeDeps(overrides: Partial = {}): TrayDeps { appOrigin: () => 'https://sim.ai', lastRoute: () => '/workspace/ws1/home', openMainWindow: vi.fn(), + platform: 'darwin', ...overrides, } } @@ -140,4 +141,33 @@ describe('installTray', () => { const tray = Tray.instances[0] expect(JSON.stringify(tray.setContextMenu.mock.calls.at(-1)?.[0])).not.toContain('Secret') }) + + it('opens Sim on a left-click of the Windows notification-area icon', () => { + // Windows shows the attached menu only on right-click, so without this a + // left-click on the icon does nothing at all. + const resized = { + addRepresentation: vi.fn(), + toDataURL: vi.fn(() => 'data:image/png;base64,'), + setTemplateImage: vi.fn(), + } + vi.mocked(nativeImage.createFromPath).mockReturnValueOnce({ + isEmpty: () => false, + resize: () => resized, + } as never) + vi.mocked(session.fromPartition).mockReturnValue({ + fetch: vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ chats: [] }) })), + } as never) + const openMainWindow = vi.fn() + + const handle = installTray(makeDeps({ platform: 'win32', openMainWindow })) + try { + const tray = Tray.instances[0] + const onClick = tray.on.mock.calls.find(([event]) => event === 'click')?.[1] + onClick?.() + expect(openMainWindow).toHaveBeenCalledWith() + expect(resized.setTemplateImage).not.toHaveBeenCalled() + } finally { + handle?.destroy() + } + }) }) diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts index 7ae06b73c72..0e45d508442 100644 --- a/apps/desktop/src/main/tray.ts +++ b/apps/desktop/src/main/tray.ts @@ -4,7 +4,12 @@ import { truncate } from '@sim/utils/string' import type { MenuItemConstructorOptions, NativeImage } from 'electron' import { app, Menu, nativeImage, session, Tray } from 'electron' import { newChatRoute } from '@/main/app-routes' -import { APP_NAME_FOR_CHANNEL, channelForOrigin, type DesktopChannel } from '@/main/config' +import { + APP_ICON_FOR_CHANNEL, + APP_NAME_FOR_CHANNEL, + channelForOrigin, + type DesktopChannel, +} from '@/main/config' const logger = createLogger('DesktopTray') @@ -25,6 +30,9 @@ const TRAY_SUBSCRIPT_WIDTH = 5 const TRAY_SUBSCRIPT_HEIGHT = 7 const TRAY_SUBSCRIPT_STROKE_WIDTH = 1.1 const TRAY_SUBSCRIPT_SUPERSAMPLE = 4 +/** Notification-area icon edge (DIP) and the display scales Windows commonly runs at. */ +const WINDOWS_TRAY_ICON_SIZE = 16 +const WINDOWS_TRAY_SCALE_FACTORS = [1.25, 1.5, 2] as const type TrayEnvironmentMarker = 'L' | 'D' | 'S' type GlyphPoint = { x: number; y: number } @@ -332,6 +340,8 @@ export interface TrayDeps { appOrigin: () => string lastRoute: () => string | undefined openMainWindow: (route?: string) => void + /** Injectable for platform-specific tray tests. */ + platform?: NodeJS.Platform } function chatMenuItem(chat: RecentChat, deps: TrayDeps): MenuItemConstructorOptions { @@ -391,27 +401,61 @@ export interface TrayHandle { destroy(): void } -/** - * The macOS status item owns an attached native menu, so macOS handles its - * selected/open state and a second icon click closes it normally. Recent chats - * refresh independently; each result swaps the attached menu for the next - * open without putting network or menu construction on the click path. - */ -export function installTray(deps: TrayDeps): TrayHandle | null { +/** Monochrome template glyph that macOS tints for the menu bar, with the environment subscript. */ +function macTrayIcon(channel: DesktopChannel): NativeImage | null { const iconPath = join(app.getAppPath(), 'static', 'tray', 'simTemplate.png') const sourceIcon = nativeImage.createFromPath(iconPath) if (sourceIcon.isEmpty()) { logger.error('Tray icon missing; skipping tray install', { iconPath }) return null } - const channel = channelForOrigin(deps.appOrigin()) const marker = trayEnvironmentMarker(channel) const icon = marker ? addTrayEnvironmentSubscript(sourceIcon, marker) : sourceIcon icon.setTemplateImage(true) + return icon +} + +/** + * Full-colour app icon for the Windows notification area, which never tints + * template images (a black glyph vanishes on a dark taskbar). The per-channel + * app icon already marks its environment, so no subscript is drawn. Each + * scale gets its own downsample so the icon stays crisp at 125-200% DPI. + */ +function windowsTrayIcon(channel: DesktopChannel): NativeImage | null { + const iconPath = join(app.getAppPath(), 'static', APP_ICON_FOR_CHANNEL[channel]) + const source = nativeImage.createFromPath(iconPath) + if (source.isEmpty()) { + logger.error('Tray icon missing; skipping tray install', { iconPath }) + return null + } + const sized = (scale: number) => { + const edge = Math.round(WINDOWS_TRAY_ICON_SIZE * scale) + return source.resize({ width: edge, height: edge, quality: 'best' }) + } + const icon = sized(1) + for (const scaleFactor of WINDOWS_TRAY_SCALE_FACTORS) { + icon.addRepresentation({ scaleFactor, dataURL: sized(scaleFactor).toDataURL() }) + } + return icon +} + +/** + * The macOS status item owns an attached native menu, so macOS handles its + * selected/open state and a second icon click closes it normally. Recent chats + * refresh independently; each result swaps the attached menu for the next + * open without putting network or menu construction on the click path. + */ +export function installTray(deps: TrayDeps): TrayHandle | null { + const channel = channelForOrigin(deps.appOrigin()) + const isWindows = (deps.platform ?? process.platform) === 'win32' + const icon = isWindows ? windowsTrayIcon(channel) : macTrayIcon(channel) + if (!icon) return null const tray = new Tray(icon) tray.setIgnoreDoubleClickEvents(true) tray.setToolTip(APP_NAME_FOR_CHANNEL[channel]) + // Windows opens the context menu only on right-click; a left-click is "show me Sim". + if (isWindows) tray.on('click', () => deps.openMainWindow()) let refreshPromise: Promise | null = null let cacheGeneration = 0 From 9a4b88a3bf96071f648a3d94d8b64360e44ade4c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:29:42 -0700 Subject: [PATCH 06/67] feat(desktop): Windows single-instance focus, AUMID, and native window chrome A Windows relaunch arrives as second-instance and now surfaces the running window instead of opening another. Claim the installer's AppUserModelID, auto-hide the menu bar on native-frame windows, drop the menu from frameless sheets, keep Sim resident in the tray when the last window closes, and lift handoff-focused windows past Windows' foreground lock. The native caption follows Sim's theme preference when the renderer reports one. --- apps/desktop/src/main/dialogs.ts | 3 + apps/desktop/src/main/handoff.ts | 6 +- apps/desktop/src/main/index.ts | 53 ++++++++++------ apps/desktop/src/main/server-window.ts | 3 + apps/desktop/src/main/shell-window.ts | 26 +++++++- apps/desktop/src/main/window.test.ts | 87 +++++++++++++++++++++++++- apps/desktop/src/main/window.ts | 49 ++++++++++++++- apps/desktop/src/main/windows.test.ts | 13 ++-- apps/desktop/src/main/windows.ts | 11 +++- apps/desktop/src/test/electron-mock.ts | 2 + 10 files changed, 217 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/main/dialogs.ts b/apps/desktop/src/main/dialogs.ts index 1f2a5e60236..8188786d536 100644 --- a/apps/desktop/src/main/dialogs.ts +++ b/apps/desktop/src/main/dialogs.ts @@ -74,6 +74,9 @@ export function showShellDialog( app.isPackaged ), }) + // Windows attaches the app menu to every window; its accelerators would + // otherwise act on (and close) this sheet behind the page's back. + if (process.platform === 'win32') win.removeMenu() const pageUrl = localPageUrl('dialog.html') attachShellTheme(win) let settled = false diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts index caeb3a4b612..b48e12f3a40 100644 --- a/apps/desktop/src/main/handoff.ts +++ b/apps/desktop/src/main/handoff.ts @@ -5,9 +5,9 @@ import { safeCompare } from '@sim/security/compare' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import type { BrowserWindow } from 'electron' -import { app } from 'electron' import { showShellDialog } from '@/main/dialogs' import type { EventRecorder } from '@/main/observability' +import { bringWindowToFront } from '@/main/shell-window' const logger = createLogger('DesktopHandoff') @@ -523,9 +523,7 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow { try { await win.loadURL(`${origin}/workspace`) } catch {} - win.show() - win.focus() - app.focus({ steal: true }) + bringWindowToFront(win) }, } } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 56c4261fe10..b23b2ff881b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -57,6 +57,8 @@ import { attachClientInfo } from '@/main/client-info' import { NativeComputerUseClient } from '@/main/computer-use/native-client' import { ComputerUseService } from '@/main/computer-use/service' import { + APP_ICON_FOR_CHANNEL, + APP_ID_FOR_CHANNEL, APP_NAME_FOR_CHANNEL, channelForOrigin, createConfigStore, @@ -106,13 +108,14 @@ import { resolveStartRoute, } from '@/main/session-lifecycle' import { setShellTheme } from '@/main/shell-theme' +import { bringWindowToFront } from '@/main/shell-window' import { attachTelemetryPolicy } from '@/main/telemetry-policy' import { TerminalRegistry } from '@/main/terminal/registry' import { createRunLedger } from '@/main/terminal/run-ledger' import { installTray, type TrayHandle } from '@/main/tray' import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/main/updater' import { installBrowserUserAgent } from '@/main/user-agent' -import { createMainWindow, setupPermissionHandlers } from '@/main/window' +import { createMainWindow, handleSecondInstance, setupPermissionHandlers } from '@/main/window' import { attachWindowOpenPolicy, isPopupContents } from '@/main/windows' const logger = createLogger('DesktopMain') @@ -128,13 +131,6 @@ function reportHandoffFailure(error: unknown): void { logger.error('Sign-in handoff failed', { error: getErrorMessage(error) }) } -const DOCK_ICON_FOR_CHANNEL = { - prod: 'dock-icon.png', - staging: 'dock-icon-staging.png', - dev: 'dock-icon-dev.png', - local: 'dock-icon-local.png', -} as const - function main(): void { app.enableSandbox() @@ -258,6 +254,11 @@ function main(): void { createRunLedger(join(userDataPath, 'terminal-runs')) ) const preloadPath = join(__dirname, 'preload.cjs') + // Packaged Windows builds inherit the executable's icon; a dev run would show Electron's. + const unpackagedWindowIcon = + process.platform === 'win32' && !app.isPackaged + ? join(__dirname, '..', 'static', APP_ICON_FOR_CHANNEL[channelForOrigin(processOrigin)]) + : undefined const windows = new Set() const loadHealthByWindow = new Map() @@ -303,14 +304,14 @@ function main(): void { /** Restore/show/focus one full Sim window and activate the app. */ function showMainWindow(target?: BrowserWindow | null): void { const win = target ?? getMainWindow() - if (win) { - if (win.isMinimized()) { - win.restore() - } - win.show() - win.focus() + if (!win) { + app.focus({ steal: true }) + return + } + if (win.isMinimized()) { + win.restore() } - app.focus({ steal: true }) + bringWindowToFront(win) } const handoff = createHandoffManager( @@ -506,6 +507,7 @@ function main(): void { const win = createMainWindow({ config, events, + icon: unpackagedWindowIcon, appOrigin, partition: appPartition(origin), preloadPath, @@ -793,13 +795,21 @@ function main(): void { } app.on('second-instance', () => { - void (startupReady ?? app.whenReady()).then(() => createAndLoadAppWindow()) + void (startupReady ?? app.whenReady()).then(() => + handleSecondInstance({ + ensureMainWindow, + createWindow: () => createAndLoadAppWindow(), + showWindow: showMainWindow, + }) + ) }) app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit() - } + if (process.platform === 'darwin') return + // The Windows notification-area icon keeps Sim resident, like the macOS + // Dock: closing the last window hides to the tray; Exit and Quit Sim quit. + if (process.platform === 'win32' && tray) return + app.quit() }) app.on('before-quit', (event) => { @@ -862,7 +872,7 @@ function main(): void { // bundle, so they still need the channel-specific development icon. if (process.platform === 'darwin' && !app.isPackaged) { const channel = channelForOrigin(config.getOrigin()) - app.dock?.setIcon(join(__dirname, '..', 'static', DOCK_ICON_FOR_CHANNEL[channel])) + app.dock?.setIcon(join(__dirname, '..', 'static', APP_ICON_FOR_CHANNEL[channel])) } events.record('app_launch', { version: app.getVersion(), @@ -1142,6 +1152,9 @@ function main(): void { // can run one install per environment side by side — separate settings, // sessions, locks, and update feeds. app.setName(APP_NAME_FOR_CHANNEL[channelForOrigin(DEFAULT_ORIGIN)]) +if (process.platform === 'win32') { + app.setAppUserModelId(APP_ID_FOR_CHANNEL[channelForOrigin(DEFAULT_ORIGIN)]) +} if (process.env.SIM_DESKTOP_USER_DATA) { app.setPath('userData', process.env.SIM_DESKTOP_USER_DATA) } diff --git a/apps/desktop/src/main/server-window.ts b/apps/desktop/src/main/server-window.ts index ef56e590fd0..6c61dc50de3 100644 --- a/apps/desktop/src/main/server-window.ts +++ b/apps/desktop/src/main/server-window.ts @@ -159,6 +159,9 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle { // ways out must therefore work without the page: Escape is handled here, // and a page that fails to load closes the window instead of leaving a // blank sheet nothing can dismiss. + // Windows attaches the app menu to every window; its accelerators would + // otherwise act on (and close) this sheet behind the page's back. + if (process.platform === 'win32') win.removeMenu() attachShellTheme(win) const opened = win let closed = false diff --git a/apps/desktop/src/main/shell-window.ts b/apps/desktop/src/main/shell-window.ts index af74d8811b3..34630ea2892 100644 --- a/apps/desktop/src/main/shell-window.ts +++ b/apps/desktop/src/main/shell-window.ts @@ -1,5 +1,5 @@ import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent } from 'electron' -import { screen } from 'electron' +import { app, screen } from 'electron' /** Only the exact bundled top-level document may operate its own host window. */ export function isShellWindowSender( @@ -35,3 +35,27 @@ export function attachShellWindowSizing( } }) } + +/** + * Raises a window above whatever app currently has the foreground — the user + * just finished something in the browser or relaunched Sim and expects it in + * front. macOS activates the app (`steal` is macOS-only). Windows ignores + * focus requests from a background process and only flashes the taskbar + * button, so a momentary always-on-top lift puts the window in front first. + */ +export function bringWindowToFront( + win: BrowserWindow, + platform: NodeJS.Platform = process.platform +): void { + if (platform === 'win32') { + if (win.isMinimized()) win.restore() + win.show() + win.setAlwaysOnTop(true) + win.focus() + win.setAlwaysOnTop(false) + return + } + win.show() + win.focus() + app.focus({ steal: true }) +} diff --git a/apps/desktop/src/main/window.test.ts b/apps/desktop/src/main/window.test.ts index f8c2ca1bd02..65d8ee4b24f 100644 --- a/apps/desktop/src/main/window.test.ts +++ b/apps/desktop/src/main/window.test.ts @@ -3,11 +3,23 @@ import { createSecureWebPreferences } from '@/main/window-preferences' vi.mock('electron', () => import('@/test/electron-mock')) -import { BrowserWindow, dialog, screen, systemPreferences, WebContentsView } from 'electron' +import { + BrowserWindow, + dialog, + nativeTheme, + screen, + systemPreferences, + WebContentsView, +} from 'electron' import { registerAgentWebContents } from '@/main/browser-agent/registry' import type { ConfigStore } from '@/main/config' import type { EventRecorder } from '@/main/observability' -import { createMainWindow, resolvePermission, setupPermissionHandlers } from '@/main/window' +import { + createMainWindow, + handleSecondInstance, + resolvePermission, + setupPermissionHandlers, +} from '@/main/window' const APP = 'https://sim.ai' @@ -142,7 +154,10 @@ describe('createMainWindow', () => { } as never) }) - function createTestWindow(isCommittedRelaunchPending: () => boolean = () => false) { + function createTestWindow( + isCommittedRelaunchPending: () => boolean = () => false, + platform?: NodeJS.Platform + ) { const config = { filePath: '/tmp/settings.json', getOrigin: vi.fn(() => APP), @@ -163,6 +178,7 @@ describe('createMainWindow', () => { isPackaged: false, onClosed: vi.fn(), isCommittedRelaunchPending, + platform, }) const contentHandlers = new Map( vi.mocked(win.webContents.on).mock.calls as unknown as Array< @@ -194,4 +210,69 @@ describe('createMainWindow', () => { handler?.(event as never) expect(event.preventDefault).toHaveBeenCalledOnce() }) + + describe('native theme on Windows', () => { + afterEach(() => { + nativeTheme.themeSource = 'system' + }) + + function reportTheme(platform: NodeJS.Platform) { + const { win } = createTestWindow(undefined, platform) + Object.defineProperty(win.webContents.mainFrame, 'url', { value: `${APP}/workspace` }) + const handler = new Map( + vi.mocked(win.webContents.ipc.on).mock.calls as unknown as Array< + [string, (event: unknown, theme: unknown, preference?: unknown) => void] + > + ).get('shell:app-theme') + if (!handler) throw new Error('shell:app-theme handler not registered') + const event = { sender: win.webContents, senderFrame: win.webContents.mainFrame } + return (theme: string, preference?: string) => handler(event, theme, preference) + } + + it('never pins the OS theme from a resolved theme alone', () => { + // Pinning would also pin prefers-color-scheme and freeze a Sim that follows the system. + reportTheme('win32')('light') + expect(nativeTheme.themeSource).toBe('system') + }) + + it('follows an explicit choice and releases it when Sim follows the system again', () => { + const report = reportTheme('win32') + report('dark', 'dark') + expect(nativeTheme.themeSource).toBe('dark') + report('dark', 'system') + expect(nativeTheme.themeSource).toBe('system') + }) + + it('leaves macOS appearance alone', () => { + reportTheme('darwin')('dark', 'dark') + expect(nativeTheme.themeSource).toBe('system') + }) + }) +}) + +describe('handleSecondInstance', () => { + function deps(platform: NodeJS.Platform) { + const existing = new BrowserWindow() + return { + existing, + ensureMainWindow: vi.fn(() => Promise.resolve(existing)), + createWindow: vi.fn(() => Promise.resolve(new BrowserWindow())), + showWindow: vi.fn(), + platform, + } + } + + it('surfaces the running window on Windows instead of opening another', async () => { + const d = deps('win32') + await handleSecondInstance(d) + expect(d.createWindow).not.toHaveBeenCalled() + expect(d.showWindow).toHaveBeenCalledWith(d.existing) + }) + + it('still opens another window for an explicit extra launch on macOS', async () => { + const d = deps('darwin') + await handleSecondInstance(d) + expect(d.createWindow).toHaveBeenCalledOnce() + expect(d.showWindow).not.toHaveBeenCalled() + }) }) diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 095385970f8..22770023c54 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -191,6 +191,18 @@ export function handleWillPreventUnload( } } +/** + * Points the native caption (and Electron-drawn menus) at Sim's own theme + * choice on Windows. Only the user's *preference* may drive this, never the + * resolved theme: `themeSource` also pins `prefers-color-scheme`, so pinning a + * resolved "light" would freeze a Sim set to follow the system. A sender that + * reports no preference leaves the OS default in place. + */ +function syncNativeThemeSource(preference: unknown): void { + if (preference !== 'system' && preference !== 'dark' && preference !== 'light') return + if (nativeTheme.themeSource !== preference) nativeTheme.themeSource = preference +} + export interface CreateMainWindowDeps { config: ConfigStore events: EventRecorder @@ -210,6 +222,11 @@ export interface CreateMainWindowDeps { restorePosition?: boolean /** Injectable for platform-specific window behavior tests. */ platform?: NodeJS.Platform + /** + * Window icon for Windows runs that have no packaged executable icon to + * inherit; packaged builds omit it. + */ + icon?: string } /** @@ -247,6 +264,11 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { titleBarOverlay: true, } : {}), + // Windows keeps its native frame; the menu bar stays out of the page's + // space until Alt reveals it. + ...(platform === 'win32' + ? { autoHideMenuBar: true, ...(deps.icon ? { icon: deps.icon } : {}) } + : {}), show: false, backgroundColor: backgroundColorFor( deps.config.get('themeBackground'), @@ -260,7 +282,7 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { }) attachShellTheme(win) - win.webContents.ipc.on('shell:app-theme', (event, theme: unknown) => { + win.webContents.ipc.on('shell:app-theme', (event, theme: unknown, preference: unknown) => { if ( event.sender !== win.webContents || event.senderFrame !== win.webContents.mainFrame || @@ -270,6 +292,7 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { return deps.config.set('themeBackground', theme) setShellTheme(theme) + if (platform === 'win32') syncNativeThemeSource(preference) }) let boundsTimer: NodeJS.Timeout | undefined @@ -419,3 +442,27 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { return win } + +export interface SecondInstanceDeps { + /** The existing full Sim window, creating one only when none is open. */ + ensureMainWindow: () => Promise + createWindow: () => Promise + showWindow: (win: BrowserWindow) => void + /** Injectable for platform-specific lifecycle tests. */ + platform?: NodeJS.Platform +} + +/** + * Answers a second OS launch forwarded by the single-instance lock. On + * Windows every Start-menu, taskbar, or shortcut launch while Sim runs + * arrives here, so it must surface the window the user already has rather + * than stack a new one per click. macOS only sees this for an explicit extra + * launch (`open -n`), which keeps meaning "another window". + */ +export async function handleSecondInstance(deps: SecondInstanceDeps): Promise { + if ((deps.platform ?? process.platform) === 'darwin') { + await deps.createWindow() + return + } + deps.showWindow(await deps.ensureMainWindow()) +} diff --git a/apps/desktop/src/main/windows.test.ts b/apps/desktop/src/main/windows.test.ts index 972026ec529..05dc504c7cd 100644 --- a/apps/desktop/src/main/windows.test.ts +++ b/apps/desktop/src/main/windows.test.ts @@ -29,27 +29,28 @@ describe('attachWindowOpenPolicy', () => { vi.mocked(shell.openExternal).mockClear() }) - function setup(isCommittedRelaunchPending: () => boolean = () => false) { + function setup(platform: NodeJS.Platform = 'darwin') { const contents = makeContents() const openAppWindow = vi.fn() attachWindowOpenPolicy(contents as unknown as WebContents, { appOrigin: () => APP, openAppWindow, allowHttpLocalhost: false, - isCommittedRelaunchPending, + isCommittedRelaunchPending: () => false, + platform, }) return { contents, openAppWindow } } - it('allows the MCP OAuth popup', () => { - const { contents } = setup() + it.each(['darwin', 'win32'] as const)('allows the MCP OAuth popup isolated on %s', (platform) => { + const { contents } = setup(platform) const result = contents.handler?.({ url: 'https://mcp.example/authorize', frameName: 'mcp-oauth-s1', }) expect(result).toEqual({ action: 'allow', - overrideBrowserWindowOptions: { + overrideBrowserWindowOptions: expect.objectContaining({ webPreferences: expect.objectContaining({ preload: undefined, additionalArguments: [], @@ -59,7 +60,7 @@ describe('attachWindowOpenPolicy', () => { webSecurity: true, webviewTag: false, }), - }, + }), }) }) diff --git a/apps/desktop/src/main/windows.ts b/apps/desktop/src/main/windows.ts index fcb9c41f115..8d648b95320 100644 --- a/apps/desktop/src/main/windows.ts +++ b/apps/desktop/src/main/windows.ts @@ -29,6 +29,13 @@ const ISOLATED_CHILD_WINDOW_OPTIONS = { }, } satisfies BrowserWindowConstructorOptions +/** Popups keep the native Windows frame but not the app's menu bar, which Alt still reveals. */ +function childWindowOptions(platform: NodeJS.Platform): BrowserWindowConstructorOptions { + return platform === 'win32' + ? { ...ISOLATED_CHILD_WINDOW_OPTIONS, autoHideMenuBar: true } + : ISOLATED_CHILD_WINDOW_OPTIONS +} + /** * Marks a WebContents as a guarded popup child (MCP OAuth, blank-then-assign) * so the navigation classifier can apply the more permissive popup policy. @@ -46,6 +53,8 @@ export interface WindowPolicyDeps { openAppWindow: (url: string) => void allowHttpLocalhost: boolean isCommittedRelaunchPending: () => boolean + /** Injectable for platform-specific window behavior tests. */ + platform?: NodeJS.Platform } /** @@ -63,7 +72,7 @@ export function attachWindowOpenPolicy(contents: WebContents, deps: WindowPolicy case 'popup-blank': return { action: 'allow', - overrideBrowserWindowOptions: ISOLATED_CHILD_WINDOW_OPTIONS, + overrideBrowserWindowOptions: childWindowOptions(deps.platform ?? process.platform), } case 'popup-internal': { deps.openAppWindow(details.url) diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index c0f180897d0..1510a9e9e77 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -80,6 +80,7 @@ export const systemPreferences = { export const nativeTheme = { shouldUseDarkColors: false, + themeSource: 'system' as 'system' | 'light' | 'dark', on: vi.fn(), } @@ -323,6 +324,7 @@ export class BrowserWindow { setTitle = vi.fn() setVisibleOnAllWorkspaces = vi.fn() setAlwaysOnTop = vi.fn() + removeMenu = vi.fn() getSize = vi.fn(() => [1180, 850]) getContentSize = vi.fn(() => [1180, 850]) contentView = { From d0a4ec4409a628c0559dc86b37b1c21262a98b06 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:31:19 -0700 Subject: [PATCH 07/67] feat(desktop): report the app theme preference and strip the picker menu on Windows The preload sends the explicit sim-theme preference alongside the resolved theme so main can follow an explicit choice on Windows. The credential picker drops the app menu Windows attaches to every window, so its accelerators cannot act inside the picker. --- .../src/main/browser-credentials/picker.ts | 2 ++ apps/desktop/src/preload/shell-theme.ts | 27 ++++++++++++++----- apps/desktop/src/test/electron-mock.ts | 1 + 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/browser-credentials/picker.ts b/apps/desktop/src/main/browser-credentials/picker.ts index e00d062a82a..49101b91a98 100644 --- a/apps/desktop/src/main/browser-credentials/picker.ts +++ b/apps/desktop/src/main/browser-credentials/picker.ts @@ -61,6 +61,8 @@ export class CredentialPicker { app.isPackaged ), }) + // Windows attaches the app menu to every window; its accelerators would act inside the picker. + if (process.platform === 'win32') win.removeMenu() this.window = win const pageUrl = localPageUrl('credential-picker.html') attachShellTheme(win) diff --git a/apps/desktop/src/preload/shell-theme.ts b/apps/desktop/src/preload/shell-theme.ts index d12f010555b..bf891b1355f 100644 --- a/apps/desktop/src/preload/shell-theme.ts +++ b/apps/desktop/src/preload/shell-theme.ts @@ -14,21 +14,36 @@ export function exposeShellTheme(): void { contextBridge.exposeInMainWorld('simShellTheme', api) } -/** Reports the actual app theme, including changes after hydration or OS theme changes. */ +/** The app's explicit theme choice (the next-themes `sim-theme` key), or `system`. */ +function themePreference(): string { + try { + return localStorage.getItem('sim-theme') ?? 'system' + } catch { + return 'system' + } +} + +/** + * Reports the actual app theme and the user's preference behind it, including + * changes after hydration or OS theme changes. + */ export function observeAppTheme(): void { window.addEventListener('DOMContentLoaded', () => { if (!['https:', 'http:'].includes(location.protocol)) return - let previous: ShellTheme | undefined + let previous: string | undefined const report = () => { const root = document.documentElement - const theme = root.classList.contains('dark') + const theme: ShellTheme | undefined = root.classList.contains('dark') ? 'dark' : root.classList.contains('light') ? 'light' : undefined - if (!theme || theme === previous) return - previous = theme - ipcRenderer.send('shell:app-theme', theme) + if (!theme) return + const preference = themePreference() + const reported = `${theme}:${preference}` + if (reported === previous) return + previous = reported + ipcRenderer.send('shell:app-theme', theme, preference) } new MutationObserver(report).observe(document.documentElement, { attributes: true, diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index c0f180897d0..80b383bf94b 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -323,6 +323,7 @@ export class BrowserWindow { setTitle = vi.fn() setVisibleOnAllWorkspaces = vi.fn() setAlwaysOnTop = vi.fn() + removeMenu = vi.fn() getSize = vi.fn(() => [1180, 850]) getContentSize = vi.fn(() => [1180, 850]) contentView = { From 41077cf4a17bf891231730ac181a20f6b70c2aea Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:32:56 -0700 Subject: [PATCH 08/67] feat(desktop): serve Windows update feeds alongside macOS - update-feed: a per-platform descriptor (manifest, artifact names, installer preference). Windows builds publish independently, so a release without latest.yml is not a Windows candidate (404 no-release, never a 502), while a latest.yml naming anything but Sim--x64.exe rejects the release. - New /api/desktop/update/latest.yml twin of latest-mac.yml, sharing one handler; the GitHub resolution moves into resolveDeploymentRelease. - /api/desktop/update/download takes ?platform=mac|windows through a contract (default mac for shells that predate it); the update gate passes the shell's platform. --- apps/sim/app/_shell/desktop-update-gate.tsx | 15 +- .../api/desktop/update/download/route.test.ts | 67 +++++- .../app/api/desktop/update/download/route.ts | 110 +++------ .../desktop/update/latest-mac.yml/route.ts | 120 +--------- .../desktop/update/latest.yml/route.test.ts | 120 ++++++++++ .../api/desktop/update/latest.yml/route.ts | 7 + .../app/api/desktop/update/manifest-feed.ts | 55 +++++ apps/sim/lib/api/contracts/desktop-update.ts | 22 ++ apps/sim/lib/desktop/update-feed.test.ts | 102 +++++++-- apps/sim/lib/desktop/update-feed.ts | 208 +++++++++++++++--- scripts/check-api-validation-contracts.ts | 9 +- 11 files changed, 574 insertions(+), 261 deletions(-) create mode 100644 apps/sim/app/api/desktop/update/latest.yml/route.test.ts create mode 100644 apps/sim/app/api/desktop/update/latest.yml/route.ts create mode 100644 apps/sim/app/api/desktop/update/manifest-feed.ts create mode 100644 apps/sim/lib/api/contracts/desktop-update.ts diff --git a/apps/sim/app/_shell/desktop-update-gate.tsx b/apps/sim/app/_shell/desktop-update-gate.tsx index ed231c04534..ef832cc608c 100644 --- a/apps/sim/app/_shell/desktop-update-gate.tsx +++ b/apps/sim/app/_shell/desktop-update-gate.tsx @@ -3,7 +3,13 @@ import { useEffect, useState } from 'react' import type { DesktopUpdateState } from '@sim/desktop-bridge' import { Chip, useNativeSurfaceOcclusionReady } from '@sim/emcn' -import { getDesktopBridge, getDesktopShellVersion, getDesktopUpdates } from '@/lib/desktop' +import type { DesktopUpdatePlatform } from '@/lib/api/contracts/desktop-update' +import { + getDesktopBridge, + getDesktopPlatform, + getDesktopShellVersion, + getDesktopUpdates, +} from '@/lib/desktop' import { isShellOutdated } from '@/lib/desktop/min-version' import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state' @@ -19,9 +25,10 @@ function manualDownloadUrl(): string { const origin = window.location.origin // openExternal only accepts https, so an http self-hosted origin cannot // serve the redirect to the system browser. - return origin.startsWith('https://') - ? `${origin}${DOWNLOAD_REDIRECT_PATH}` - : DOWNLOAD_FALLBACK_URL + if (!origin.startsWith('https://')) return DOWNLOAD_FALLBACK_URL + // Shells that cannot report their platform predate Windows support: macOS. + const platform: DesktopUpdatePlatform = getDesktopPlatform() === 'win32' ? 'windows' : 'mac' + return `${origin}${DOWNLOAD_REDIRECT_PATH}?platform=${platform}` } interface GateAction { diff --git a/apps/sim/app/api/desktop/update/download/route.test.ts b/apps/sim/app/api/desktop/update/download/route.test.ts index 0246374ab6d..7f10c965be4 100644 --- a/apps/sim/app/api/desktop/update/download/route.test.ts +++ b/apps/sim/app/api/desktop/update/download/route.test.ts @@ -6,6 +6,7 @@ import { DESKTOP_STABLE_RELEASE_REPOSITORY, MANIFEST_ASSET_NAME, releasesApiUrl, + WINDOWS_MANIFEST_ASSET_NAME, } from '@/lib/desktop/update-feed' import { GET } from '@/app/api/desktop/update/download/route' @@ -37,8 +38,24 @@ function manifest(version: string) { return [`version: ${version}`, 'files:', ` - url: Sim-${version}-universal.zip`].join('\n') } -async function getDownload(): Promise { - return GET(new NextRequest('https://www.sim.ai/api/desktop/update/download'), undefined) +async function getDownload(query = ''): Promise { + return GET(new NextRequest(`https://www.sim.ai/api/desktop/update/download${query}`), undefined) +} + +function withWindowsBuild(macRelease: ReturnType) { + const version = macRelease.tag_name.replace(/^v/, '') + const base = macRelease.assets[0].browser_download_url.replace(/\/[^/]+$/, '') + return { + ...macRelease, + assets: [ + ...macRelease.assets, + { + name: WINDOWS_MANIFEST_ASSET_NAME, + browser_download_url: `${base}/${WINDOWS_MANIFEST_ASSET_NAME}`, + }, + { name: `Sim-${version}-x64.exe`, browser_download_url: `${base}/Sim-${version}-x64.exe` }, + ], + } } describe('desktop update download route', () => { @@ -53,9 +70,13 @@ describe('desktop update download route', () => { const candidate = releases.find((release) => release.assets.some((asset) => asset.browser_download_url === url) ) - return candidate - ? new Response(manifest(candidate.tag_name.replace(/^v/, ''))) - : new Response(null, { status: 404 }) + if (!candidate) return new Response(null, { status: 404 }) + const version = candidate.tag_name.replace(/^v/, '') + return new Response( + url.endsWith(`/${WINDOWS_MANIFEST_ASSET_NAME}`) + ? [`version: ${version}`, 'files:', ` - url: Sim-${version}-x64.exe`].join('\n') + : manifest(version) + ) }) } @@ -125,4 +146,40 @@ describe('desktop update download route', () => { expect(response.status).toBe(502) expect(await response.json()).toMatchObject({ error: 'Release feed unavailable' }) }) + it('redirects a Windows shell to the x64 installer of the newest Windows release', async () => { + mockReleases([ + release('v1.4.0', DESKTOP_STABLE_RELEASE_REPOSITORY), + withWindowsBuild(release('v1.3.0', DESKTOP_STABLE_RELEASE_REPOSITORY)), + ]) + + const response = await getDownload('?platform=windows') + + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe( + `https://github.com/${DESKTOP_STABLE_RELEASE_REPOSITORY}/releases/download/v1.3.0/Sim-1.3.0-x64.exe` + ) + }) + + it('keeps macOS callers on the dmg when a release also ships Windows', async () => { + mockReleases([withWindowsBuild(release('v1.3.0', DESKTOP_STABLE_RELEASE_REPOSITORY))]) + + const response = await getDownload() + + expect(response.headers.get('location')).toContain('Sim-1.3.0-universal.dmg') + }) + + it('reports no Windows release instead of redirecting to a macOS installer', async () => { + mockReleases([release('v1.3.0', DESKTOP_STABLE_RELEASE_REPOSITORY)]) + + const response = await getDownload('?platform=windows') + + expect(response.status).toBe(404) + }) + + it('rejects an unknown platform', async () => { + const response = await getDownload('?platform=linux') + + expect(response.status).toBe(400) + expect(fetchMock).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/desktop/update/download/route.ts b/apps/sim/app/api/desktop/update/download/route.ts index f48124a11cd..1556e882891 100644 --- a/apps/sim/app/api/desktop/update/download/route.ts +++ b/apps/sim/app/api/desktop/update/download/route.ts @@ -1,25 +1,16 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +import { downloadDesktopUpdateContract } from '@/lib/api/contracts/desktop-update' +import { parseRequest } from '@/lib/api/server' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - channelForDeploymentEnvironment, - type DesktopReleaseCandidate, - releaseRepositoryForChannel, - releasesApiUrl, - resolveLatestRelease, - resolveReleaseAssets, + DESKTOP_FEED_REVALIDATE_SECONDS, + resolveDeploymentRelease, } from '@/lib/desktop/update-feed' -const logger = createLogger('DesktopUpdateDownloadAPI') - -/** Matches the manifest feed so both paths resolve the same release. */ -const REVALIDATE_SECONDS = 300 - /** - * Redirects to the installer for the newest release of this deployment's - * channel (see `lib/desktop/update-feed.ts`). + * Redirects to the platform's installer for the newest release of this + * deployment's channel (see `lib/desktop/update-feed.ts`). * * This is the manual escape hatch behind the blocking update gate: shells too * old to expose the updater bridge send the user here instead of @@ -31,73 +22,30 @@ const REVALIDATE_SECONDS = 300 * Public by the same reasoning as the manifest feed: it only points at public * GitHub release assets. */ -export const GET = withRouteHandler(async (_request: NextRequest): Promise => { - const channel = channelForDeploymentEnvironment(env.APPCONFIG_ENVIRONMENT) - const releaseRepository = releaseRepositoryForChannel(channel) - - const githubToken = env.GITHUB_TOKEN - const resolved = await resolveLatestRelease( - channel, - async (page) => { - try { - const response = await fetch(releasesApiUrl(releaseRepository, page), { - headers: { - accept: 'application/vnd.github+json', - ...(githubToken ? { authorization: `Bearer ${githubToken}` } : {}), - }, - next: { revalidate: REVALIDATE_SECONDS }, - }) - if (!response.ok) { - logger.error('GitHub releases lookup failed', { - status: response.status, - page, - channel, - releaseRepository, - }) - return null - } - return (await response.json()) as DesktopReleaseCandidate[] - } catch (error) { - logger.error('GitHub releases response could not be read', { - message: getErrorMessage(error), - page, - channel, - releaseRepository, - }) - return null - } - }, - async (release) => { - const assets = await resolveReleaseAssets(release, releaseRepository, (url) => - fetch(url, { - next: { revalidate: REVALIDATE_SECONDS }, - }) - ) - if (!assets) { - logger.warn('Skipping incomplete or invalid desktop release', { - tag: release.tag_name, - channel, - }) - } - return assets?.installer ?? null - } - ) - if ('error' in resolved) { - return NextResponse.json({ error: 'Release feed unavailable' }, { status: 502 }) - } +export const GET = withRouteHandler(async (request: NextRequest): Promise => { + const parsed = await parseRequest(downloadDesktopUpdateContract, request, {}) + if (!parsed.success) return parsed.response - if (!resolved.release) { - if (resolved.rejectedCandidates) { + const resolved = await resolveDeploymentRelease({ + environment: env.APPCONFIG_ENVIRONMENT, + githubToken: env.GITHUB_TOKEN, + platform: parsed.data.query.platform, + select: (assets) => assets.installer, + }) + switch (resolved.status) { + case 'unavailable': + return NextResponse.json({ error: 'Release feed unavailable' }, { status: 502 }) + case 'invalid': return NextResponse.json({ error: 'Release installer unavailable' }, { status: 502 }) - } - return NextResponse.json( - { error: `No desktop release for channel ${channel}` }, - { status: 404 } - ) + case 'no-release': + return NextResponse.json( + { error: `No desktop release for channel ${resolved.channel}` }, + { status: 404 } + ) + case 'release': + return NextResponse.redirect(resolved.value.browser_download_url, { + status: 302, + headers: { 'cache-control': `public, max-age=${DESKTOP_FEED_REVALIDATE_SECONDS}` }, + }) } - - return NextResponse.redirect(resolved.value.browser_download_url, { - status: 302, - headers: { 'cache-control': `public, max-age=${REVALIDATE_SECONDS}` }, - }) }) diff --git a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts index dafe66d433f..62798023d55 100644 --- a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts +++ b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts @@ -1,117 +1,7 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - channelForDeploymentEnvironment, - type DesktopReleaseCandidate, - releaseRepositoryForChannel, - releasesApiUrl, - resolveLatestRelease, - resolveReleaseAssets, -} from '@/lib/desktop/update-feed' +import { serveDesktopUpdateManifest } from '@/app/api/desktop/update/manifest-feed' -const logger = createLogger('DesktopUpdateFeedAPI') - -/** - * How long a resolved feed may be stale. A fresh release becomes visible to - * shells within this window (their own check cadence is hours anyway). - */ -const REVALIDATE_SECONDS = 300 -const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' - -/** - * The per-environment desktop update feed (see `lib/desktop/update-feed.ts`). - * - * electron-updater's generic provider on installed shells requests this - * exact path from the origin the shell is pointed at. The route is public by - * design: the updater's HTTP client carries no session, and the response - * only describes public GitHub release artifacts. - */ -export const GET = withRouteHandler(async (_request: NextRequest): Promise => { - /** - * Hosted deployments inject APPCONFIG_ENVIRONMENT independently at runtime, - * so it stays correct when the same image is promoted across environments. - * Request host headers are intentionally excluded: this public route must not - * let a caller choose which app-identity release the feed serves. - */ - const channel = channelForDeploymentEnvironment(env.APPCONFIG_ENVIRONMENT) - const releaseRepository = releaseRepositoryForChannel(channel) - - // A token raises the GitHub API quota from 60/h per NAT IP to 5000/h. - // Optional: the repo is public, so the feed works without one. - const githubToken = env.GITHUB_TOKEN - const resolved = await resolveLatestRelease( - channel, - async (page) => { - try { - const response = await fetch(releasesApiUrl(releaseRepository, page), { - headers: { - accept: 'application/vnd.github+json', - ...(githubToken ? { authorization: `Bearer ${githubToken}` } : {}), - }, - next: { revalidate: REVALIDATE_SECONDS }, - }) - if (!response.ok) { - logger.error('GitHub releases lookup failed', { - status: response.status, - page, - channel, - releaseRepository, - }) - return null - } - return (await response.json()) as DesktopReleaseCandidate[] - } catch (error) { - logger.error('GitHub releases response could not be read', { - message: getErrorMessage(error), - page, - channel, - releaseRepository, - }) - return null - } - }, - async (release) => { - const assets = await resolveReleaseAssets(release, releaseRepository, (url) => - fetch(url, { - next: { revalidate: REVALIDATE_SECONDS }, - }) - ) - if (!assets) { - logger.warn('Skipping incomplete or invalid desktop release', { - tag: release.tag_name, - channel, - }) - } - return assets?.manifest ?? null - } - ) - if ('error' in resolved) { - return NextResponse.json({ error: 'Release feed unavailable' }, { status: 502 }) - } - - const release = resolved.release - if (!release) { - if (resolved.rejectedCandidates) { - return NextResponse.json({ error: 'Release manifest unavailable' }, { status: 502 }) - } - return NextResponse.json( - { error: `No desktop release for channel ${channel}` }, - { - status: 404, - headers: { [FEED_STATUS_HEADER]: 'no-release' }, - } - ) - } - - return new NextResponse(resolved.value, { - status: 200, - headers: { - 'content-type': 'text/yaml; charset=utf-8', - 'cache-control': `public, max-age=${REVALIDATE_SECONDS}`, - [FEED_STATUS_HEADER]: 'release', - }, - }) -}) +/** The macOS update manifest (see `manifest-feed.ts`). */ +export const GET = withRouteHandler( + async (): Promise => serveDesktopUpdateManifest('mac') +) diff --git a/apps/sim/app/api/desktop/update/latest.yml/route.test.ts b/apps/sim/app/api/desktop/update/latest.yml/route.test.ts new file mode 100644 index 00000000000..a4154db6c25 --- /dev/null +++ b/apps/sim/app/api/desktop/update/latest.yml/route.test.ts @@ -0,0 +1,120 @@ +import { setEnv } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + DESKTOP_STABLE_RELEASE_REPOSITORY, + MANIFEST_ASSET_NAME, + releasesApiUrl, + WINDOWS_MANIFEST_ASSET_NAME, +} from '@/lib/desktop/update-feed' +import { GET } from '@/app/api/desktop/update/latest.yml/route' + +const STABLE_RELEASES_URL = releasesApiUrl(DESKTOP_STABLE_RELEASE_REPOSITORY, 1) +const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' + +function asset(tag: string, name: string) { + return { name, browser_download_url: `https://downloads.example/${tag}/${name}` } +} + +/** A release carrying the macOS build, plus the Windows build when `windows` is set. */ +function release(tag: string, options?: { windows?: boolean }) { + const version = tag.replace(/^v/, '') + return { + tag_name: tag, + draft: false, + prerelease: tag.includes('-'), + assets: [ + asset(tag, MANIFEST_ASSET_NAME), + asset(tag, `Sim-${version}-universal.zip`), + asset(tag, `Sim-${version}-universal.dmg`), + ...(options?.windows + ? [asset(tag, WINDOWS_MANIFEST_ASSET_NAME), asset(tag, `Sim-${version}-x64.exe`)] + : []), + ], + } +} + +function windowsManifest(version: string, artifact = `Sim-${version}-x64.exe`) { + return [`version: ${version}`, 'files:', ` - url: ${artifact}`, `path: ${artifact}`].join('\n') +} + +function macManifest(version: string) { + return [`version: ${version}`, 'files:', ` - url: Sim-${version}-universal.zip`].join('\n') +} + +async function getFeed(): Promise { + return GET(new NextRequest('https://www.sim.ai/api/desktop/update/latest.yml'), undefined) +} + +describe('desktop Windows update manifest route', () => { + const fetchMock = vi.fn() + + function mockFeed( + releases: ReturnType[], + manifests: Record + ): void { + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = String(input) + if (url === STABLE_RELEASES_URL) return Response.json(releases) + const body = manifests[url] + return body === undefined ? new Response(null, { status: 404 }) : new Response(body) + }) + } + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + setEnv({ APPCONFIG_ENVIRONMENT: undefined }) + }) + + it('serves the Windows manifest, never the macOS one', async () => { + mockFeed([release('v1.1.0', { windows: true })], { + [`https://downloads.example/v1.1.0/${WINDOWS_MANIFEST_ASSET_NAME}`]: windowsManifest('1.1.0'), + [`https://downloads.example/v1.1.0/${MANIFEST_ASSET_NAME}`]: macManifest('1.1.0'), + }) + + const response = await getFeed() + const body = await response.text() + + expect(response.status).toBe(200) + expect(response.headers.get(FEED_STATUS_HEADER)).toBe('release') + expect(body).toContain( + ` - url: https://github.com/${DESKTOP_STABLE_RELEASE_REPOSITORY}/releases/download/v1.1.0/Sim-1.1.0-x64.exe` + ) + expect(body).not.toContain('universal') + }) + + it('passes over a newer release whose Windows build was never published', async () => { + mockFeed([release('v1.2.0'), release('v1.1.0', { windows: true })], { + [`https://downloads.example/v1.1.0/${WINDOWS_MANIFEST_ASSET_NAME}`]: windowsManifest('1.1.0'), + }) + + const response = await getFeed() + + expect(response.status).toBe(200) + expect(await response.text()).toContain('version: 1.1.0') + }) + + it('reports no release, not a broken feed, before any Windows build ships', async () => { + mockFeed([release('v1.2.0'), release('v1.1.0')], {}) + + const response = await getFeed() + + expect(response.status).toBe(404) + expect(response.headers.get(FEED_STATUS_HEADER)).toBe('no-release') + }) + + it('rejects a Windows manifest that points at macOS artifacts', async () => { + mockFeed([release('v1.1.0', { windows: true })], { + [`https://downloads.example/v1.1.0/${WINDOWS_MANIFEST_ASSET_NAME}`]: windowsManifest( + '1.1.0', + 'Sim-1.1.0-universal.zip' + ), + }) + + const response = await getFeed() + + expect(response.status).toBe(502) + expect(await response.json()).toMatchObject({ error: 'Release manifest unavailable' }) + }) +}) diff --git a/apps/sim/app/api/desktop/update/latest.yml/route.ts b/apps/sim/app/api/desktop/update/latest.yml/route.ts new file mode 100644 index 00000000000..f6eace3109b --- /dev/null +++ b/apps/sim/app/api/desktop/update/latest.yml/route.ts @@ -0,0 +1,7 @@ +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { serveDesktopUpdateManifest } from '@/app/api/desktop/update/manifest-feed' + +/** The Windows update manifest electron-updater's NSIS updater requests (see `manifest-feed.ts`). */ +export const GET = withRouteHandler( + async (): Promise => serveDesktopUpdateManifest('windows') +) diff --git a/apps/sim/app/api/desktop/update/manifest-feed.ts b/apps/sim/app/api/desktop/update/manifest-feed.ts new file mode 100644 index 00000000000..cc21fad660b --- /dev/null +++ b/apps/sim/app/api/desktop/update/manifest-feed.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server' +import type { DesktopUpdatePlatform } from '@/lib/api/contracts/desktop-update' +import { env } from '@/lib/core/config/env' +import { + DESKTOP_FEED_REVALIDATE_SECONDS, + resolveDeploymentRelease, +} from '@/lib/desktop/update-feed' + +const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' + +/** + * Serves a platform's electron-updater manifest for this deployment's channel + * (see `lib/desktop/update-feed.ts`), shared by `latest-mac.yml` and + * `latest.yml`. + * + * electron-updater's generic provider on installed shells requests these exact + * paths from the origin the shell is pointed at. The routes are public by + * design: the updater's HTTP client carries no session, and the response only + * describes public GitHub release artifacts. + * + * A 404 tagged `no-release` tells the shell the channel has nothing for its + * platform yet; a 502 means releases exist but none can be offered. + */ +export async function serveDesktopUpdateManifest( + platform: DesktopUpdatePlatform +): Promise { + // Hosted deployments inject APPCONFIG_ENVIRONMENT independently at runtime, + // so it stays correct when the same image is promoted across environments. + const resolved = await resolveDeploymentRelease({ + environment: env.APPCONFIG_ENVIRONMENT, + githubToken: env.GITHUB_TOKEN, + platform, + select: (assets) => assets.manifest, + }) + switch (resolved.status) { + case 'unavailable': + return NextResponse.json({ error: 'Release feed unavailable' }, { status: 502 }) + case 'invalid': + return NextResponse.json({ error: 'Release manifest unavailable' }, { status: 502 }) + case 'no-release': + return NextResponse.json( + { error: `No desktop release for channel ${resolved.channel}` }, + { status: 404, headers: { [FEED_STATUS_HEADER]: 'no-release' } } + ) + case 'release': + return new NextResponse(resolved.value, { + status: 200, + headers: { + 'content-type': 'text/yaml; charset=utf-8', + 'cache-control': `public, max-age=${DESKTOP_FEED_REVALIDATE_SECONDS}`, + [FEED_STATUS_HEADER]: 'release', + }, + }) + } +} diff --git a/apps/sim/lib/api/contracts/desktop-update.ts b/apps/sim/lib/api/contracts/desktop-update.ts new file mode 100644 index 00000000000..d5e818447b8 --- /dev/null +++ b/apps/sim/lib/api/contracts/desktop-update.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const desktopUpdatePlatformSchema = z.enum(['mac', 'windows']) + +/** The desktop platforms the update feed serves releases for. */ +export type DesktopUpdatePlatform = z.output + +/** + * Redirects to the newest installer of this deployment's channel for a + * platform. Public and session-less: it only points at public GitHub release + * assets. Shells that predate Windows support omit `platform`, so it defaults + * to `mac`. + */ +export const downloadDesktopUpdateContract = defineRouteContract({ + method: 'GET', + path: '/api/desktop/update/download', + query: z.object({ + platform: desktopUpdatePlatformSchema.default('mac'), + }), + response: { mode: 'redirect', status: 302 }, +}) diff --git a/apps/sim/lib/desktop/update-feed.test.ts b/apps/sim/lib/desktop/update-feed.test.ts index bd2f6817c73..888cddc0317 100644 --- a/apps/sim/lib/desktop/update-feed.test.ts +++ b/apps/sim/lib/desktop/update-feed.test.ts @@ -5,6 +5,7 @@ import { MANIFEST_ASSET_NAME, rewriteManifestUrls, selectReleaseForChannel, + WINDOWS_MANIFEST_ASSET_NAME, } from '@/lib/desktop/update-feed' function release( @@ -32,22 +33,22 @@ describe('selectReleaseForChannel', () => { ] it('offers stable-only to the latest channel', () => { - expect(selectReleaseForChannel(releases, 'latest')?.tag_name).toBe('v0.5.24') + expect(selectReleaseForChannel(releases, 'latest', 'mac')?.tag_name).toBe('v0.5.24') }) it('offers only staging builds to the staging stream', () => { - expect(selectReleaseForChannel(releases, 'staging')?.tag_name).toBe('v0.5.25-staging.2') + expect(selectReleaseForChannel(releases, 'staging', 'mac')?.tag_name).toBe('v0.5.25-staging.2') }) it('offers only dev builds to the dev stream, never staging builds', () => { - expect(selectReleaseForChannel(releases, 'dev')?.tag_name).toBe('v0.5.25-dev.412') + expect(selectReleaseForChannel(releases, 'dev', 'mac')?.tag_name).toBe('v0.5.25-dev.412') }) it('keeps already-published alpha and beta releases eligible during migration', () => { - expect(selectReleaseForChannel([release('v0.5.25-alpha.412')], 'dev')?.tag_name).toBe( + expect(selectReleaseForChannel([release('v0.5.25-alpha.412')], 'dev', 'mac')?.tag_name).toBe( 'v0.5.25-alpha.412' ) - expect(selectReleaseForChannel([release('v0.5.25-beta.2')], 'staging')?.tag_name).toBe( + expect(selectReleaseForChannel([release('v0.5.25-beta.2')], 'staging', 'mac')?.tag_name).toBe( 'v0.5.25-beta.2' ) }) @@ -57,14 +58,16 @@ describe('selectReleaseForChannel', () => { // Sim Staging); a stable Sim.app artifact can't be applied by those // shells, so a newer stable must not shadow the channel's own builds. const withNewStable = [...releases, release('v0.5.25')] - expect(selectReleaseForChannel(withNewStable, 'dev')?.tag_name).toBe('v0.5.25-dev.412') - expect(selectReleaseForChannel(withNewStable, 'staging')?.tag_name).toBe('v0.5.25-staging.2') - expect(selectReleaseForChannel(withNewStable, 'latest')?.tag_name).toBe('v0.5.25') + expect(selectReleaseForChannel(withNewStable, 'dev', 'mac')?.tag_name).toBe('v0.5.25-dev.412') + expect(selectReleaseForChannel(withNewStable, 'staging', 'mac')?.tag_name).toBe( + 'v0.5.25-staging.2' + ) + expect(selectReleaseForChannel(withNewStable, 'latest', 'mac')?.tag_name).toBe('v0.5.25') }) it('skips stable-tagged releases flagged prerelease on the latest channel', () => { const flagged = [release('v0.5.25', { prerelease: true }), release('v0.5.24')] - expect(selectReleaseForChannel(flagged, 'latest')?.tag_name).toBe('v0.5.24') + expect(selectReleaseForChannel(flagged, 'latest', 'mac')?.tag_name).toBe('v0.5.24') }) it('keeps releases missing the updater manifest eligible for candidate validation', () => { @@ -72,7 +75,9 @@ describe('selectReleaseForChannel', () => { release('v0.5.25-dev.413', { assets: [{ name: 'Sim-0.5.25-dev.413-universal.dmg' }] }), release('v0.5.25-dev.412'), ] - expect(selectReleaseForChannel(withBrokenNewest, 'dev')?.tag_name).toBe('v0.5.25-dev.413') + expect(selectReleaseForChannel(withBrokenNewest, 'dev', 'mac')?.tag_name).toBe( + 'v0.5.25-dev.413' + ) }) }) @@ -95,7 +100,8 @@ describe('rewriteManifestUrls', () => { manifest, 'v0.5.24', repository, - new Set(['Sim-0.5.24-universal.zip']) + new Set(['Sim-0.5.24-universal.zip']), + 'mac' ) expect(rewritten).toContain( ` - url: https://github.com/${repository}/releases/download/v0.5.24/Sim-0.5.24-universal.zip` @@ -113,7 +119,8 @@ describe('rewriteManifestUrls', () => { manifest, 'v0.5.24', DESKTOP_STABLE_RELEASE_REPOSITORY, - new Set(['Sim-0.5.24-universal.zip']) + new Set(['Sim-0.5.24-universal.zip']), + 'mac' ) ).toBe( ` - url: https://github.com/${DESKTOP_STABLE_RELEASE_REPOSITORY}/releases/download/v0.5.24/Sim-0.5.24-universal.zip` @@ -127,7 +134,8 @@ describe('rewriteManifestUrls', () => { manifest, 'v0.5.24', DESKTOP_STABLE_RELEASE_REPOSITORY, - new Set(['Sim-0.5.24-universal.zip']) + new Set(['Sim-0.5.24-universal.zip']), + 'mac' ) ).toBeNull() }) @@ -135,7 +143,7 @@ describe('rewriteManifestUrls', () => { it('rejects an expected artifact that is absent from the release', () => { const manifest = ' - url: Sim-0.5.24-universal.zip' expect( - rewriteManifestUrls(manifest, 'v0.5.24', DESKTOP_STABLE_RELEASE_REPOSITORY, new Set()) + rewriteManifestUrls(manifest, 'v0.5.24', DESKTOP_STABLE_RELEASE_REPOSITORY, new Set(), 'mac') ).toBeNull() }) @@ -146,7 +154,71 @@ describe('rewriteManifestUrls', () => { manifest, 'v0.5.24', DESKTOP_STABLE_RELEASE_REPOSITORY, - new Set(['Sim-0.5.24-universal.zip']) + new Set(['Sim-0.5.24-universal.zip']), + 'mac' + ) + ).toBeNull() + }) +}) + +describe('Windows feed', () => { + const windowsRelease = (tag: string) => + release(tag, { + assets: [ + { name: MANIFEST_ASSET_NAME }, + { name: WINDOWS_MANIFEST_ASSET_NAME }, + { name: `Sim-${tag.replace(/^v/, '')}-x64.exe` }, + ], + }) + + it('skips a newer release that carries no Windows build instead of rejecting it', () => { + const releases = [release('v0.5.25'), windowsRelease('v0.5.24')] + expect(selectReleaseForChannel(releases, 'latest', 'windows')?.tag_name).toBe('v0.5.24') + expect(selectReleaseForChannel(releases, 'latest', 'mac')?.tag_name).toBe('v0.5.25') + }) + + it('rewrites the Windows installer entry to its absolute release asset', () => { + const manifest = [ + 'version: 0.5.24', + 'files:', + ' - url: Sim-0.5.24-x64.exe', + ' sha512: abc', + 'path: Sim-0.5.24-x64.exe', + ].join('\n') + expect( + rewriteManifestUrls( + manifest, + 'v0.5.24', + DESKTOP_STABLE_RELEASE_REPOSITORY, + new Set(['Sim-0.5.24-x64.exe']), + 'windows' + ) + ).toContain( + ` - url: https://github.com/${DESKTOP_STABLE_RELEASE_REPOSITORY}/releases/download/v0.5.24/Sim-0.5.24-x64.exe` + ) + }) + + it('rejects a Windows manifest that points at macOS artifacts', () => { + expect( + rewriteManifestUrls( + ' - url: Sim-0.5.24-universal.zip', + 'v0.5.24', + DESKTOP_STABLE_RELEASE_REPOSITORY, + new Set(['Sim-0.5.24-universal.zip']), + 'windows' + ) + ).toBeNull() + }) + + it('rejects a Windows manifest naming an architecture the feed does not ship', () => { + const manifest = [' - url: Sim-0.5.24-x64.exe', ' - url: Sim-0.5.24-arm64.exe'].join('\n') + expect( + rewriteManifestUrls( + manifest, + 'v0.5.24', + DESKTOP_STABLE_RELEASE_REPOSITORY, + new Set(['Sim-0.5.24-x64.exe', 'Sim-0.5.24-arm64.exe']), + 'windows' ) ).toBeNull() }) diff --git a/apps/sim/lib/desktop/update-feed.ts b/apps/sim/lib/desktop/update-feed.ts index f6ba20a3788..51a703f57ce 100644 --- a/apps/sim/lib/desktop/update-feed.ts +++ b/apps/sim/lib/desktop/update-feed.ts @@ -2,8 +2,8 @@ * Per-environment desktop update feed resolution. * * Installed desktop shells ask the Sim deployment they are pointed at — - * `GET /api/desktop/update/latest-mac.yml` — instead of a global - * GitHub feed, so each environment independently controls which shell build + * `GET /api/desktop/update/latest-mac.yml` on macOS, `latest.yml` on + * Windows — instead of a global GitHub feed, so each environment independently controls which shell build * its clients are offered. The environment IS the channel: * * - hosted `dev` deployment → `dev` (per-push prerelease builds from `dev`) @@ -24,11 +24,21 @@ * stable prod-identity artifact to a dev shell would offer an update * Squirrel.Mac cannot apply (bundle-id mismatch) — each channel only ever * moves forward on its own artifacts. + * + * Platforms are published independently: every release carries the macOS + * build, while the Windows build is signed and uploaded by its own job and may + * be absent. A release without the Windows manifest is simply not a Windows + * release, so Windows shells keep the newest release that has one. */ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { DesktopUpdatePlatform } from '@/lib/api/contracts/desktop-update' import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' import { compareVersions } from '@/lib/desktop/min-version' +const logger = createLogger('DesktopUpdateFeed') + export const DESKTOP_STABLE_RELEASE_REPOSITORY = 'simstudioai/sim' export const DESKTOP_PRERELEASE_REPOSITORY = 'simstudioai/sim-desktop-releases' @@ -39,18 +49,14 @@ export type DesktopReleaseRepository = export type DesktopUpdateChannel = 'dev' | 'staging' | 'latest' /** Maps Sim's server-controlled deployment environment to its update channel. */ -export function channelForDeploymentEnvironment( - environment: string | undefined -): DesktopUpdateChannel { +function channelForDeploymentEnvironment(environment: string | undefined): DesktopUpdateChannel { if (environment === 'dev') return 'dev' if (environment === 'staging') return 'staging' return 'latest' } /** Keeps stable and prerelease release storage isolated by channel. */ -export function releaseRepositoryForChannel( - channel: DesktopUpdateChannel -): DesktopReleaseRepository { +function releaseRepositoryForChannel(channel: DesktopUpdateChannel): DesktopReleaseRepository { return channel === 'latest' ? DESKTOP_STABLE_RELEASE_REPOSITORY : DESKTOP_PRERELEASE_REPOSITORY } @@ -61,13 +67,39 @@ export function channelOfVersion(version: string): DesktopUpdateChannel { return 'latest' } -/** - * The manifest asset every desktop build uploads. electron-builder's GitHub - * provider always names it `latest-mac.yml` regardless of the version's - * prerelease tag (channels are a generic-provider concept); which channel a - * release belongs to is carried entirely by its tag. - */ -export const MANIFEST_ASSET_NAME = 'latest-mac.yml' +interface DesktopPlatformFeed { + /** + * The manifest asset a platform's build uploads. electron-builder's GitHub + * provider names it `latest-mac.yml` / `latest.yml` regardless of the + * version's prerelease tag (channels are a generic-provider concept); which + * channel a release belongs to is carried entirely by its tag. + */ + manifestAssetName: string + /** Every artifact the manifest may reference, most human-installable first. */ + artifactNames(version: string): readonly string[] + /** + * Whether every desktop release must carry this platform. A missing manifest + * then marks a broken release; otherwise the release just lacks the platform. + */ + inEveryRelease: boolean +} + +const PLATFORM_FEEDS: Record = { + mac: { + manifestAssetName: 'latest-mac.yml', + artifactNames: (version) => [`Sim-${version}-universal.dmg`, `Sim-${version}-universal.zip`], + inEveryRelease: true, + }, + windows: { + manifestAssetName: 'latest.yml', + // x64 only: a manifest naming any other architecture is not one this feed ships. + artifactNames: (version) => [`Sim-${version}-x64.exe`], + inEveryRelease: false, + }, +} + +export const MANIFEST_ASSET_NAME = PLATFORM_FEEDS.mac.manifestAssetName +export const WINDOWS_MANIFEST_ASSET_NAME = PLATFORM_FEEDS.windows.manifestAssetName export const MAX_DESKTOP_UPDATE_MANIFEST_BYTES = 256 * 1024 /** The subset of the GitHub releases API the feed needs. */ @@ -86,11 +118,19 @@ export interface DesktopReleaseCandidate { */ function releasesForChannel( releases: DesktopReleaseCandidate[], - channel: DesktopUpdateChannel + channel: DesktopUpdateChannel, + platform: DesktopUpdatePlatform ): DesktopReleaseCandidate[] { + const feed = PLATFORM_FEEDS[platform] const candidates: Array<{ release: DesktopReleaseCandidate; version: string }> = [] for (const release of releases) { if (release.draft) continue + if ( + !feed.inEveryRelease && + !release.assets?.some((asset) => asset.name === feed.manifestAssetName) + ) { + continue + } const version = release.tag_name.replace(/^v/, '') if (channelOfVersion(version) !== channel) continue // Defense in depth: a bare vX.Y.Z tag manually marked "pre-release" on @@ -106,9 +146,10 @@ function releasesForChannel( /** Picks the newest release that passes the channel and version checks. */ export function selectReleaseForChannel( releases: DesktopReleaseCandidate[], - channel: DesktopUpdateChannel + channel: DesktopUpdateChannel, + platform: DesktopUpdatePlatform ): DesktopReleaseCandidate | null { - return releasesForChannel(releases, channel)[0] ?? null + return releasesForChannel(releases, channel, platform)[0] ?? null } /** @@ -122,11 +163,12 @@ export function rewriteManifestUrls( manifest: string, tag: string, repository: DesktopReleaseRepository, - availableAssetNames: ReadonlySet + availableAssetNames: ReadonlySet, + platform: DesktopUpdatePlatform ): string | null { const base = `https://github.com/${repository}/releases/download/${tag}/` const version = tag.replace(/^v/, '') - const expectedNames = new Set([`Sim-${version}-universal.dmg`, `Sim-${version}-universal.zip`]) + const expectedNames = new Set(PLATFORM_FEEDS[platform].artifactNames(version)) let valid = true let hasUpdaterFile = false const rewritten = manifest.replace( @@ -174,13 +216,15 @@ export interface DesktopReleaseAssets { } /** Reads and validates the complete artifact set required to offer a release. */ -export async function resolveReleaseAssets( +async function resolveReleaseAssets( release: DesktopReleaseCandidate, repository: DesktopReleaseRepository, + platform: DesktopUpdatePlatform, fetchManifest: (url: string) => Promise ): Promise { - const manifestAsset = release.assets?.find((asset) => asset.name === MANIFEST_ASSET_NAME) - const installer = selectInstallerAsset(release, repository) + const manifestAssetName = PLATFORM_FEEDS[platform].manifestAssetName + const manifestAsset = release.assets?.find((asset) => asset.name === manifestAssetName) + const installer = selectInstallerAsset(release, repository, platform) if (!manifestAsset || !installer) return null try { @@ -194,7 +238,13 @@ export async function resolveReleaseAssets( if (/^version:\s*(\S+)\s*$/m.exec(source)?.[1] !== version) return null const availableAssetNames = new Set(release.assets?.map((asset) => asset.name)) - const manifest = rewriteManifestUrls(source, release.tag_name, repository, availableAssetNames) + const manifest = rewriteManifestUrls( + source, + release.tag_name, + repository, + availableAssetNames, + platform + ) return manifest ? { manifest, installer } : null } catch { return null @@ -219,8 +269,9 @@ export function releasesApiUrl(repository: DesktopReleaseRepository, page: numbe * through to the next version. `fetchPage` returning null remains fatal because * an unreadable page could hide a newer valid release. */ -export async function resolveLatestRelease( +async function resolveLatestRelease( channel: DesktopUpdateChannel, + platform: DesktopUpdatePlatform, fetchPage: (page: number) => Promise, resolveCandidate: (release: DesktopReleaseCandidate) => T | null | Promise ): Promise< @@ -232,7 +283,7 @@ export async function resolveLatestRelease( for (let page = 1; page <= MAX_DESKTOP_RELEASE_PAGES; page++) { const releases = await fetchPage(page) if (releases === null) return { error: 'fetch-failed' } - for (const release of releasesForChannel(releases, channel)) { + for (const release of releasesForChannel(releases, channel, platform)) { const value = await resolveCandidate(release) if (value !== null) return { release, value } rejectedCandidates = true @@ -244,25 +295,110 @@ export async function resolveLatestRelease( } /** - * The human-installable artifact of a release, preferred over the zip the - * updater consumes. Selected per-release rather than through GitHub's - * repository-wide "latest release", which the stable repository shares with - * web-app and SDK tags that carry no desktop artifact at all. + * The human-installable artifact of a release for a platform (on macOS, the + * dmg over the zip the updater consumes). Selected per-release rather than + * through GitHub's repository-wide "latest release", which the stable + * repository shares with web-app and SDK tags that carry no desktop artifact + * at all. */ export function selectInstallerAsset( release: DesktopReleaseCandidate, - repository: DesktopReleaseRepository + repository: DesktopReleaseRepository, + platform: DesktopUpdatePlatform ): { name: string; browser_download_url: string } | null { const assets = release.assets ?? [] const version = release.tag_name.replace(/^v/, '') - const dmgName = `Sim-${version}-universal.dmg` - const zipName = `Sim-${version}-universal.zip` - const asset = - assets.find((candidate) => candidate.name === dmgName) ?? - assets.find((candidate) => candidate.name === zipName) + const asset = PLATFORM_FEEDS[platform] + .artifactNames(version) + .map((name) => assets.find((candidate) => candidate.name === name)) + .find((candidate) => candidate !== undefined) if (!asset) return null return { name: asset.name, browser_download_url: `https://github.com/${repository}/releases/download/${release.tag_name}/${asset.name}`, } } + +/** How a deployment's feed resolved for one platform. */ +type DesktopFeedResolution = + | { status: 'release'; channel: DesktopUpdateChannel; value: T } + /** The channel has no release for the platform yet: an authoritative answer, not an outage. */ + | { status: 'no-release'; channel: DesktopUpdateChannel } + /** Releases exist, but none carries a complete, valid artifact set. */ + | { status: 'invalid'; channel: DesktopUpdateChannel } + /** GitHub could not be read, so a newer valid release may be hidden. */ + | { status: 'unavailable'; channel: DesktopUpdateChannel } + +/** Seconds a resolved release (and the GitHub reads behind it) may be stale. */ +export const DESKTOP_FEED_REVALIDATE_SECONDS = 300 + +/** + * Resolves the newest complete release a deployment offers to one platform, + * reading GitHub through Next's fetch cache, and projects it with `select` + * (the rewritten manifest for the feed, the installer for the download + * redirect) so both routes always agree on the release. + * + * `environment` is the server-controlled deployment environment, never a + * request header: this public lookup must not let a caller pick which + * app-identity release it serves. `githubToken` only raises the API quota. + */ +export async function resolveDeploymentRelease(options: { + environment: string | undefined + githubToken: string | undefined + platform: DesktopUpdatePlatform + select: (assets: DesktopReleaseAssets) => T +}): Promise> { + const { githubToken, platform, select } = options + const channel = channelForDeploymentEnvironment(options.environment) + const releaseRepository = releaseRepositoryForChannel(channel) + const resolved = await resolveLatestRelease( + channel, + platform, + async (page) => { + try { + const response = await fetch(releasesApiUrl(releaseRepository, page), { + headers: { + accept: 'application/vnd.github+json', + ...(githubToken ? { authorization: `Bearer ${githubToken}` } : {}), + }, + next: { revalidate: DESKTOP_FEED_REVALIDATE_SECONDS }, + }) + if (!response.ok) { + logger.error('GitHub releases lookup failed', { + status: response.status, + page, + channel, + releaseRepository, + }) + return null + } + return (await response.json()) as DesktopReleaseCandidate[] + } catch (error) { + logger.error('GitHub releases response could not be read', { + message: getErrorMessage(error), + page, + channel, + releaseRepository, + }) + return null + } + }, + async (release) => { + const assets = await resolveReleaseAssets(release, releaseRepository, platform, (url) => + fetch(url, { next: { revalidate: DESKTOP_FEED_REVALIDATE_SECONDS } }) + ) + if (!assets) { + logger.warn('Skipping incomplete or invalid desktop release', { + tag: release.tag_name, + channel, + platform, + }) + return null + } + return select(assets) + } + ) + if ('error' in resolved) return { status: 'unavailable', channel } + if (resolved.release) return { status: 'release', channel, value: resolved.value } + return { status: resolved.rejectedCandidates ? 'invalid' : 'no-release', channel } +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 1afbb402217..e104d90588c 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -66,12 +66,11 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/demo-requests/route.ts', // Input-less session-bound GET: nothing to validate; response is // contract-typed via `satisfies InvitationDetails` in the route. - // Public updater feed: input-less GET, session-less, returns YAML (not JSON), - // so it can't be JSON-contract-bound. Wrapped in withRouteHandler. + // Public updater feeds (macOS and Windows): input-less GETs, session-less, + // returning YAML (not JSON), so they can't be JSON-contract-bound. Wrapped in + // withRouteHandler. 'apps/sim/app/api/desktop/update/latest-mac.yml/route.ts', - // Public updater download redirect: input-less GET, session-less, whose only - // response is a 302 to a GitHub release asset. Wrapped in withRouteHandler. - 'apps/sim/app/api/desktop/update/download/route.ts', + 'apps/sim/app/api/desktop/update/latest.yml/route.ts', 'apps/sim/app/api/invitations/route.ts', 'apps/sim/app/api/logs/export/route.ts', 'apps/sim/app/api/tools/docusign/route.ts', From 95010d36bb0ff2116f8aee354ae2cc44c180ab51 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:32:56 -0700 Subject: [PATCH 09/67] feat(desktop): point sim-setup and the docs at the Windows build sim-setup desktop resolves the installer and feed for the host platform, and the desktop and self-hosting docs cover the Windows installer, requirements, endpoints, and the features Windows does not have yet. --- apps/docs/content/docs/desktop/index.mdx | 33 ++++++++++----- .../docs/platform/self-hosting/desktop.mdx | 27 +++++++------ packages/sim-setup/README.md | 6 +-- packages/sim-setup/src/desktop.ts | 40 ++++++++++++++----- 4 files changed, 70 insertions(+), 36 deletions(-) diff --git a/apps/docs/content/docs/desktop/index.mdx b/apps/docs/content/docs/desktop/index.mdx index 393f553975f..44842a28a8b 100644 --- a/apps/docs/content/docs/desktop/index.mdx +++ b/apps/docs/content/docs/desktop/index.mdx @@ -1,13 +1,13 @@ --- title: Sim Desktop -description: Install the macOS app and configure browser, terminal, local file, and available Mac app controls. +description: Install the macOS or Windows app and configure browser, terminal, local file, and available Mac app controls. --- import { Callout } from '@/components/ui/callout' import { Step, Steps } from '@/components/ui/steps' import { Image } from '@/components/ui/image' -Sim Desktop opens your Sim workspace in a macOS app. It adds a built-in browser, terminal sessions, local file tools, and native notifications. Available builds and deployments can also provide native Mac app control. Your account, workspaces, and workflows are shared with the web app. +Sim Desktop opens your Sim workspace in a macOS or Windows app. It adds a built-in browser, terminal sessions, local file tools, and native notifications. Available builds and deployments can also provide native Mac app control. Your account, workspaces, and workflows are shared with the web app. -universal.dmg` from [GitHub releases](https://github.com/simstudioai/sim/releases). +Use the web app on other platforms, including Windows on Arm. + +The download links redirect to the latest available desktop installer for that deployment. Results can be cached for five minutes. To install a specific version, download `Sim--universal.dmg` or `Sim--x64.exe` from [GitHub releases](https://github.com/simstudioai/sim/releases). You need outbound access to your Sim deployment and GitHub's release downloads. For a self-hosted deployment: - Use your own download endpoint, such as `https://sim.example.com/api/desktop/update/download`. - Allow the Sim server to reach both `api.github.com` and `github.com` for release lookup and manifests. Blocking either can make the download endpoint return `502`. -- Use a certificate trusted by macOS. The app rejects certificate errors without an override; install a private CA in the system trust store if required. +- Use a certificate trusted by the operating system. The app rejects certificate errors without an override; install a private CA in the system trust store if required. See [Desktop app on your deployment](/platform/self-hosting/desktop) for server URLs, update channels, and network troubleshooting. @@ -39,12 +44,14 @@ See [Desktop app on your deployment](/platform/self-hosting/desktop) for server -### Install in Applications +### Install the app + +On macOS, open the disk image and drag Sim to **Applications**. Launch it from there. -Open the disk image and drag Sim to **Applications**. Launch it from there. +On Windows, run the installer. It installs for your user account without an administrator prompt and opens Sim when it finishes. A new installer can show a Microsoft Defender SmartScreen warning until it builds download reputation; check that the publisher is Sim before choosing **More info → Run anyway**. - Automatic self-update requires a Developer ID build installed in Applications. Builds elsewhere use the manual installer update path. + On macOS, automatic self-update requires a Developer ID build installed in Applications. Builds elsewhere use the manual installer update path. @@ -68,6 +75,10 @@ The app has its own session. Signing out of the app does not sign you out of Sim ## Browser, terminal, and local files + + On Windows, Terminal, local file tools, native app control, and importing from other browsers are not available yet. The built-in browser, notifications, and updates work as on macOS. + + | Capability | Behavior | | --- | --- | | **Browser** | Open websites in tabs with their own sessions and saved passwords. Chat can use the sites you sign into here. | @@ -192,8 +203,8 @@ The app checks the deployment it is connected to for updates. It does not force | Installation | Update behavior | | --- | --- | -| Developer ID build in **Applications** | The app can replace itself. With **Automatically download updates** on, it downloads in the background and offers to restart. Choose **Later** to apply the downloaded update when you next quit. With the setting off, **Sim → Check for Updates…** offers a version and waits for **Download**. | -| Build outside **Applications**, or without Developer ID signing | The app offers an installer for you to download and install manually. | +| Windows installer, or a Developer ID build in **Applications** on macOS | The app can replace itself. With **Automatically download updates** on, it downloads in the background and offers to restart. Choose **Later** to apply the downloaded update when you next quit. With the setting off, **Sim → Check for Updates…** offers a version and waits for **Download**. | +| macOS build outside **Applications**, or without Developer ID signing | The app offers an installer for you to download and install manually. | If the deployment's update feed is unavailable, an eligible stable build falls back to Sim's public GitHub releases. Prerelease builds skip the check instead. See [Desktop app on your deployment](/platform/self-hosting/desktop#what-an-installed-app-does-when-the-feed-fails) for feed failures and release selection. diff --git a/apps/docs/content/docs/platform/self-hosting/desktop.mdx b/apps/docs/content/docs/platform/self-hosting/desktop.mdx index a05697e60b3..2a9b3bdcc7c 100644 --- a/apps/docs/content/docs/platform/self-hosting/desktop.mdx +++ b/apps/docs/content/docs/platform/self-hosting/desktop.mdx @@ -1,19 +1,19 @@ --- title: Desktop app on your deployment -description: Connect the macOS desktop app to a Sim deployment you operate +description: Connect the macOS or Windows desktop app to a Sim deployment you operate --- import { Callout } from '@/components/ui/callout' import { Step, Steps } from '@/components/ui/steps' -Install the official Sim desktop app and connect it to your deployment. You do not need a custom build to change servers. The app is currently macOS-only; use the web app on other platforms. See [Sim Desktop](/desktop) for general installation and use. +Install the official Sim desktop app and connect it to your deployment. You do not need a custom build to change servers. The app runs on macOS and on Windows (x64, Windows 10 version 1809 or later, or Windows 11); use the web app on other platforms. See [Sim Desktop](/desktop) for general installation and use. ## Server prerequisites [#requirements-for-the-server-url] - Use the origin your server serves, including scheme, hostname, and port. Paths are ignored. - HTTPS is required except for `localhost`, `127.0.0.1`, and `::1`, which allow HTTP for local testing. - Do not include credentials in the URL. -- Use a certificate trusted by macOS. +- Use a certificate trusted by the operating system. - Allow your Sim server to reach `api.github.com` and `github.com` for official installer and update lookup. @@ -39,21 +39,21 @@ The command reads Compose/Helm configuration, resolves the installer, and prints bunx sim-setup desktop --url https://sim.example.com --no-open ``` -`--url` skips discovery. Without the CLI, open `https:///api/desktop/update/download` in a browser. +`--url` skips discovery. The command resolves the installer for the machine it runs on. Without the CLI, open `https:///api/desktop/update/download` in a browser, adding `?platform=windows` for the Windows installer. ### Install the app -Open the `.dmg` and drag Sim to **Applications**. Official installers are signed and notarized by Sim. +On macOS, open the `.dmg` and drag Sim to **Applications**. On Windows, run the `.exe`; it installs for the current user. Official installers are signed by Sim, and macOS installers are also notarized. ### Connect to your server -Launch Sim, choose **Sim → Server…**, enter your deployment origin, and select **Connect**. The app relaunches against that server and saves the setting across updates. +Launch Sim, choose **Sim → Server…** (on Windows, **Server…** in the **Sim** menu), enter your deployment origin, and select **Connect**. The app relaunches against that server and saves the setting across updates. @@ -75,18 +75,19 @@ Window size, zoom, theme, notification preferences, tray, and launch-at-login se The **Can't connect** page reports a DNS, timeout, or TLS failure. Select **Change server** to open the picker with the current value prefilled. No filesystem change is needed to correct a typo. -A private or self-signed certificate must be trusted by macOS; otherwise the app shows **Connection isn't secure**. A correct URL does not bypass certificate validation. +A private or self-signed certificate must be trusted by the operating system; otherwise the app shows **Connection isn't secure**. A correct URL does not bypass certificate validation. ## Installer and update endpoints [#your-deployment-already-serves-the-installer] -Your deployment exposes two public endpoints: +Your deployment exposes these public endpoints: | Endpoint | Response | | --- | --- | -| `/api/desktop/update/download` | `302` to the newest official installer for the deployment channel. | -| `/api/desktop/update/latest-mac.yml` | `200` with a `text/yaml` update manifest. | +| `/api/desktop/update/download` | `302` to the newest official macOS installer for the deployment channel. Add `?platform=windows` for the Windows installer. | +| `/api/desktop/update/latest-mac.yml` | `200` with the macOS `text/yaml` update manifest. | +| `/api/desktop/update/latest.yml` | `200` with the Windows `text/yaml` update manifest. | -Both resolve official GitHub release assets and cache lookups for five minutes. They return `404` when the channel has no release and `502` when GitHub lookup fails or candidates lack valid desktop artifacts. +All resolve official GitHub release assets and cache lookups for five minutes. They return `404` when the channel has no release for the platform and `502` when GitHub lookup fails or candidates lack valid desktop artifacts. Windows builds are published separately from macOS builds, so the Windows endpoints can serve an older release than the macOS ones. The server serves release selection and the manifest; clients download installers from GitHub. These endpoints cannot distribute your custom artifacts. Set `GITHUB_TOKEN` on the Sim server if its shared egress IP reaches GitHub's unauthenticated 60-requests/hour limit; authenticated user-token requests typically allow 5,000/hour. See [GitHub rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api). @@ -112,7 +113,7 @@ A feed `404` with the no-release header means no update is available. For `502`, - A `dev` or `staging` build skips the check because the stable fallback cannot update its bundle identity. - Other builds receive no update offer until the deployment feed answers again. -Self-update requires a Developer ID build installed in **Applications**. Other packaged builds can offer manual downloads when the feed works. Release selection therefore follows your deployment only while its feed answers; an eligible stable app can use the fallback during an outage. +On macOS, self-update requires a Developer ID build installed in **Applications**; official Windows installs always self-update. Other packaged builds can offer manual downloads when the feed works. Release selection therefore follows your deployment only while its feed answers; an eligible stable app can use the fallback during an outage. ## CLI discovery and probe failures @@ -131,6 +132,8 @@ SIM_DESKTOP_DEFAULT_ORIGIN=https://sim.example.com bun run package:mac The default origin applies to fresh installs; the server picker remains available. The macOS target builds a universal binary. Output files are `apps/desktop/release/Sim--universal.dmg` and `.zip`. Pass `-c.appId=com.example.sim` to choose a bundle identifier. +On Windows, `bun run package:win` builds `apps/desktop/release/Sim--x64.exe`. Pass `-c.extraMetadata.name=` with a unique slug as well as `-c.appId`, because one-click Windows installs use the package name for their install directory. Sign it with your own Authenticode certificate. + The baked default determines the app's channel identity, userData directory, and single-instance lock. Loopback, `dev.sim.ai`, and `staging.sim.ai` defaults produce separate Local, Dev, and Staging app identities; other origins use the production Sim identity. A Local build can still be pointed at another HTTPS server, but changing servers does not turn it into a production build or change its update eligibility. `package:share` instead writes `apps/desktop/release//--.` and disables trusted timestamps. Use `package:mac` for notarized distribution. diff --git a/packages/sim-setup/README.md b/packages/sim-setup/README.md index 944115c75cc..0a20a992931 100644 --- a/packages/sim-setup/README.md +++ b/packages/sim-setup/README.md @@ -155,8 +155,8 @@ running and healthy?” ### Install the desktop app -`desktop` resolves the macOS installer from your own deployment and prints the -server URL to enter in the app: +`desktop` resolves the installer for your machine (macOS, or Windows x64) from +your own deployment and prints the server URL to enter in the app: ```bash npx sim-setup desktop @@ -167,7 +167,7 @@ npx sim-setup desktop --no-open The desktop app is not tied to sim.ai — it bakes in only a default server, and every Sim deployment already serves `/api/desktop/update/download` and the update feed installed apps poll. Install the signed build, then point it at your -deployment with **Sim → Server…**. Nothing has to be built or signed by you. +deployment with **Server…** in the Sim menu. Nothing has to be built or signed by you. ### Add or change capabilities diff --git a/packages/sim-setup/src/desktop.ts b/packages/sim-setup/src/desktop.ts index 60132f633ad..94099389bf2 100644 --- a/packages/sim-setup/src/desktop.ts +++ b/packages/sim-setup/src/desktop.ts @@ -10,12 +10,29 @@ import { APP_URL } from './urls' /** * Where a deployment redirects to the newest installer for its own channel, - * and the manifest installed shells poll. Both ship in every Sim deployment - * (`app/api/desktop/update/*`), so a self-hosted install already serves them — - * there is nothing to build or host. + * and the manifest installed shells poll, per desktop platform. Both ship in + * every Sim deployment (`app/api/desktop/update/*`), so a self-hosted install + * already serves them — there is nothing to build or host. */ -const DOWNLOAD_PATH = '/api/desktop/update/download' -const FEED_PATH = '/api/desktop/update/latest-mac.yml' +const DESKTOP_PLATFORMS = { + mac: { + downloadPath: '/api/desktop/update/download', + feedPath: '/api/desktop/update/latest-mac.yml', + serverCommand: 'Sim → Server…', + serverCommandWhere: 'in the menu bar', + }, + windows: { + downloadPath: '/api/desktop/update/download?platform=windows', + feedPath: '/api/desktop/update/latest.yml', + serverCommand: 'Server…', + serverCommandWhere: 'from the Sim menu', + }, +} as const + +/** The desktop build for the machine running the CLI; macOS unless it is Windows. */ +function desktopPlatformForHost(): (typeof DESKTOP_PLATFORMS)[keyof typeof DESKTOP_PLATFORMS] { + return process.platform === 'win32' ? DESKTOP_PLATFORMS.windows : DESKTOP_PLATFORMS.mac +} const PROBE_TIMEOUT_MS = 15_000 @@ -219,7 +236,8 @@ export async function runDesktop(flags: DesktopFlags): Promise { // values`, so it is seconds of blocking work — and every bit of it is // discarded when --url already names the deployment. const appUrl = resolveDeploymentUrl(flags.url ? [] : discoverConfigurationSources(), flags.url) - const downloadUrl = `${appUrl}${DOWNLOAD_PATH}` + const platform = desktopPlatformForHost() + const downloadUrl = `${appUrl}${platform.downloadPath}` p.log.step(`Deployment: ${theme.accent(appUrl)}`) @@ -227,7 +245,7 @@ export async function runDesktop(flags: DesktopFlags): Promise { spin.start('Resolving the desktop installer…') const [probe, feedOk] = await Promise.all([ probeDownload(downloadUrl), - httpHealth(`${appUrl}${FEED_PATH}`, PROBE_TIMEOUT_MS), + httpHealth(`${appUrl}${platform.feedPath}`, PROBE_TIMEOUT_MS), ]) const { headline, hints } = describeProbe(probe, appUrl) spin.stop(headline) @@ -242,7 +260,7 @@ export async function runDesktop(flags: DesktopFlags): Promise { if (!feedOk) { p.log.warn( - `${FEED_PATH} did not resolve — the app will install but will not auto-update from this deployment.` + `${platform.feedPath} did not resolve — the app will install but will not auto-update from this deployment.` ) } @@ -251,13 +269,15 @@ export async function runDesktop(flags: DesktopFlags): Promise { `1. Download and install Sim:`, ` ${theme.accent(downloadUrl)}`, '', - `2. Open Sim, then choose ${theme.command('Sim → Server…')} in the menu bar.`, + `2. Open Sim, then choose ${theme.command(platform.serverCommand)} ${platform.serverCommandWhere}.`, '', `3. Enter your server URL and press Connect:`, ` ${theme.accent(appUrl)}`, '', theme.muted('Sim relaunches against your deployment and updates from it from then on.'), - theme.muted('The desktop app is macOS-only today; the web app works everywhere.'), + theme.muted( + 'The desktop app runs on macOS and Windows (x64, 10 1809 or later); the web app works everywhere.' + ), ].join('\n'), 'Connect the desktop app' ) From 50a1ce7a3487956b8cc2bf201924fdeb69ea825a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:34:50 -0700 Subject: [PATCH 10/67] feat(desktop): run the packaged smoke suite on Windows Windows has no process groups, so teardown kills the app's tree with taskkill /T before removal and retries removal while files unlock. --- apps/desktop/e2e/packaged-smoke.spec.ts | 37 +++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/desktop/e2e/packaged-smoke.spec.ts b/apps/desktop/e2e/packaged-smoke.spec.ts index 559db75d3ea..4ce89f3a72a 100644 --- a/apps/desktop/e2e/packaged-smoke.spec.ts +++ b/apps/desktop/e2e/packaged-smoke.spec.ts @@ -45,12 +45,43 @@ function profileWriters(appGroup: number, profilePaths: readonly string[]): numb }) } +const isWindows = process.platform === 'win32' + +/** + * Windows has no process groups. `taskkill /T` walks the tree from the main process — helpers and + * Crashpad's handler are its descendants — so it must run while the main process is still alive. + * Files stay locked for a moment after their process dies, so removal retries until it succeeds. + */ +async function stopAndRemoveWindowsProfile(child: ChildProcess, userDataPath: string) { + if (child.pid !== undefined && child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit') + try { + execFileSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' }) + } catch {} + await exited + } + await expect + .poll(() => { + try { + rmSync(userDataPath, { recursive: true, force: true }) + return true + } catch { + return false + } + }) + .toBe(true) +} + /** * Removes a profile only once nothing can write into it. Killing the main process leaves its * helpers and Crashpad's handler running for a moment, and a file one of them creates while the * directory is being removed fails the removal with ENOTEMPTY. */ async function stopAndRemoveProfile(child: ChildProcess, userDataPath: string): Promise { + if (isWindows) { + await stopAndRemoveWindowsProfile(child, userDataPath) + return + } if (child.exitCode === null && child.signalCode === null) { const exited = once(child, 'exit') child.kill('SIGKILL') @@ -95,7 +126,8 @@ test('packaged main process starts and records launch telemetry', async () => { if (!executablePath) throw new Error('SIM_DESKTOP_EXECUTABLE is required') const userDataPath = mkdtempSync(join(tmpdir(), 'sim-desktop-packaged-e2e-')) const child = spawn(executablePath, [], { - detached: true, + // Its own process group on POSIX, for profileWriters; on Windows it would only open a console. + detached: !isWindows, env: { ...process.env, SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:1', @@ -140,7 +172,8 @@ test('packaged shell renders the bundled offline page', async () => { // run on a machine that already has the real app's item) would block on a // Keychain prompt on its main thread, and the debugging endpoint with it. const child = spawn(executablePath, ['--remote-debugging-port=0', '--use-mock-keychain'], { - detached: true, + // Its own process group on POSIX, for profileWriters; on Windows it would only open a console. + detached: !isWindows, env: { ...process.env, SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:1', From 87cd48d5fffae74b65c2ff4456ea9dacbda902d8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:34:57 -0700 Subject: [PATCH 11/67] fix(desktop): validate Windows paths lexically before touching the filesystem A model-supplied native path is now resolved without any fs call on Windows: only drive-absolute and ~ paths pass, so a UNC path can no longer make the shell authenticate to a remote host before consent, and device, verbatim, drive-relative, and rooted paths are refused. Path segments in native paths and localfs URIs must be plain NTFS names (no streams, reserved devices, trailing dots or spaces, or invalid characters). File identity checks compare bigint dev/ino, the missing Windows O_NOFOLLOW is explicit, and the atomic store write retries a rename that Defender or the indexer briefly blocks. --- .../desktop/src/main/atomic-json-file.test.ts | 49 ++++++++++++- apps/desktop/src/main/atomic-json-file.ts | 40 ++++++++++- .../src/main/local-file-permissions.test.ts | 46 +++++++++++++ .../src/main/local-file-permissions.ts | 12 +++- apps/desktop/src/main/local-files.ts | 9 ++- .../desktop/src/main/local-filesystem.test.ts | 22 ++++++ apps/desktop/src/main/local-filesystem.ts | 22 ++++-- apps/desktop/src/main/win32-path.ts | 68 +++++++++++++++++++ 8 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/main/local-file-permissions.test.ts create mode 100644 apps/desktop/src/main/win32-path.ts diff --git a/apps/desktop/src/main/atomic-json-file.test.ts b/apps/desktop/src/main/atomic-json-file.test.ts index b95236083ab..6a946bbdc9e 100644 --- a/apps/desktop/src/main/atomic-json-file.test.ts +++ b/apps/desktop/src/main/atomic-json-file.test.ts @@ -1,11 +1,12 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FileResourceLimitError, readFileWithinLimit, readFileWithinLimitSync, + renameReplacing, } from '@/main/atomic-json-file' describe('bounded file reads', () => { @@ -27,3 +28,49 @@ describe('bounded file reads', () => { expect(() => readFileWithinLimitSync(filePath, 8)).toThrow(FileResourceLimitError) }) }) + +describe('renameReplacing', () => { + const busy = (code: string) => Object.assign(new Error(code), { code }) + + function flakyRename(failures: number, code = 'EBUSY') { + let calls = 0 + const rename = vi.fn(async () => { + calls += 1 + if (calls <= failures) throw busy(code) + }) + return rename + } + + it.each(['EPERM', 'EBUSY', 'EACCES'])( + 'retries a Windows rename that fails with %s until the holder lets go', + async (code) => { + const rename = flakyRename(2, code) + await renameReplacing('from', 'to', { platform: 'win32', rename }) + expect(rename).toHaveBeenCalledTimes(3) + } + ) + + it('gives up on Windows after a bounded number of attempts', async () => { + const rename = flakyRename(Number.POSITIVE_INFINITY) + await expect(renameReplacing('from', 'to', { platform: 'win32', rename })).rejects.toThrow( + 'EBUSY' + ) + expect(rename).toHaveBeenCalledTimes(5) + }) + + it('does not retry an error a held file cannot cause', async () => { + const rename = flakyRename(1, 'ENOENT') + await expect(renameReplacing('from', 'to', { platform: 'win32', rename })).rejects.toThrow( + 'ENOENT' + ) + expect(rename).toHaveBeenCalledTimes(1) + }) + + it('does not retry on macOS, where these codes mean a real permission failure', async () => { + const rename = flakyRename(1, 'EACCES') + await expect(renameReplacing('from', 'to', { platform: 'darwin', rename })).rejects.toThrow( + 'EACCES' + ) + expect(rename).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/main/atomic-json-file.ts b/apps/desktop/src/main/atomic-json-file.ts index ff364e2dc40..814523183df 100644 --- a/apps/desktop/src/main/atomic-json-file.ts +++ b/apps/desktop/src/main/atomic-json-file.ts @@ -9,6 +9,8 @@ import { } from 'node:fs' import { mkdir, open, rename, unlink, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter } from '@sim/utils/retry' /** Owner-only, matching every store that keeps user data in userData. */ const FILE_MODE = 0o600 @@ -84,6 +86,42 @@ function temporaryPathFor(filePath: string): string { return `${filePath}.${process.pid}.${temporaryFileCounter}.tmp` } +/** Defender and the search indexer briefly hold files open on Windows, which fails the rename. */ +const WINDOWS_TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']) +const MAX_RENAME_ATTEMPTS = 5 + +interface RenameOptions { + platform?: NodeJS.Platform + rename?: (from: string, to: string) => Promise +} + +/** + * Renames over `to`, retrying a few times on Windows while another process + * holds the target. Other platforms and other errors fail on the first try. + */ +export async function renameReplacing( + from: string, + to: string, + { platform = process.platform, rename: renameFile = rename }: RenameOptions = {} +): Promise { + for (let attempt = 1; ; attempt++) { + try { + await renameFile(from, to) + return + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if ( + platform !== 'win32' || + attempt >= MAX_RENAME_ATTEMPTS || + !code || + !WINDOWS_TRANSIENT_RENAME_CODES.has(code) + ) + throw error + await sleep(backoffWithJitter(attempt, null, { baseMs: 20, maxMs: 200 })) + } + } +} + /** * Crash-safe JSON writes for the small encrypted stores in userData. * @@ -97,7 +135,7 @@ export async function writeJsonFileAtomically(filePath: string, value: unknown): await mkdir(dirname(filePath), { recursive: true }) const temporaryPath = temporaryPathFor(filePath) await writeFile(temporaryPath, JSON.stringify(value), { mode: FILE_MODE }) - await rename(temporaryPath, filePath) + await renameReplacing(temporaryPath, filePath) } /** diff --git a/apps/desktop/src/main/local-file-permissions.test.ts b/apps/desktop/src/main/local-file-permissions.test.ts new file mode 100644 index 00000000000..4403e1190bf --- /dev/null +++ b/apps/desktop/src/main/local-file-permissions.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { nativePath } from '@/main/local-file-permissions' + +const HOME = 'C:\\Users\\me' +const windowsPath = (value: string) => nativePath(value, 'win32', () => HOME) + +describe('native paths on Windows', () => { + it.each([ + ['a UNC share, which would authenticate to the host', '\\\\attacker\\share\\file.txt'], + ['a UNC share with forward slashes', '//attacker/share/file.txt'], + ['a \\\\?\\ verbatim path', '\\\\?\\C:\\Users\\me\\file.txt'], + ['a \\\\.\\ device path', '\\\\.\\pipe\\secret'], + ['a rooted path without a drive', '\\Users\\me\\file.txt'], + ['a POSIX-style rooted path', '/Users/me/file.txt'], + ['a drive-relative path', 'C:Users\\me'], + ['a UNC path after the home prefix', '~\\\\attacker\\share'], + ['a reserved device name', 'C:\\Users\\me\\CON'], + ['a reserved device name with an extension', 'C:\\Users\\me\\nul.txt'], + ['a numbered reserved device', 'C:\\Users\\me\\com1'], + ['an alternate data stream', 'C:\\Users\\me\\file.txt:hidden'], + ['a segment with a trailing dot', 'C:\\Users\\me\\file.'], + ['a segment with a trailing space', 'C:\\Users\\me\\folder \\file.txt'], + ['a wildcard', 'C:\\Users\\me\\*.txt'], + ['a NUL byte', 'C:\\Users\\me\\a\0b'], + ])('refuses %s', (_label, value) => { + expect(() => windowsPath(value)).toThrow() + }) + + it('resolves a drive-absolute path', () => { + expect(windowsPath('C:\\Users\\me\\notes\\..\\todo.txt')).toBe('C:\\Users\\me\\todo.txt') + expect(windowsPath('d:/Projects/sim')).toBe('d:\\Projects\\sim') + }) + + it('resolves ~ paths under the home directory', () => { + expect(windowsPath('~')).toBe(HOME) + expect(windowsPath('~\\Documents\\a.txt')).toBe('C:\\Users\\me\\Documents\\a.txt') + expect(windowsPath('~/Documents/a.txt')).toBe('C:\\Users\\me\\Documents\\a.txt') + }) + + it('cannot be walked off the drive with ..', () => { + expect(windowsPath('C:\\..\\..\\Windows')).toBe('C:\\Windows') + }) +}) diff --git a/apps/desktop/src/main/local-file-permissions.ts b/apps/desktop/src/main/local-file-permissions.ts index 9504f591327..2e4233b8798 100644 --- a/apps/desktop/src/main/local-file-permissions.ts +++ b/apps/desktop/src/main/local-file-permissions.ts @@ -7,6 +7,7 @@ import { showShellDialog } from '@/main/dialogs' import type { LocalFileAuthorization } from '@/main/local-files' import type { LocalFileAccess, LocalFilesystemService } from '@/main/local-filesystem' import { openNativeFile } from '@/main/native-directory' +import { resolveWin32NativePath } from '@/main/win32-path' const MAX_PENDING_REQUESTS = 32 @@ -25,9 +26,18 @@ interface PendingFolderDecision { decision: Promise } -function nativePath(value: unknown): string { +/** + * Lexically resolves a model-supplied path. Runs before any filesystem call, + * which on Windows is what keeps a UNC path from reaching the network. + */ +export function nativePath( + value: unknown, + platform: NodeJS.Platform = process.platform, + home: () => string = homedir +): string { if (typeof value !== 'string' || value.length > 4096 || value.includes('\0')) throw new Error('A native absolute path or ~/ path is required.') + if (platform === 'win32') return resolveWin32NativePath(value, home()) const path = value === '~' ? homedir() : value.startsWith('~/') ? join(homedir(), value.slice(2)) : value if (!isAbsolute(path)) throw new Error('Use an absolute path or ~/ path.') diff --git a/apps/desktop/src/main/local-files.ts b/apps/desktop/src/main/local-files.ts index 840dd6c10f5..45a6e6328e0 100644 --- a/apps/desktop/src/main/local-files.ts +++ b/apps/desktop/src/main/local-files.ts @@ -1,5 +1,7 @@ +import { fstat } from 'node:fs' import { lstat, stat } from 'node:fs/promises' import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { promisify } from 'node:util' import type { DesktopLocalFileEntry, DesktopLocalFileManifest, @@ -18,6 +20,8 @@ const CHUNK_BYTES = 8 * 1024 * 1024 const MAX_ENTRIES = 1000 const MAX_READ_BYTES = 64_000 +const statDescriptor = promisify(fstat) + export interface LocalFileAuthorization { toolName: string args: Record @@ -45,8 +49,9 @@ async function readApprovedDirectory(path: string, access: LocalFileAccess) { try { const listing = await readNativeDirectory(handle.fd, MAX_ENTRIES) const canonical = await access.resolve(path) - const current = await lstat(canonical) - const opened = await handle.stat() + // bigint: NTFS file IDs exceed 2^53, and rounded IDs could compare equal. + const current = await lstat(canonical, { bigint: true }) + const opened = await statDescriptor(handle.fd, { bigint: true }) if (canonical !== path || current.dev !== opened.dev || current.ino !== opened.ino) throw new Error('The local directory changed while it was being read. Try again.') listing.entries.sort((left, right) => compareStrings(left.name, right.name)) diff --git a/apps/desktop/src/main/local-filesystem.test.ts b/apps/desktop/src/main/local-filesystem.test.ts index 81a1bbc2c6e..e62f8677bb3 100644 --- a/apps/desktop/src/main/local-filesystem.test.ts +++ b/apps/desktop/src/main/local-filesystem.test.ts @@ -413,6 +413,28 @@ describe('LocalFilesystemService', () => { } ) + it.each([ + ['an alternate data stream', 'README.md%3Ahidden'], + ['a reserved device name', 'CON'], + ['a reserved device name with an extension', 'src/nul.txt'], + ['a trailing dot Windows would strip', 'README.md.'], + ['a trailing space Windows would strip', 'README.md%20'], + ['a wildcard', 'src/%2A.ts'], + ['a pipe', 'a%7Cb'], + ['a quote', 'a%22b'], + ['an angle bracket', 'a%3Cb'], + ])('refuses %s in a URI on Windows', async (_label, path) => { + const windowsService = new LocalFilesystemService({ + chooseDirectory: async () => root, + platform: 'win32', + }) + const granted = await mount(windowsService) + + expect( + await windowsService.handle({ operation: 'read', uri: `${granted.uri}${path}` }) + ).toMatchObject({ ok: false, code: 'INVALID_URI' }) + }) + it('rejects lexical traversal before URL normalization can reinterpret it', async () => { const granted = await mount(service) const traversal = await service.handle({ diff --git a/apps/desktop/src/main/local-filesystem.ts b/apps/desktop/src/main/local-filesystem.ts index f823c60c315..c032f1fe5c3 100644 --- a/apps/desktop/src/main/local-filesystem.ts +++ b/apps/desktop/src/main/local-filesystem.ts @@ -38,6 +38,7 @@ import type { PersistedLocalFilesystemGrant, } from '@/main/local-filesystem-grant-store' import { openNativeFile } from '@/main/native-directory' +import { isWin32SafeSegment } from '@/main/win32-path' const MAX_URI_LENGTH = 4096 const MAX_LIST_ENTRIES = 500 @@ -114,6 +115,8 @@ interface LocalFilesystemServiceOptions { chooseDirectory?: () => Promise grantStore?: LocalFilesystemGrantStore startAccessingBookmark?: (bookmark: string) => (() => void) | undefined + /** Which platform's file-name rules URI segments must satisfy. Defaults to the host. */ + platform?: NodeJS.Platform } interface SelectedDirectory { @@ -364,10 +367,12 @@ export class LocalFilesystemService { private readonly chooseDirectory: () => Promise private readonly grantStore?: LocalFilesystemGrantStore private readonly startAccessingBookmark: (bookmark: string) => (() => void) | undefined + private readonly platform: NodeJS.Platform private initializePromise?: Promise constructor(options: LocalFilesystemServiceOptions = {}) { this.grantStore = options.grantStore + this.platform = options.platform ?? process.platform this.startAccessingBookmark = options.startAccessingBookmark ?? ((bookmark) => { @@ -976,15 +981,18 @@ export class LocalFilesystemService { const resolved = await this.resolveUri(uri) const handle = await open( resolved.realPath, - constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + // Windows has no O_NOFOLLOW (the constant is undefined, so this ORs in 0); + // the identity recheck below is what catches a swapped path there. + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | constants.O_NONBLOCK ) try { - const metadata = await handle.stat() + // bigint: NTFS file IDs exceed 2^53, and rounded IDs could compare equal. + const metadata = await handle.stat({ bigint: true }) if (!metadata.isFile()) { throw new LocalFilesystemError('NOT_A_FILE', 'The local path is not a file.') } const verified = await this.resolveUri(uri) - const currentMetadata = await lstat(verified.realPath) + const currentMetadata = await lstat(verified.realPath, { bigint: true }) if ( verified.realPath !== resolved.realPath || !currentMetadata.isFile() || @@ -996,13 +1004,14 @@ export class LocalFilesystemService { 'The local file changed while it was being opened. Try again.' ) } - if (metadata.size > maxBytes) { + const size = Number(metadata.size) + if (size > maxBytes) { throw new LocalFilesystemError( 'FILE_TOO_LARGE', `The local file exceeds the ${Math.floor(maxBytes / 1024 / 1024)} MB upload limit.` ) } - return { handle, name: basename(resolved.realPath), size: metadata.size } + return { handle, name: basename(resolved.realPath), size } } catch (error) { await handle.close() throw error @@ -1075,7 +1084,8 @@ export class LocalFilesystemService { decoded === '..' || decoded.includes('/') || (sep === '\\' && decoded.includes('\\')) || - decoded.includes('\0') + decoded.includes('\0') || + (this.platform === 'win32' && !isWin32SafeSegment(decoded)) ) { throw new LocalFilesystemError('INVALID_URI', 'The localfs URI is invalid.') } diff --git a/apps/desktop/src/main/win32-path.ts b/apps/desktop/src/main/win32-path.ts new file mode 100644 index 00000000000..45a816e4405 --- /dev/null +++ b/apps/desktop/src/main/win32-path.ts @@ -0,0 +1,68 @@ +import { win32 } from 'node:path' + +/** + * Windows treats these stems as devices in any directory and with any + * extension (`NUL.txt` is still NUL). The superscript digits are reserved too. + */ +const RESERVED_DEVICE_STEM = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³]|conin\$|conout\$)$/i + +/** `:` opens an alternate data stream; the rest are invalid in any NTFS name. */ +const INVALID_NAME_CHARACTERS = /[<>:"/\\|?*\x00-\x1f]/ +const INVALID_NAME_CHARACTERS_GLOBAL = /[<>:"/\\|?*\x00-\x1f]/g + +function isReservedDeviceName(name: string): boolean { + return RESERVED_DEVICE_STEM.test(name.split('.')[0].trimEnd()) +} + +/** + * Whether `segment` names exactly the file it spells as one Windows path + * component. Windows silently strips a trailing dot or space, reads `name:x` + * as a stream of `name`, and opens a reserved device name as the device, so + * each of those reaches something other than the file that was checked. + */ +export function isWin32SafeSegment(segment: string): boolean { + return ( + segment.length > 0 && + !INVALID_NAME_CHARACTERS.test(segment) && + !/[. ]$/.test(segment) && + !isReservedDeviceName(segment) + ) +} + +/** Rewrites an untrusted file name into one Windows stores under exactly that name. */ +export function toWin32SafeFileName(name: string): string { + const replaced = name.replace(INVALID_NAME_CHARACTERS_GLOBAL, '_').replace(/[. ]+$/, '') + return isReservedDeviceName(replaced) ? `_${replaced}` : replaced +} + +const DRIVE_ABSOLUTE = /^[A-Za-z]:[\\/]/ +const HOME_RELATIVE = /^~[\\/]/ + +/** + * Resolves a model-supplied Windows path without touching the filesystem. + * + * It runs before any `realpath` or `stat`, because opening a UNC path + * (`\\host\share`) makes Windows authenticate to that host and hands it the + * user's NTLM hash before any consent dialog. Only a drive-absolute path or a + * path under `~` is accepted, so UNC, `\\?\` and `\\.\` device paths, + * drive-relative `C:foo`, and rooted `\foo` (relative to the current drive) + * are all refused. UNC shares are refused outright, even inside a granted + * share, until grants can be matched lexically. Each segment must also pass + * {@link isWin32SafeSegment}. + */ +export function resolveWin32NativePath(value: string, home: string): string { + const homeRelative = value === '~' ? '' : HOME_RELATIVE.test(value) ? value.slice(2) : null + if (homeRelative === null && !DRIVE_ABSOLUTE.test(value)) + throw new Error('Use a drive path such as C:\\Users\\name\\file or a ~\\ path.') + if (homeRelative !== null && /^[\\/]/.test(homeRelative)) + throw new Error('Use a drive path such as C:\\Users\\name\\file or a ~\\ path.') + const segments = (homeRelative ?? value.slice(3)).split(/[\\/]/) + for (const segment of segments) { + if (segment === '' || segment === '.' || segment === '..') continue + if (!isWin32SafeSegment(segment)) + throw new Error(`"${segment}" is not a plain Windows file or folder name.`) + } + const resolved = homeRelative === null ? win32.resolve(value) : win32.resolve(home, homeRelative) + if (!DRIVE_ABSOLUTE.test(resolved)) throw new Error('Use a path on a local drive.') + return resolved +} From 57fb0f40c2ce73c605c31d566d95ffd51c5c5ed0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:37:40 -0700 Subject: [PATCH 12/67] ci(desktop): build, sign, and publish the Windows installer - desktop-release takes a platform input. The new build-windows job resolves the same channel identity (plus the package-name slug), signs through Azure Artifact Signing after an OIDC azure/login, and validates the exact Sim--x64.exe, its blockmap, latest.yml (version, url, path, sha512), each channel's updater cache, Authenticode on the installer and app exe, and the publisherName pinned in app-update.yml. Then it runs the packaged smoke and uploads with the manifest last. - ci: a separate Azure signing probe gates the Windows stable and prerelease calls, which skip with a warning until configured. Nothing waits on them, so macOS publication never depends on the Windows build. - desktop-e2e: a windows-latest leg runs the desktop unit suite and the packaged smoke against an unsigned unpacked x64 build. --- .github/workflows/ci.yml | 74 +++++ .github/workflows/desktop-e2e.yml | 69 +++++ .github/workflows/desktop-release.yml | 388 +++++++++++++++++++++++++- 3 files changed, 528 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75277ba3f97..a0fd271d152 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1275,6 +1275,55 @@ jobs: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Windows desktop builds are signed with Azure Artifact Signing over GitHub + # OIDC. Gated like the macOS build: until the Azure identity and signing + # variables exist, the Windows build skips with a warning, and nothing is + # ever published unsigned. Windows jobs are separate workflow calls that no + # other job needs, so a Windows failure never blocks a macOS release; the + # update feed treats a release without latest.yml as having no Windows build. + desktop-signing-windows: + name: desktop-signing-windows + runs-on: *runner-2vcpu + timeout-minutes: 2 + needs: [version, desktop-changes] + if: ${{ !cancelled() && (needs.version.outputs.is_release == 'true' || needs.desktop-changes.outputs.changed == 'true') }} + outputs: + configured: ${{ steps.check.outputs.configured }} + steps: + - name: Probe Azure signing configuration + id: check + env: + CONFIGURED: ${{ secrets.AZURE_CLIENT_ID != '' && secrets.AZURE_TENANT_ID != '' && secrets.AZURE_SUBSCRIPTION_ID != '' && vars.AZURE_SIGNING_ENDPOINT != '' && vars.AZURE_SIGNING_ACCOUNT != '' && vars.AZURE_SIGNING_PROFILE != '' && vars.AZURE_SIGNING_PUBLISHER != '' }} + run: | + echo "configured=${CONFIGURED}" >> "$GITHUB_OUTPUT" + if [ "$CONFIGURED" != "true" ]; then + echo "::warning::Windows desktop release skipped: Azure signing is not configured (secrets AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID; variables AZURE_SIGNING_ENDPOINT, AZURE_SIGNING_ACCOUNT, AZURE_SIGNING_PROFILE, AZURE_SIGNING_PUBLISHER)." + fi + + desktop-release-windows: + name: desktop-release-windows + needs: [release, desktop-signing-windows, version] + # See desktop-release for why the implicit success() check is suppressed. + if: >- + !cancelled() && + needs.release.result == 'success' && + needs.desktop-signing-windows.result == 'success' && + needs.version.result == 'success' && + needs.desktop-signing-windows.outputs.configured == 'true' + permissions: + contents: write + id-token: write + uses: ./.github/workflows/desktop-release.yml + with: + version: ${{ needs.version.outputs.version }} + publish: true + platform: windows + secrets: + DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + # Per-env desktop prereleases: a dev/staging push that touches shell code # publishes an environment-tagged GitHub prerelease to the public, # release-only simstudioai/sim-desktop-releases repository. Keeping these @@ -1374,6 +1423,31 @@ jobs: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Attaches the Windows build to the same prerelease, before or after + # prerelease-publish: publication waits only for the macOS build. + prerelease-build-windows: + name: prerelease-build-windows + needs: [prerelease-create, desktop-signing-windows] + if: >- + !cancelled() && + needs.prerelease-create.result == 'success' && + needs.desktop-signing-windows.result == 'success' && + needs.desktop-signing-windows.outputs.configured == 'true' + permissions: + contents: write + id-token: write + uses: ./.github/workflows/desktop-release.yml + with: + version: ${{ needs.prerelease-create.outputs.version }} + publish: true + sign: true + platform: windows + secrets: + DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + # The draft only becomes visible to the update feed once its artifacts are # attached — this is what makes a dev/staging push atomic from the shell's # point of view. diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index b34719e1bb1..bc4a0a6480c 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -260,3 +260,72 @@ jobs: exit 1 fi SIM_DESKTOP_EXECUTABLE="$EXECUTABLE" bunx playwright test e2e/packaged-smoke.spec.ts + + # The Windows leg: the unit suite on Windows, then an unsigned unpacked x64 + # build through the same packaged smoke suite (fuses, launch telemetry, and + # the bundled offline page). + package-smoke-windows: + name: package-smoke-windows + if: github.event_name != 'schedule' + runs-on: windows-latest + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.4 + + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: 22 + + - name: Cache Electron downloads + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 + with: + path: | + ~/AppData/Local/electron/Cache + ~/AppData/Local/electron-builder/Cache + key: desktop-downloads-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: desktop-downloads-${{ runner.os }}- + + - name: Install dependencies + env: + HUSKY: '0' + run: bun install --frozen-lockfile + + - name: Run desktop unit tests + working-directory: apps/desktop + run: bun run test + + - name: Bundle and package unsigned + working-directory: apps/desktop + run: | + bun run build + bunx electron-builder --win dir --x64 --publish never -c.win.signExecutable=false + + - name: Run packaged Electron smoke suite + working-directory: apps/desktop + run: | + EXECUTABLE=release/win-unpacked/Sim.exe + if [ ! -f "$EXECUTABLE" ]; then + echo "::error::Packaged app executable was not found." + exit 1 + fi + SIM_DESKTOP_EXECUTABLE="$EXECUTABLE" bunx playwright test e2e/packaged-smoke.spec.ts + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: desktop-package-smoke-windows-results + path: apps/desktop/test-results + retention-days: 7 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index f8d7da153c0..0d51ea60d82 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -5,6 +5,10 @@ name: desktop-release # releases live in simstudioai/sim-desktop-releases. Ordering is load-bearing: # scripts/create-single-release.ts skips creation when the stable tag already # exists, so this workflow must never create a release itself. +# +# One call builds one platform. Callers run macOS and Windows as separate calls +# so a Windows failure never holds back the macOS release: the update feed +# treats a release without latest.yml as having no Windows build. on: workflow_call: @@ -19,11 +23,16 @@ on: type: boolean default: true sign: - description: Sign and notarize with the Apple Developer identity. Unsigned - builds are workflow artifacts only and cannot be published. + description: Sign (and on macOS notarize) the build. Unsigned builds are + workflow artifacts only and cannot be published. required: false type: boolean default: true + platform: + description: Desktop platform to build (mac or windows) + required: false + type: string + default: mac secrets: DESKTOP_RELEASE_TOKEN: required: false @@ -39,6 +48,12 @@ on: required: false APPLE_TEAM_ID: required: false + AZURE_CLIENT_ID: + required: false + AZURE_TENANT_ID: + required: false + AZURE_SUBSCRIPTION_ID: + required: false workflow_dispatch: inputs: version: @@ -51,10 +66,16 @@ on: type: boolean default: false sign: - description: Sign and notarize with the Apple Developer identity + description: Sign (and on macOS notarize) the build required: false type: boolean default: true + platform: + description: Desktop platform to build + required: false + type: choice + options: [mac, windows] + default: mac permissions: contents: write @@ -62,6 +83,7 @@ permissions: jobs: build: name: build + if: ${{ inputs.platform != 'windows' }} runs-on: macos-26 timeout-minutes: 60 steps: @@ -479,3 +501,363 @@ jobs: apps/desktop/release/*.blockmap apps/desktop/release/latest-mac.yml retention-days: 7 + + # Windows: a per-user NSIS installer for x64, Authenticode-signed through + # Azure Artifact Signing. GitHub OIDC logs in to Azure, so no signing secret + # is stored here; the signing account and profile come from repository + # variables and are passed as -c flags. + build-windows: + name: build-windows + if: ${{ inputs.platform == 'windows' }} + runs-on: windows-latest + timeout-minutes: 60 + permissions: + contents: write + id-token: write + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.sha }} + + # Mirrors the macOS job's channel identity. SLUG becomes the package name + # (-c.extraMetadata.name): one-click NSIS installs derive their install + # directory and updater cache from it, so each channel needs its own. + - name: Resolve channel identity + id: channel + env: + VERSION: ${{ inputs.version }} + run: | + case "$VERSION" in + *-dev.*|*-alpha.*) + NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; SLUG=sim-dev; ORIGIN=https://www.dev.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;; + *-staging.*|*-beta.*) + NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; SLUG=sim-staging; ORIGIN=https://www.staging.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;; + *) + NAME='Sim'; APP_ID=ai.sim.desktop; SLUG=sim; ORIGIN=''; RELEASE_REPOSITORY="$GITHUB_REPOSITORY"; TOKEN_KIND=stable ;; + esac + { + echo "name=$NAME" + echo "app_id=$APP_ID" + echo "slug=$SLUG" + echo "origin=$ORIGIN" + echo "release_repository=$RELEASE_REPOSITORY" + echo "token_kind=$TOKEN_KIND" + } >> "$GITHUB_OUTPUT" + echo "Building $NAME ($APP_ID, $SLUG) for $RELEASE_REPOSITORY; default origin: ${ORIGIN:-production}" + + - name: Validate release source + env: + PUBLISH: ${{ inputs.publish }} + SIGN: ${{ inputs.sign }} + TOKEN_KIND: ${{ steps.channel.outputs.token_kind }} + VERSION: ${{ inputs.version }} + AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ vars.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_PROFILE: ${{ vars.AZURE_SIGNING_PROFILE }} + AZURE_SIGNING_PUBLISHER: ${{ vars.AZURE_SIGNING_PUBLISHER }} + run: | + if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Refusing to build: '$VERSION' is not a vX.Y.Z release tag." + exit 1 + fi + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] && [ "$TOKEN_KIND" != stable ]; then + echo "::error::Manual desktop releases must use a stable source-repository tag." + exit 1 + fi + if [ "$PUBLISH" = true ] && [ "$SIGN" != true ]; then + echo "::error::Desktop releases must be signed before publication." + exit 1 + fi + if [ "$SIGN" = true ]; then + for NAME in AZURE_SIGNING_ENDPOINT AZURE_SIGNING_ACCOUNT AZURE_SIGNING_PROFILE AZURE_SIGNING_PUBLISHER; do + if [ -z "${!NAME}" ]; then + echo "::error::Repository variable $NAME is required to sign the Windows build." + exit 1 + fi + done + fi + if [ "$TOKEN_KIND" = stable ]; then + TAG_COMMIT="$(git rev-parse "refs/tags/${VERSION}^{commit}")" + HEAD_COMMIT="$(git rev-parse HEAD)" + if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then + echo "::error::Requested tag $VERSION points to $TAG_COMMIT, but the checkout is $HEAD_COMMIT." + exit 1 + fi + fi + + - name: Validate release authentication + if: ${{ inputs.publish }} + env: + DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }} + RELEASE_REPOSITORY: ${{ steps.channel.outputs.release_repository }} + TOKEN_KIND: ${{ steps.channel.outputs.token_kind }} + run: | + if [ "$TOKEN_KIND" = prerelease ] && [ -z "$DESKTOP_RELEASE_TOKEN" ]; then + echo "::error::DESKTOP_RELEASE_TOKEN is required to publish prereleases to $RELEASE_REPOSITORY." + exit 1 + fi + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.4 + + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: 22 + + - name: Cache Electron binaries + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 + with: + path: | + ~/AppData/Local/electron/Cache + ~/AppData/Local/electron-builder/Cache + key: electron-cache-${{ runner.os }}-${{ hashFiles('apps/desktop/package.json') }} + + - name: Install dependencies + env: + HUSKY: '0' + run: bun install --frozen-lockfile + + - name: Inject release version + env: + VERSION: ${{ inputs.version }} + run: | + SEMVER="${VERSION#v}" + cd apps/desktop + bun pm pkg set version="$SEMVER" + cd ../.. + INJECTED="$(node -p "require('./apps/desktop/package.json').version")" + if [ "$INJECTED" != "$SEMVER" ]; then + echo "Version injection mismatch: wanted $SEMVER got $INJECTED" >&2 + exit 1 + fi + + # Lint and type-check are platform-independent and already run in the + # macOS job; the unit suite runs here for the Windows code paths. + - name: Verify desktop source + run: bun run --cwd apps/desktop test + + - name: Bundle main and preload + working-directory: apps/desktop + env: + SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }} + run: bun run build + + - name: Log in to Azure + if: ${{ inputs.sign }} + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + # forceCodeSigning fails the build if any file that should be signed is + # not. publisherName also lands in resources/app-update.yml, where the + # installed app's updater checks each downloaded installer against it. + - name: Package and sign + if: ${{ inputs.sign }} + working-directory: apps/desktop + env: + PRODUCT_NAME: ${{ steps.channel.outputs.name }} + APP_ID: ${{ steps.channel.outputs.app_id }} + SLUG: ${{ steps.channel.outputs.slug }} + AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ vars.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_PROFILE: ${{ vars.AZURE_SIGNING_PROFILE }} + AZURE_SIGNING_PUBLISHER: ${{ vars.AZURE_SIGNING_PUBLISHER }} + run: > + bunx electron-builder --win --x64 --publish never + -c.productName="$PRODUCT_NAME" -c.appId="$APP_ID" -c.extraMetadata.name="$SLUG" + -c.win.azureSignOptions.publisherName="$AZURE_SIGNING_PUBLISHER" + -c.win.azureSignOptions.endpoint="$AZURE_SIGNING_ENDPOINT" + -c.win.azureSignOptions.codeSigningAccountName="$AZURE_SIGNING_ACCOUNT" + -c.win.azureSignOptions.certificateProfileName="$AZURE_SIGNING_PROFILE" + -c.forceCodeSigning=true + + - name: Log out of Azure + if: ${{ always() && inputs.sign }} + run: az logout || true + + # Unsigned artifact-only path for workflow testing; never published. + - name: Package unsigned + if: ${{ !inputs.sign }} + working-directory: apps/desktop + env: + PRODUCT_NAME: ${{ steps.channel.outputs.name }} + APP_ID: ${{ steps.channel.outputs.app_id }} + SLUG: ${{ steps.channel.outputs.slug }} + run: > + bunx electron-builder --win --x64 --publish never + -c.productName="$PRODUCT_NAME" -c.appId="$APP_ID" -c.extraMetadata.name="$SLUG" + -c.win.signExecutable=false + + - name: Validate packaged artifacts + env: + VERSION: ${{ inputs.version }} + PRODUCT_NAME: ${{ steps.channel.outputs.name }} + SLUG: ${{ steps.channel.outputs.slug }} + run: | + SEMVER="${VERSION#v}" + RELEASE_DIR=apps/desktop/release + INSTALLER="$RELEASE_DIR/Sim-${SEMVER}-x64.exe" + MANIFEST="$RELEASE_DIR/latest.yml" + for ARTIFACT in "$INSTALLER" "$INSTALLER.blockmap" "$MANIFEST" "$RELEASE_DIR/win-unpacked/$PRODUCT_NAME.exe"; do + if [ ! -f "$ARTIFACT" ]; then + echo "::error::Expected desktop artifact is missing: $ARTIFACT" + exit 1 + fi + done + if [ "$(find "$RELEASE_DIR" -maxdepth 1 \( -name '*.exe' -o -name '*.blockmap' -o -name '*.yml' \) ! -name builder-debug.yml ! -name builder-effective-config.yaml | wc -l | tr -d ' ')" != 3 ]; then + echo "::error::Unexpected package artifacts were produced; refusing a wildcard upload." + find "$RELEASE_DIR" -maxdepth 1 -type f -print + exit 1 + fi + if ! grep -Fxq "version: $SEMVER" "$MANIFEST"; then + echo "::error::Updater manifest version does not match $VERSION." + exit 1 + fi + URLS="$(sed -nE 's/^[[:space:]]*(-[[:space:]]*)?url:[[:space:]]*([^[:space:]]+)[[:space:]]*$/\2/p' "$MANIFEST")" + if [ "$URLS" != "Sim-${SEMVER}-x64.exe" ]; then + echo "::error::Updater manifest contains unexpected artifact URLs." + exit 1 + fi + if ! grep -Fxq "path: Sim-${SEMVER}-x64.exe" "$MANIFEST"; then + echo "::error::Updater manifest path does not reference the verified installer." + exit 1 + fi + DIGEST="$(node -e "process.stdout.write(require('crypto').createHash('sha512').update(require('fs').readFileSync(process.argv[1])).digest('base64'))" "$INSTALLER")" + if [ "$(grep -c "sha512: ${DIGEST}\$" "$MANIFEST")" -lt 1 ]; then + echo "::error::Updater manifest sha512 does not match the installer." + exit 1 + fi + # Each channel must keep its own updater cache (see Resolve channel identity). + APP_UPDATE="$RELEASE_DIR/win-unpacked/resources/app-update.yml" + if ! grep -Fxq "updaterCacheDirName: ${SLUG}-updater" "$APP_UPDATE"; then + echo "::error::app-update.yml does not carry the ${SLUG} updater cache." + exit 1 + fi + + # Without publisherName in app-update.yml, electron-updater skips the + # signature check on downloaded installers entirely. + - name: Validate signatures + if: ${{ inputs.sign }} + shell: pwsh + env: + VERSION: ${{ inputs.version }} + PRODUCT_NAME: ${{ steps.channel.outputs.name }} + AZURE_SIGNING_PUBLISHER: ${{ vars.AZURE_SIGNING_PUBLISHER }} + run: | + $ErrorActionPreference = 'Stop' + $semver = $env:VERSION -replace '^v', '' + $files = @( + "apps/desktop/release/Sim-$semver-x64.exe", + "apps/desktop/release/win-unpacked/$($env:PRODUCT_NAME).exe" + ) + foreach ($file in $files) { + $signature = Get-AuthenticodeSignature -FilePath $file + if ($signature.Status -ne 'Valid') { + Write-Output "::error::$file is not validly signed: $($signature.Status) $($signature.StatusMessage)" + exit 1 + } + $commonName = $signature.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) + if ($commonName -ne $env:AZURE_SIGNING_PUBLISHER) { + Write-Output "::error::$file is signed by '$commonName', not '$($env:AZURE_SIGNING_PUBLISHER)'." + exit 1 + } + } + $appUpdate = Get-Content -Raw 'apps/desktop/release/win-unpacked/resources/app-update.yml' + $publisherLine = '^\s*-\s*[''"]?' + [regex]::Escape($env:AZURE_SIGNING_PUBLISHER) + '[''"]?\s*$' + if ($appUpdate -notmatch '(?m)^publisherName:' -or $appUpdate -notmatch "(?m)$publisherLine") { + Write-Output "::error::app-update.yml does not pin the expected publisherName; updates would install unverified." + exit 1 + } + + - name: Run packaged Electron smoke suite + working-directory: apps/desktop + env: + PRODUCT_NAME: ${{ steps.channel.outputs.name }} + run: SIM_DESKTOP_EXECUTABLE="release/win-unpacked/$PRODUCT_NAME.exe" bunx playwright test e2e/packaged-smoke.spec.ts + + - name: Upload artifacts to the release + if: ${{ inputs.publish }} + env: + DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }} + RELEASE_REPOSITORY: ${{ steps.channel.outputs.release_repository }} + SOURCE_RELEASE_TOKEN: ${{ github.token }} + TOKEN_KIND: ${{ steps.channel.outputs.token_kind }} + VERSION: ${{ inputs.version }} + run: | + case "$TOKEN_KIND" in + prerelease) GH_TOKEN="$DESKTOP_RELEASE_TOKEN" ;; + stable) GH_TOKEN="$SOURCE_RELEASE_TOKEN" ;; + *) + echo "::error::Unknown desktop release token kind: $TOKEN_KIND" + exit 1 ;; + esac + if [ -z "$GH_TOKEN" ]; then + echo "::error::No GitHub token is available to publish to $RELEASE_REPOSITORY." + exit 1 + fi + export GH_TOKEN + if ! RELEASE_ID="$( + gh release view "$VERSION" --repo "$RELEASE_REPOSITORY" \ + --json databaseId --jq '.databaseId' + )"; then + echo "::error::Release $VERSION does not exist in $RELEASE_REPOSITORY." + exit 1 + fi + if ! [[ "$RELEASE_ID" =~ ^[0-9]+$ ]]; then + echo "::error::Release $VERSION returned an invalid database ID." + exit 1 + fi + RELEASE_JSON="$(gh api "repos/${RELEASE_REPOSITORY}/releases/${RELEASE_ID}")" + SEMVER="${VERSION#v}" + # latest.yml goes last: the feed only offers a Windows build once its + # manifest exists, so it must never point at a missing installer. + ARTIFACTS=( + "apps/desktop/release/Sim-${SEMVER}-x64.exe" + "apps/desktop/release/Sim-${SEMVER}-x64.exe.blockmap" + "apps/desktop/release/latest.yml" + ) + upload_or_verify() { + local ARTIFACT="$1" + local NAME SIZE DIGEST REMOTE REMOTE_SIZE REMOTE_DIGEST + NAME="$(basename "$ARTIFACT")" + SIZE="$(stat -c%s "$ARTIFACT")" + DIGEST="sha256:$(sha256sum "$ARTIFACT" | awk '{print $1}')" + REMOTE="$(jq -c --arg name "$NAME" '.assets[] | select(.name == $name)' <<< "$RELEASE_JSON")" + if [ -n "$REMOTE" ]; then + REMOTE_SIZE="$(jq -r '.size' <<< "$REMOTE")" + REMOTE_DIGEST="$(jq -r '.digest // empty' <<< "$REMOTE")" + if [ "$REMOTE_SIZE" != "$SIZE" ] || [ "$REMOTE_DIGEST" != "$DIGEST" ]; then + echo "::error::Existing release asset $NAME does not match this build." + exit 1 + fi + echo "Verified existing release asset $NAME; skipping upload." + return + fi + gh release upload "$VERSION" "$ARTIFACT" --repo "$RELEASE_REPOSITORY" + } + for ARTIFACT in "${ARTIFACTS[@]:0:2}"; do + upload_or_verify "$ARTIFACT" + done + upload_or_verify "${ARTIFACTS[2]}" + + - name: Upload artifacts to the workflow run + if: ${{ !inputs.publish }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: sim-desktop-windows-${{ inputs.version }} + path: | + apps/desktop/release/*.exe + apps/desktop/release/*.blockmap + apps/desktop/release/latest.yml + retention-days: 7 From 261bf2c94a89a5654964d0b7e8a74a0691d9e26b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:38:15 -0700 Subject: [PATCH 13/67] feat(desktop): document Windows packaging, signing, and updates --- apps/desktop/README.md | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 7f9415e3fdc..1823fea5551 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,4 +1,4 @@ -# Sim Desktop (macOS) +# Sim Desktop (macOS and Windows) A thin Electron shell around the hosted Sim web app. The renderer loads the configured origin (default `https://www.sim.ai` — the origin the server actually serves; the apex 301s there) as a normal top-level page in a bundled, pinned Chromium — rendering is identical to Chrome of that version on every machine. No UI is re-implemented and no server stack is bundled. @@ -194,13 +194,36 @@ The native `read_local_file` and `import_local_files` tools also accept absolute Approved native reads can return bounded text, directory listings, images, or PDFs to the chat. Approved imports transfer file bytes to Workspace Files. Electron revalidates every pending call before using a grant, including remembered grants, checks canonical containment and grant identity throughout the operation, and opens files with no-follow and descriptor identity checks. Directory enumeration uses `fdopendir` on the verified descriptor, so replacing a parent path cannot redirect the listing. Listings scan at most 1,001 entries and return up to 1,000 sorted names with an explicit truncation flag; the cap bounds both memory and filesystem work, rather than promising the globally first 1,000 names in an arbitrarily large directory. Imports reject truncated listings. A model or hosted renderer cannot answer the local consent dialog. These permissions govern the native file tools; the separately enabled terminal still runs with the user's OS privileges. +## Windows + +The Windows build is a one-click, per-user NSIS installer (`Sim--x64.exe`), x64 only for now (arm64 later). It installs under `%LOCALAPPDATA%\Programs` without an elevation prompt, and uninstalling keeps the user's data (`deleteAppDataOnUninstall: false`). Supported: Windows 10 1809+ and Windows 11. + +- Local: `bun run package:win` (installer) or `bun run package:win:dir` (unpacked app in `release/win-unpacked/`), on Windows. `scripts/build-native.ts` skips the macOS/Linux native modules there, and `scripts/ensure-pty-prebuilds.ts` fetches the `win32-x64` ConPTY prebuild. `install:local` and `package:share` are macOS-only. +- Channel isolation: one-click installs derive their install directory and updater cache from the package **name**, not `productName`, so CI passes `-c.extraMetadata.name=` (`sim`, `sim-staging`, `sim-dev`, `sim-local`) beside `-c.productName`/`-c.appId`. `-c.appId` must match the AppUserModelID the app sets at runtime, or toasts and taskbar pins split. Local builds without the slug install as the package name. +- Icons: `build/icon*.ico` (16–256 px) are committed, generated from the per-channel `static/dock-icon*.png` by `bun run scripts/generate-windows-icons.ts` (needs the workspace's hoisted `sharp`); `scripts/build.ts` copies the channel's one to the ignored `build/generated-icon.ico`. +- Signing: Azure Artifact Signing through `win.azureSignOptions`, authenticated by GitHub OIDC (`azure/login`); nothing in `electron-builder.yml` or the workflow names the account. `signExts: [".node"]` also signs the node-pty addons, and `-c.forceCodeSigning=true` fails the build on anything unsigned. `azureSignOptions.publisherName` is written into `resources/app-update.yml`; without it electron-updater **skips** signature verification of downloaded installers, so CI asserts it is present, and it must equal the certificate's subject CN exactly. Windows ASAR integrity is embedded in the exe by electron-builder, so the fuse policy carries over unchanged. +- Updates: the NSIS updater requests `latest.yml` from the same `/api/desktop/update` feed. `ready` is reached when electron-updater's own `update-downloaded` fires (after the publisher check); there is no native staging step. A confirmed restart runs the installer silently and relaunches. Builds without a packaged `app-update.yml` fall back to the manual-download pipeline. Deltas use `.exe.blockmap`. +- Publication: Windows publishes independently of macOS. `desktop-signing-windows` probes the Azure configuration and skips with a warning until it exists; unsigned Windows builds are never published. No job waits on the Windows calls, so a Windows failure never blocks the macOS release, and the feed treats a release without `latest.yml` as having no Windows build (Windows shells keep the newest release that has one). `latest.yml` is uploaded last. +- SmartScreen: a new signing identity starts without download reputation, so early installers can show "Windows protected your PC" until reputation builds. Keep the same certificate profile across releases. +- Not available on Windows yet: Terminal, native local-file tools, native Computer Use, and importing from other browsers. node-pty still ships but is not exercised. + +| Secret / variable | Kind | Contents | +|---|---|---| +| `AZURE_CLIENT_ID` | secret | App registration (or managed identity) client ID with a federated credential for this repository and the *Artifact Signing Certificate Profile Signer* role | +| `AZURE_TENANT_ID` | secret | Entra tenant ID | +| `AZURE_SUBSCRIPTION_ID` | secret | Subscription holding the signing account | +| `AZURE_SIGNING_ENDPOINT` | variable | Regional endpoint, e.g. `https://eus.codesigning.azure.net` | +| `AZURE_SIGNING_ACCOUNT` | variable | Artifact Signing account name | +| `AZURE_SIGNING_PROFILE` | variable | Certificate profile name | +| `AZURE_SIGNING_PUBLISHER` | variable | The certificate's subject CN, exactly (pinned in `app-update.yml`) | + ## Auto-update, channels, rollout, rollback - `electron-updater` reads the deployment's `/api/desktop/update` feed; production resolves stable releases from `simstudioai/sim`, while dev/staging resolve prereleases from `simstudioai/sim-desktop-releases`. Artifact downloads go directly to GitHub and deltas use `.zip.blockmap`. Sim validates every candidate before starting its download. Developer ID builds installed under `/Applications` use a prompt (Restart and update / Later; Later installs on quit); other packaged builds offer a validated installer download — never forced mid-session. A staged or offered update keeps being re-checked on the normal cadence, and a newer release replaces it, so a shell left running across several releases installs the latest build in one restart instead of the stale one followed by another prompt. - Streams: production follows stable `X.Y.Z` releases, dev follows `-dev.N`, and staging follows `-staging.N`. The feed still recognizes legacy `-alpha.N`/`-beta.N` releases during migration. -- Restart becomes available only after Squirrel confirms native staging. Replacing a staged update returns the UI to downloading; a failed transfer can retain the previous staged build, but a failure after the native feed is replaced requires a retry. Diagnostics record `update_downloaded` after native staging, `update_install` when an explicit restart is committed, and `update_install_result` on the next launch with the expected and installed versions. The staging checkpoint also covers updates installed on a normal quit. +- On macOS, restart becomes available only after Squirrel confirms native staging (Windows has no native step; see [Windows](#windows)). Replacing a staged update returns the UI to downloading; a failed transfer can retain the previous staged build, but a failure after the native feed is replaced requires a retry. Diagnostics record `update_downloaded` after native staging, `update_install` when an explicit restart is committed, and `update_install_result` on the next launch with the expected and installed versions. The staging checkpoint also covers updates installed on a normal quit. - `bun run test:e2e e2e/updater.spec.ts` exercises the real Electron process, MacUpdater, downloads, retries, and installation checkpoints. Native verification and bundle replacement are simulated. Set `DESKTOP_UPDATER_REPORT_PATH` to choose the JSON report path; by default it is included in Playwright's test results and uploaded by CI on failure. -- Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean. +- Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml` (and `latest.yml` for Windows), then raise as crash metrics stay clean. - Rollback: a pulled release must be superseded by a **higher** version — users on the broken build will not reinstall an equal one. (A blocked-versions kill-switch was removed as unwired dead code; reintroduce it in `updater.ts` if a remote config source ever exists to feed it.) - Ship the DMG and tell users to install to `/Applications` — App Translocation breaks Squirrel.Mac updates from quarantined paths. From 822f0483abf183906e11574739fef3b6e0ab09a9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:38:43 -0700 Subject: [PATCH 14/67] fix(desktop): keep the committed bridge contract snapshot Regenerating it inlines the bridge's relative imports (./local-files, ./title-bar, ./computer-use.generated), which the check cannot resolve. The platform changes are additive and pass the check against the existing snapshot. --- packages/desktop-bridge/contract-snapshot.ts | 355 ++++--------------- 1 file changed, 77 insertions(+), 278 deletions(-) diff --git a/packages/desktop-bridge/contract-snapshot.ts b/packages/desktop-bridge/contract-snapshot.ts index 92492ebb244..c52c10df42b 100644 --- a/packages/desktop-bridge/contract-snapshot.ts +++ b/packages/desktop-bridge/contract-snapshot.ts @@ -47,7 +47,6 @@ export const CURRENT_BROWSER_TOOL_NAMES = [ 'browser_list_tabs', 'browser_list_sessions', 'browser_list_downloads', - 'browser_save_download', 'browser_wait_for', 'browser_snapshot', 'browser_find', @@ -58,13 +57,11 @@ export const CURRENT_BROWSER_TOOL_NAMES = [ 'browser_click_at', 'browser_type', 'browser_fill_form', - 'browser_batch', 'browser_insert_text', 'browser_press_key', 'browser_scroll', 'browser_select_option', 'browser_set_checked', - 'browser_upload_file', 'browser_hover', 'browser_drag', 'browser_zoom', @@ -72,35 +69,28 @@ export const CURRENT_BROWSER_TOOL_NAMES = [ export type CurrentBrowserToolName = (typeof CURRENT_BROWSER_TOOL_NAMES)[number] -const RETIRED_BROWSER_TOOL_NAMES = ['browser_request_takeover'] as const +export const RETIRED_BROWSER_TOOL_NAMES = ['browser_request_takeover'] as const -const BROWSER_TOOL_NAMES = [...CURRENT_BROWSER_TOOL_NAMES, ...RETIRED_BROWSER_TOOL_NAMES] as const +export const BROWSER_TOOL_NAMES = [ + ...CURRENT_BROWSER_TOOL_NAMES, + ...RETIRED_BROWSER_TOOL_NAMES, +] as const export type BrowserToolName = (typeof BROWSER_TOOL_NAMES)[number] -const BROWSER_WAIT_FOR_DEFAULT_TIMEOUT_MS = 10_000 -const BROWSER_WAIT_FOR_MAX_TIMEOUT_MS = 120_000 -const BROWSER_WAIT_FOR_RENDERER_GRACE_MS = 15_000 +export const BROWSER_WAIT_FOR_DEFAULT_TIMEOUT_MS = 10_000 +export const BROWSER_WAIT_FOR_MAX_TIMEOUT_MS = 120_000 +export const BROWSER_WAIT_FOR_RENDERER_GRACE_MS = 15_000 export const BROWSER_TOOL_AUTHORIZATION_TIMEOUT_MS = 8_000 export const BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS = 60_000 export const BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS = BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS const BROWSER_RENDERER_TRANSPORT_GRACE_MS = 2_000 -const BROWSER_NAVIGATION_RENDERER_TIMEOUT_MS = +export const BROWSER_NAVIGATION_RENDERER_TIMEOUT_MS = BROWSER_TOOL_AUTHORIZATION_TIMEOUT_MS + BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS + BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS + BROWSER_RENDERER_TRANSPORT_GRACE_MS -/** - * The desktop exchanges browser-agent file bytes with the app here: POST streams a file named by - * a claimed `browser_upload_file` call, PUT stores a completed download for `browser_save_download`. - */ -export const BROWSER_FILE_TRANSFER_PATH = '/api/desktop/tool/file' -/** Per-file byte ceiling for browser uploads and saved downloads, enforced on both sides. */ -export const BROWSER_FILE_TRANSFER_MAX_BYTES = 100 * 1024 * 1024 -/** Files one `browser_upload_file` call may attach. */ -export const BROWSER_UPLOAD_MAX_FILES = 10 - /** * Normalizes the model-visible `browser_wait_for.timeoutMs` consistently in * the renderer and desktop main process. @@ -129,9 +119,6 @@ export function browserToolRendererTimeoutMs( case 'browser_reload': case 'browser_open_tab': case 'browser_switch_tab': - case 'browser_upload_file': - case 'browser_save_download': - case 'browser_batch': return BROWSER_NAVIGATION_RENDERER_TIMEOUT_MS case 'browser_wait_for': return ( @@ -144,7 +131,7 @@ export function browserToolRendererTimeoutMs( } } -const BROWSER_THEMES = ['system', 'light', 'dark'] as const +export const BROWSER_THEMES = ['system', 'light', 'dark'] as const /** Sim appearance preference mirrored into browser-tab media queries. */ export type BrowserTheme = (typeof BROWSER_THEMES)[number] @@ -165,46 +152,6 @@ export function isCurrentBrowserToolName(name: string): name is CurrentBrowserTo return CURRENT_BROWSER_TOOL_NAME_SET.has(name) } -/** - * Which browser tools only observe the page (read it, list it, wait on it) and never act on it. - * Repeating one cannot cause a side effect, and one can never collide with what the user is doing. - */ -export const BROWSER_TOOL_OBSERVES_ONLY = { - browser_navigate: false, - browser_open_url: false, - browser_go_back: false, - browser_go_forward: false, - browser_reload: false, - browser_open_tab: false, - browser_switch_tab: false, - browser_close_tab: false, - browser_list_tabs: true, - browser_list_sessions: true, - browser_list_downloads: true, - browser_save_download: false, - browser_wait_for: true, - browser_snapshot: true, - browser_find: true, - browser_read_text: true, - browser_screenshot: true, - browser_extract: true, - browser_click: false, - browser_click_at: false, - browser_type: false, - browser_fill_form: false, - browser_batch: false, - browser_insert_text: false, - browser_press_key: false, - browser_scroll: false, - browser_select_option: false, - browser_set_checked: false, - browser_upload_file: false, - browser_hover: false, - browser_drag: false, - browser_zoom: false, - browser_request_takeover: false, -} as const satisfies Readonly> - export function isBrowserTheme(value: unknown): value is BrowserTheme { return typeof value === 'string' && BROWSER_THEME_SET.has(value) } @@ -307,14 +254,6 @@ export interface BrowserPanelAction { /** Compatibility response for installed shells with the retired navigation gate. */ | 'respond-site-permission' | 'takeover-done' - /** - * Declares that this renderer shows page dialogs. Until it arrives, the - * shell keeps answering them itself, so an older renderer never leaves a - * page blocked on a dialog nobody can see. - */ - | 'enable-page-dialogs' - /** Answers {@link BrowserPageState.dialog}; `allowed` is Leave. */ - | 'respond-dialog' /** Absolute URL for `navigate` (typed into the panel's URL bar). */ url?: string /** Stable tab id for `switch-tab` and `close-tab`. */ @@ -327,18 +266,12 @@ export interface BrowserPanelAction { claim?: boolean /** Optional free-text instruction submitted with `takeover-done`. */ takeoverResponse?: string - /** Exact pending permission request or dialog being answered. */ + /** Exact pending permission request being answered. */ requestId?: string - /** User decision for a permission or dialog response. */ + /** User decision for a permission response. */ allowed?: boolean } -/** A leave-site decision awaiting the user's answer in browser chrome. */ -export interface BrowserPageDialog { - requestId: string - kind: 'beforeunload' -} - export type BrowserMediaDevice = 'microphone' | 'camera' /** One document-scoped media request awaiting an explicit user decision. */ @@ -373,8 +306,6 @@ export interface BrowserPageState { mediaPermissionRequest?: BrowserMediaPermissionRequest /** Legacy request from installed shells that still require a site-origin prompt. */ sitePermissionRequest?: BrowserSitePermissionRequest - /** Page dialog awaiting the user's answer. Optional for older shells. */ - dialog?: BrowserPageDialog } /** A recoverable top-level page problem rendered by Sim instead of a blank native view. */ @@ -523,6 +454,25 @@ import { truncate } from '@sim/utils/string' /** The single tool the model calls; what it does is in `operation`. */ export const TERMINAL_TOOL_NAME = 'terminal' +/** + * Names this surface used to expose, one tool per operation. Kept so rows in + * conversations recorded before the consolidation still render with a real + * title instead of a humanized tool name. + */ +export const LEGACY_TERMINAL_TOOL_NAMES = [ + 'terminal_run', + 'terminal_input', + 'terminal_read', + 'terminal_kill', + 'terminal_cwd', + 'terminal_list', + 'terminal_new', + 'terminal_switch', + 'terminal_close', +] as const + +export type LegacyTerminalToolName = (typeof LEGACY_TERMINAL_TOOL_NAMES)[number] + /** * What one `terminal` call does. * @@ -532,7 +482,7 @@ export const TERMINAL_TOOL_NAME = 'terminal' * inspects them rather than Sim mirroring them into the tab strip. `handoff` * gives the terminal to the user and waits. */ -const TERMINAL_OPERATIONS = [ +export const TERMINAL_OPERATIONS = [ 'run', 'read', 'input', @@ -585,17 +535,9 @@ export const MAX_CAPTURE_CHARS = 512_000 * `terminal_read`. Successive reads are also how it tells progress from a * stall — output that stops changing is a command waiting on input or wedged. */ -const DEFAULT_RUN_WAIT_MS = 30_000 +export const DEFAULT_RUN_WAIT_MS = 30_000 -const MAX_RUN_WAIT_MS = 120_000 - -/** How long one `terminal_run` holds the turn, from the `waitSeconds` the model asked for. */ -export function resolveRunWaitMs(waitSeconds: unknown): number { - const requested = Number(waitSeconds) - return Number.isFinite(requested) && requested > 0 - ? Math.min(requested * 1000, MAX_RUN_WAIT_MS) - : DEFAULT_RUN_WAIT_MS -} +export const MAX_RUN_WAIT_MS = 120_000 /** * How long output must be silent, with the cursor left mid-line, before the @@ -616,7 +558,7 @@ export const PROMPT_IDLE_MS = 2_500 export const MAX_INPUT_KEYS = 20 /** Control keys the agent may send to a running foreground process. */ -const TERMINAL_CONTROL_KEYS = [ +export const TERMINAL_CONTROL_KEYS = [ 'ctrl-c', 'ctrl-d', 'ctrl-z', @@ -637,7 +579,7 @@ export function isTerminalControlKey(value: unknown): value is TerminalControlKe return typeof value === 'string' && TERMINAL_CONTROL_KEY_SET.has(value) } -type TerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' +export type TerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' /** * Arguments for every operation, flattened into one object. @@ -676,8 +618,8 @@ export interface TerminalToolArgs { */ terminalId?: string /** - * Which tmux pane to act on, as a tmux target (`session:window.pane`, or a - * run's pane id `%N`), for a terminal that has tmux attached. Omitting it uses that session's active + * Which tmux pane to act on, as a tmux target (`session:window.pane`), for + * a terminal that has tmux attached. Omitting it uses that session's active * pane. Ignored when the terminal is a plain shell. */ pane?: string @@ -685,12 +627,17 @@ export interface TerminalToolArgs { reason?: string } +export interface TerminalToolCall { + operation: TerminalOperation + args?: TerminalToolArgs +} + /** * How a `terminal_run` ended. Only `completed` means the command is finished * and the terminal is free; in every other case it is still running and still * holds the foreground. */ -type TerminalRunStatus = +export type TerminalRunStatus = /** Exited on its own. `exitCode` is set. */ | 'completed' /** @@ -713,10 +660,7 @@ export interface TerminalRunResult { durationMs: number cwd: string | null terminalId: string - /** - * Set when the command ran in tmux: its own pane's id (`%N`), a tmux target that `read`, - * `input`, `kill` and `close` accept as `pane`. - */ + /** Set when the command ran in tmux: the target it ran under. */ pane?: string /** True when output was elided to fit {@link MAX_TOOL_OUTPUT_CHARS}. */ truncated: boolean @@ -956,14 +900,18 @@ export type TerminalErrorCode = /** No pane with that target — the targets come from the `panes` operation. */ | 'NO_SUCH_PANE' | 'INVALID_REQUEST' - /** The call was stopped before its command started; nothing ran. */ - | 'CANCELLED' export interface TerminalStartOptions { cols: number rows: number } +/** One batch of PTY bytes, tagged with the terminal that produced it. */ +export interface TerminalOutputEvent { + terminalId: string + data: string +} + /** * Command lifecycle, used by the panel to attribute rows to the agent and to * show a running indicator. Emitted for user-typed commands too (no @@ -984,80 +932,7 @@ export interface ScopedTerminalCommandEvent extends TerminalCommandEvent { scopeId: string } -import type { - ComputerUseInput, - ComputerUseNativeError, - ComputerUseResult, -} from './computer-use.generated' -import type { DesktopLocalFileRequest, DesktopLocalFileResponse } from './local-files' - -export type { - DesktopLocalFileEntry, - DesktopLocalFileManifest, - DesktopLocalFileRead, - DesktopLocalFileRequest, - DesktopLocalFileResponse, -} from './local-files' - -const PENDING_DESKTOP_SCOPE_PREFIX = 'pending:' as const - -/** Renderer budget for native authorization, app approval, queueing, and execution. */ -export const COMPUTER_USE_TOOL_TIMEOUT_MS = 90_000 - -/** Native work is bound to the server-authorized chat and tool call. */ -export interface ComputerUseActivity { - toolCallId: string - scopeId: string - bundleId?: string - appName?: string - action: string - startedAt: number - stopShortcutAvailable?: boolean -} - -/** Device permissions are independent of the server rollout flag. */ -export interface ComputerUseStatus { - supported: boolean - enabled: boolean - permissions: { accessibility: boolean; screenCapture: boolean } - activeAction: ComputerUseActivity | null -} - -export interface ComputerUseAppPermission { - bundleId: string - displayName: string -} - -/** Preserve native dispatch certainty as data across Electron's error serialization boundary. */ -export interface ComputerUseToolFailure { - kind: 'error' - error: ComputerUseNativeError -} - -/** Only validated helper replies may carry a confirmed pre-dispatch failure. */ -export class ComputerUseError extends Error { - constructor(readonly details: ComputerUseNativeError) { - super(details.message) - this.name = 'ComputerUseError' - } -} - -/** Optional so a new web deployment remains compatible with older desktop shells. */ -interface SimDesktopComputerUseApi { - /** Current activity without starting the helper; absent in older shells. */ - getActivity?(): Promise - getStatus(): Promise - setEnabled(enabled: boolean): Promise - requestPermission(permission: 'accessibility' | 'screenCapture'): Promise - listAppPermissions(): Promise - revokeApp(bundleId: string): Promise - executeTool( - toolCallId: string, - params: ComputerUseInput - ): Promise - cancel(toolCallId?: string): Promise - onActivity(callback: (activity: ComputerUseActivity | null) => void): () => void -} +export const PENDING_DESKTOP_SCOPE_PREFIX = 'pending:' as const /** Boolean results preserve compatibility with older installed desktop shells. */ export type TerminalPasteResult = boolean | 'too-large' @@ -1329,7 +1204,7 @@ export interface SimDesktopBrowserAgentApi { onScopeSuspended(callback: (scopeId: string) => void): () => void } -type BrowserDownloadState = 'progressing' | 'completed' | 'interrupted' | 'cancelled' +export type BrowserDownloadState = 'progressing' | 'completed' | 'interrupted' | 'cancelled' /** Safe renderer metadata for a native browser download; host paths never cross the bridge. */ export interface BrowserDownloadInfo { @@ -1348,7 +1223,7 @@ export interface BrowserDownloadsState { } /** Renderer navigation requested by the native browser toolbar menu. */ -export type BrowserToolbarCommand = 'browser-settings' | 'import' | 'passwords' +export type BrowserToolbarCommand = 'browser-settings' | 'import' /** Selected text and live page identity handed from the native browser to Sim. */ export interface BrowserAddToChatPayload { @@ -1433,7 +1308,7 @@ export interface BrowserSiteInfo { importedAt?: string } -interface SimDesktopBrowserImportApi { +export interface SimDesktopBrowserImportApi { /** Chrome profiles detected on this device; empty when none are readable. */ listChromeProfiles(): Promise /** @@ -1531,7 +1406,7 @@ export interface BrowserFillAvailability { * management calls require the Sim app origin, filling additionally requires * a real user gesture, and no browser tool maps to these channels. */ -interface SimDesktopBrowserCredentialsApi { +export interface SimDesktopBrowserCredentialsApi { /** False when OS-backed encryption is unavailable and passwords are disabled. */ isAvailable(): Promise /** Saved credentials, without passwords. */ @@ -1546,7 +1421,7 @@ interface SimDesktopBrowserCredentialsApi { * This is the only method on the entire bridge that can produce password * plaintext, and it is heavily conditioned: it requires an active user * gesture, the shell prompts for Touch ID (or a native confirmation where - * Touch ID is unavailable, including every Windows machine) on every call, and it returns exactly one + * Touch ID is unavailable) on every call, and it returns exactly one * password. Resolves null when the user declines or the credential is gone. */ reveal(id: string): Promise @@ -1554,11 +1429,8 @@ interface SimDesktopBrowserCredentialsApi { * Copy one saved password to the clipboard. Same authorization as * {@link reveal}, but the password never enters the renderer: the shell * writes the clipboard itself and clears it again shortly after. - * - * Absent on Windows, where Clipboard History and the cloud clipboard keep a - * copy that clearing the clipboard does not remove. */ - copy?(id: string): Promise + copy(id: string): Promise /** Copy saved passwords out of a Chrome profile into the vault. */ importFromChrome( profileId?: string, @@ -1602,7 +1474,7 @@ export interface LocalFilesystemEntry { modifiedAt?: string } -interface LocalFilesystemStat { +export interface LocalFilesystemStat { name: string uri: string kind: LocalFilesystemEntryKind @@ -1610,7 +1482,7 @@ interface LocalFilesystemStat { modifiedAt: string } -interface LocalFilesystemReadResult { +export interface LocalFilesystemReadResult { uri: string content: string startLine: number @@ -1695,10 +1567,6 @@ export type LocalFilesystemResponse = /** Outcome of an OAuth connect handoff, pushed when the browser flow finishes. */ export interface DesktopOAuthConnectResult { ok: boolean - /** Source request correlated by the shell, never taken from the browser callback. */ - sourceRequestId?: string - /** A GitHub setup selection; consumers verify current access before using it. */ - credentialId?: string /** OAuth error slug forwarded from the provider callback, when the flow failed. */ error?: string /** @@ -1812,7 +1680,7 @@ export const TERMINAL_DARK_THEME = { brightWhite: '#f0f6fc', } as const satisfies TerminalThemePalette -type TerminalThemeSource = 'terminal' | 'iterm2' +export type TerminalThemeSource = 'terminal' | 'iterm2' export interface TerminalSelectedProfile { /** Stable source profile id used to restore this selection. */ @@ -1895,14 +1763,12 @@ export function cloneTerminalSelectedProfile( } export interface DesktopPreferences { - /** Allows authorized local file tools to run without per-folder approval on this device. */ - fullFileAccess?: boolean notificationsEnabled: boolean notificationSounds: boolean notificationsOnlyWhenUnfocused: boolean launchAtLogin: boolean autoDownloadUpdates: boolean - /** Show the Sim status item (recent chats menu) in the macOS menu bar or the Windows system tray. */ + /** Show the Sim status item (recent chats menu) in the macOS menu bar. */ trayEnabled: boolean /** Let Chat drive the built-in agent browser on this device. */ browserEnabled: boolean @@ -1910,11 +1776,6 @@ export interface DesktopPreferences { browserSearchSuggestionsEnabled?: boolean /** Let Chat run commands in local shells. */ terminalEnabled: boolean - /** - * Keep the machine awake while a chat runs desktop work in the background. Optional for - * compatibility with installed shells that predate the background executor. - */ - preventSleepWhileRunning?: boolean /** * Appearance used by browser pages on this device. `app` follows Sim's * current preference; explicit values override it. @@ -1962,7 +1823,7 @@ export function resolveDesktopZoom( return Math.min(bounds.max, Math.max(bounds.min, next)) } -const DESKTOP_APPEARANCE_THEMES = ['app', 'light', 'dark'] as const +export const DESKTOP_APPEARANCE_THEMES = ['app', 'light', 'dark'] as const export type DesktopAppearanceTheme = (typeof DESKTOP_APPEARANCE_THEMES)[number] @@ -1988,16 +1849,10 @@ export interface DesktopNotificationPayload { body: string /** Optional in-app route opened when the notification is clicked. */ route?: string - /** - * A chat that finished in the background. In "only when unfocused" mode such a notification is - * held back only while the focused window shows its `route`; every other notification is held - * back whenever the window is focused. - */ - background?: boolean } /** Device-level settings owned by the desktop shell. */ -interface SimDesktopSettingsApi { +export interface SimDesktopSettingsApi { getPreferences(): Promise setPreference( key: K, @@ -2008,15 +1863,6 @@ interface SimDesktopSettingsApi { * for compatibility with installed shells that predate live suggestions. */ setBrowserSearchSuggestionsEnabled?(enabled: boolean): Promise - /** - * Keeps the machine awake while a chat runs desktop work in the background. Optional for - * compatibility with installed shells that predate the background executor. - */ - setPreventSleepWhileRunning?(enabled: boolean): Promise - /** Optional for installed shells that predate the explicit full-file-access setting. */ - setFullFileAccess?(enabled: boolean): Promise - /** Keeps settings in sync when folder consent changes access in the native shell. */ - onFullFileAccessChanged?(callback: (preferences: DesktopPreferences) => void): () => void notify(payload: DesktopNotificationPayload): Promise /** Overrides the appearance requested by browser pages. */ setBrowserTheme(theme: DesktopAppearanceTheme): Promise @@ -2030,7 +1876,7 @@ interface SimDesktopSettingsApi { setTerminalDefaultZoom(zoom: DesktopZoomPercent): Promise } -interface SimDesktopTerminalThemesApi { +export interface SimDesktopTerminalThemesApi { listProfiles(): Promise selectProfile(profileId: string): Promise } @@ -2040,7 +1886,13 @@ interface SimDesktopTerminalThemesApi { * automatic downloads are disabled or the shell requires a manual installer; * self-updating shells with automatic downloads enabled move to `downloading`. */ -type DesktopUpdateStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'error' +export type DesktopUpdateStatus = + | 'idle' + | 'checking' + | 'available' + | 'downloading' + | 'ready' + | 'error' export interface DesktopUpdateState { status: DesktopUpdateStatus @@ -2057,7 +1909,7 @@ export interface DesktopUpdateState { } /** The shell updater surface. */ -interface SimDesktopUpdatesApi { +export interface SimDesktopUpdatesApi { getState(): Promise /** * Advances the pipeline: checks for an update, downloads an available @@ -2110,7 +1962,7 @@ export type DesktopServerChangeResult = * to must stay reachable when that server cannot be reached at all, and must * never be drivable by a page the current server serves. */ -interface SimDesktopServerApi { +export interface SimDesktopServerApi { /** Opens the shell's native server-selection window. */ open(): void getConfiguration(): Promise @@ -2121,29 +1973,9 @@ interface SimDesktopServerApi { setOrigin(origin: string): Promise } -/** The device the desktop app registered for its background executor. */ -export interface DesktopExecutorDevice { - /** The install id Sim binds a turn to. */ - deviceId: string - /** The executor protocol version Sim accepted at registration. */ - protocolVersion: number -} - -/** The background executor in the desktop main process. */ -interface SimDesktopExecutorApi { - /** Null until the device registers, and whenever Sim reports the executor off. */ - getDevice(): Promise -} - -/** The operating system a desktop shell runs on. */ -export type DesktopPlatform = 'darwin' | 'win32' | 'linux' - export interface SimDesktopApi { - computerUse?: SimDesktopComputerUseApi /** Installed shell version (plain semver, e.g. `0.3.1`). */ version: string - /** The shell's operating system. Absent on shells that predate it, all of which are macOS. */ - platform?: DesktopPlatform openExternal(url: string): Promise /** Opens the operating system's microphone privacy settings when supported. */ openMicrophoneSettings?(): Promise @@ -2153,11 +1985,6 @@ export interface SimDesktopApi { * browser could not be opened. */ beginOAuthConnect(providerId: string, scope?: DesktopOAuthConnectScope): Promise - /** Starts an opaque source request in the browser without moving the desktop page. */ - prepareSourceConnect?(): Promise - beginSourceConnect?(requestId: string): Promise - /** Cancels only the matching pending source handoff. */ - cancelSourceConnect?(requestId: string): Promise /** * Subscribe to connect-handoff completions (the app is refocused just * before this fires). Returns an unsubscribe function. @@ -2170,17 +1997,6 @@ export interface SimDesktopApi { */ server?: SimDesktopServerApi localFilesystem(request: LocalFilesystemRequest): Promise - /** - * Optional so older installed shells do not advertise the new native tools. Also absent on - * platforms whose shell cannot run them (Windows today). - */ - localFiles?(request: DesktopLocalFileRequest): Promise - /** - * Present on shells that claim each local read (`read_local_file`, user-local VFS reads) through - * the server's tool authorization before reading, as they already do for imports. A turn started - * from such a shell persists those calls pending, so a read nobody picks up fails fast. - */ - localReadClaims?: true /** Subscribe to commands initiated by the native application menu. */ onCommand(callback: (command: DesktopCommand) => void): () => void windowState: SimDesktopWindowStateApi @@ -2189,29 +2005,12 @@ export interface SimDesktopApi { browserAgent: SimDesktopBrowserAgentApi /** * Local Chrome import for the built-in browser. Absent on platforms without - * a supported importer (everything but macOS today). + * a supported importer. */ browserImport?: SimDesktopBrowserImportApi /** Saved passwords and user-driven fill for the built-in browser. */ browserCredentials: SimDesktopBrowserCredentialsApi - /** Absent on platforms without the agent terminal (Windows today). */ - terminal?: SimDesktopTerminalApi - /** - * Runs desktop tools for chats the user is not viewing. Optional: shells without one leave - * desktop tools to the chat view. - */ - desktopExecutor?: SimDesktopExecutorApi - /** Reads and selects Terminal.app or iTerm2 color profiles. Present only on macOS. */ + terminal: SimDesktopTerminalApi + /** Reads and selects Terminal.app or iTerm2 color profiles on macOS. */ terminalThemes?: SimDesktopTerminalThemesApi } -export { - DESKTOP_IMPORT_TOKEN_HEADER, - isStorableImportName, - MAX_DESKTOP_IMPORT_FILE_BYTES, -} from './local-files' -export { - applyDesktopTitleBarMode, - DESKTOP_TITLE_BAR_ATTRIBUTE, - type DesktopTitleBarMode, - observeDesktopTitleBar, -} from './title-bar' From bafbdaeba9cf44d039f5ebe8660ffa4e53e3569c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:38:45 -0700 Subject: [PATCH 15/67] fix(desktop): close Windows clipboard and file-name gaps in the agent browser The agent's clipboard guard also refuses Blink's Shift+Insert, Ctrl+Insert, and Shift+Delete bindings off macOS, and a Cmd editing shortcut becomes Ctrl there instead of the Windows key. Staged uploads and downloads get NTFS-safe names on Windows. Password copy refuses in main as well as at the bridge, and the credential storage comments describe DPAPI and the confirmation fallback alongside Keychain and Touch ID. --- .../src/main/browser-agent/file-transfer.ts | 9 ++-- .../src/main/browser-agent/keyboard.test.ts | 20 +++++++ .../src/main/browser-agent/keyboard.ts | 23 +++++--- .../src/main/browser-credentials/index.ts | 3 ++ .../src/main/browser-credentials/os-auth.ts | 10 ++-- .../src/main/browser-credentials/vault.ts | 4 +- .../src/main/browser-sites/directory.ts | 8 +-- apps/desktop/src/main/downloads.ts | 12 +++-- apps/desktop/src/main/win32-path.test.ts | 54 +++++++++++++++++++ 9 files changed, 120 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/src/main/win32-path.test.ts diff --git a/apps/desktop/src/main/browser-agent/file-transfer.ts b/apps/desktop/src/main/browser-agent/file-transfer.ts index ddc72c6b2da..7bfb1fb27ff 100644 --- a/apps/desktop/src/main/browser-agent/file-transfer.ts +++ b/apps/desktop/src/main/browser-agent/file-transfer.ts @@ -18,6 +18,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { app } from 'electron' import { ToolError } from '@/main/browser-agent/errors' import type { BrowserAppSession } from '@/main/browser-agent/session' +import { toWin32SafeFileName } from '@/main/win32-path' /** Granted local folders; the caller owns and must close each returned file handle. */ export interface LocalFileSource { @@ -39,12 +40,14 @@ function directoryName(value: string): string { return encodeURIComponent(value).replaceAll('.', '%2E') } -/** A file name that cannot leave the staging directory. */ -function stagedFileName(name: string): string { +/** A file name that cannot leave the staging directory, and that Windows stores as spelled. */ +function stagedFileName(name: string, platform: NodeJS.Platform = process.platform): string { const base = basename(name) .replace(/[\x00-\x1f\x7f]/g, '') .trim() - return base && base !== '.' && base !== '..' ? base.slice(0, 200) : 'upload' + .slice(0, 200) + const safe = platform === 'win32' ? toWin32SafeFileName(base) : base + return safe && safe !== '.' && safe !== '..' ? safe : 'upload' } function filenameFromDisposition(header: string | null): string | null { diff --git a/apps/desktop/src/main/browser-agent/keyboard.test.ts b/apps/desktop/src/main/browser-agent/keyboard.test.ts index 18c98710645..25d0e9b5450 100644 --- a/apps/desktop/src/main/browser-agent/keyboard.test.ts +++ b/apps/desktop/src/main/browser-agent/keyboard.test.ts @@ -5,6 +5,7 @@ vi.mock('electron', () => import('@/test/electron-mock')) import { WebContentsView } from 'electron' import { buildKeyDispatchPlan, + comboTouchesClipboard, dispatchKeyCombo, KeyDispatchError, modifierKeyEvents, @@ -101,6 +102,12 @@ describe('buildKeyDispatchPlan', () => { expect(linuxDown.commands).toBeUndefined() }) + it('sends a Cmd editing shortcut as Ctrl on Windows instead of the Windows key', () => { + const [down] = buildKeyDispatchPlan(parseKeyCombo('Cmd+Z', 'win32'), 'win32') + expect(down.modifiers).toBe(2) + expect(down.commands).toBeUndefined() + }) + it('encodes the CDP modifier bitmask (Alt=1 Ctrl=2 Meta=4 Shift=8)', () => { const [down] = buildKeyDispatchPlan(parseKeyCombo('Control+Shift+K'), 'linux') expect(down.modifiers).toBe(2 | 8) @@ -110,6 +117,19 @@ describe('buildKeyDispatchPlan', () => { }) }) +describe('comboTouchesClipboard', () => { + it.each(['Shift+Insert', 'Control+Insert', 'Shift+Delete'])( + 'refuses %s on Windows, where Blink binds it to paste, copy, or cut', + (key) => { + expect(comboTouchesClipboard(parseKeyCombo(key, 'win32'), 'win32')).toBe(true) + } + ) + + it.each(['Insert', 'Delete', 'Control+Delete'])('leaves %s alone on Windows', (key) => { + expect(comboTouchesClipboard(parseKeyCombo(key, 'win32'), 'win32')).toBe(false) + }) +}) + describe('modifierKeyEvents', () => { it('presses each chord modifier in order and releases them in reverse', () => { const { downs, ups } = modifierKeyEvents(parseKeyCombo('Control+Shift+Y', 'linux'), 'linux') diff --git a/apps/desktop/src/main/browser-agent/keyboard.ts b/apps/desktop/src/main/browser-agent/keyboard.ts index ed755763b81..f7d9960e108 100644 --- a/apps/desktop/src/main/browser-agent/keyboard.ts +++ b/apps/desktop/src/main/browser-agent/keyboard.ts @@ -251,9 +251,18 @@ function editingCommandFor(combo: ParsedCombo): string | null { /** * Whether a combo would drive the system clipboard. Checked before dispatch * rather than by withholding the CDP `commands` array: off macOS these are - * Blink-native, so a key event alone still performs them. + * Blink-native, so a key event alone still performs them. That includes + * Blink's legacy bindings there: Shift+Insert pastes, Ctrl+Insert copies, + * and Shift+Delete cuts. */ -export function comboTouchesClipboard(combo: ParsedCombo): boolean { +export function comboTouchesClipboard( + combo: ParsedCombo, + platform: NodeJS.Platform = process.platform +): boolean { + if (platform !== 'darwin') { + if (combo.key === 'Insert' && (combo.shift || combo.ctrl)) return true + if (combo.key === 'Delete' && combo.shift) return true + } if (!combo.ctrl && !combo.meta) return false const key = combo.key.toLowerCase() return key === 'v' || key === 'c' || key === 'x' @@ -265,13 +274,13 @@ export function comboTouchesClipboard(combo: ParsedCombo): boolean { * explicitly (same technique as Puppeteer/Playwright). The model doesn't know * the host OS and often says "Control+A", so on macOS Ctrl is treated as Cmd * for these shortcuts: both must select all, not silently no-op. On other - * platforms Ctrl+key is handled inside Blink and needs no help. + * platforms Ctrl+key is handled inside Blink and needs no help, so a Cmd+A + * the model meant for a Mac becomes Ctrl+A there instead of the Windows key. */ function normalizeComboForPlatform(combo: ParsedCombo, platform: NodeJS.Platform): ParsedCombo { - if (platform !== 'darwin' || !combo.ctrl || combo.meta || editingCommandFor(combo) === null) { - return combo - } - return { ...combo, ctrl: false, meta: true } + if (editingCommandFor(combo) === null || combo.ctrl === combo.meta) return combo + if (platform === 'darwin') return combo.ctrl ? { ...combo, ctrl: false, meta: true } : combo + return combo.meta ? { ...combo, ctrl: true, meta: false } : combo } function macEditingCommands(combo: ParsedCombo, platform: NodeJS.Platform): string[] { diff --git a/apps/desktop/src/main/browser-credentials/index.ts b/apps/desktop/src/main/browser-credentials/index.ts index e78086e4d2d..c2151f1a54c 100644 --- a/apps/desktop/src/main/browser-credentials/index.ts +++ b/apps/desktop/src/main/browser-credentials/index.ts @@ -8,6 +8,7 @@ import { FillCoordinator, type FillCoordinatorDeps } from '@/main/browser-creden import { authorizeForSecret, revokeSecretAuthorization } from '@/main/browser-credentials/os-auth' import { CredentialVault } from '@/main/browser-credentials/vault' import { clearSites } from '@/main/browser-sites' +import { desktopFeatures } from '@/shared/platform-features' /** How long a copied password may sit on the clipboard before Sim clears it. */ const CLIPBOARD_CLEAR_MS = 30_000 @@ -104,8 +105,10 @@ export async function revealCredential(id: string): Promise { * * The clipboard is only cleared if it still holds this password — overwriting * whatever the user copied in the meantime would be its own small betrayal. + * Refused where clearing cannot reach every copy (`DesktopFeatures.passwordCopy`). */ export async function copyCredential(id: string): Promise { + if (!desktopFeatures(process.platform).passwordCopy) return false const authorized = await authorizeForSecret({ credentialId: id, operation: 'copy', diff --git a/apps/desktop/src/main/browser-credentials/os-auth.ts b/apps/desktop/src/main/browser-credentials/os-auth.ts index 2aea35c5c0b..c46dc1b1c6d 100644 --- a/apps/desktop/src/main/browser-credentials/os-auth.ts +++ b/apps/desktop/src/main/browser-credentials/os-auth.ts @@ -22,9 +22,11 @@ const AUTH_GRACE_MS = 30_000 * * Neither operation dominates the other, so a grant satisfies only its own. * `reveal` hands the plaintext to the Sim renderer; `copy` publishes it to the - * macOS pasteboard, which every process on the machine can read, which + * system clipboard, which every process on the machine can read, which * clipboard managers persist to disk beyond the 30s `clipboard.clear()`, and - * which Universal Clipboard syncs to the user's other devices. Ordering them + * which Universal Clipboard syncs to the user's other devices. (Windows keeps + * the same text in Clipboard History and the cloud clipboard, which is why + * copy is not offered there at all.) Ordering them * either way lets one consent authorize an exposure the prompt never described. */ export type SecretOperation = 'reveal' | 'copy' @@ -96,8 +98,8 @@ export function revokeSecretAuthorization(credentialId?: string): void { * renderer cannot drive or fake. * * Touch ID is used when the machine has it. Where it does not (a Mac mini - * without a Touch ID keyboard, or a user who has not enrolled), the fallback - * is a native modal that the main process owns. That is genuinely weaker than + * without a Touch ID keyboard, a user who has not enrolled, or any Windows + * machine), the fallback is a native modal that the main process owns. That is genuinely weaker than * biometric auth — it proves presence, not identity — so it is a deliberate, * documented trade rather than a silent one, and it still cannot be triggered * without a real click in the page. diff --git a/apps/desktop/src/main/browser-credentials/vault.ts b/apps/desktop/src/main/browser-credentials/vault.ts index 08b6fe0a5f0..7c904483202 100644 --- a/apps/desktop/src/main/browser-credentials/vault.ts +++ b/apps/desktop/src/main/browser-credentials/vault.ts @@ -14,8 +14,8 @@ import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/o * The encrypted credential store. * * The whole record set is encrypted as one blob with Electron `safeStorage` - * (Keychain on macOS), written atomically with restrictive permissions. There - * is no plaintext fallback: when OS-backed encryption is unavailable the vault + * (Keychain on macOS, DPAPI on Windows), written atomically with restrictive + * permissions. There is no plaintext fallback: when OS-backed encryption is unavailable the vault * reports itself unavailable and refuses to store anything, because a password * file Sim cannot encrypt is worse than a feature the user does not get. * diff --git a/apps/desktop/src/main/browser-sites/directory.ts b/apps/desktop/src/main/browser-sites/directory.ts index 994b5e79585..3805eb1cdcb 100644 --- a/apps/desktop/src/main/browser-sites/directory.ts +++ b/apps/desktop/src/main/browser-sites/directory.ts @@ -17,10 +17,10 @@ import { * anyway. It is a name and an icon per host — never a visit log, never a URL * beyond the host itself. * - * Encrypted with the same OS-backed key as the credential vault. The hostnames - * in here include ones only a saved password knows about, and those live in an - * encrypted vault today; writing them to a plaintext file beside it would - * quietly undo that. + * Encrypted with the same OS-backed `safeStorage` key as the credential vault + * (Keychain on macOS, DPAPI on Windows). The hostnames in here include ones + * only a saved password knows about, and those live in an encrypted vault + * today; writing them to a plaintext file beside it would quietly undo that. */ /** diff --git a/apps/desktop/src/main/downloads.ts b/apps/desktop/src/main/downloads.ts index a26582f379b..d1c3f22c7cd 100644 --- a/apps/desktop/src/main/downloads.ts +++ b/apps/desktop/src/main/downloads.ts @@ -6,6 +6,7 @@ import type { Session } from 'electron' import { app } from 'electron' import { isAgentWebContents } from '@/main/browser-agent/registry' import type { EventRecorder } from '@/main/observability' +import { toWin32SafeFileName } from '@/main/win32-path' const logger = createLogger('DesktopDownloads') @@ -27,15 +28,20 @@ const MIME_EXTENSIONS: Record = { /** * Strips path separators and control characters from a server- or - * blob-suggested filename so it can never escape the chosen directory. + * blob-suggested filename so it can never escape the chosen directory. On + * Windows it also becomes a name NTFS stores exactly as spelled. */ -export function sanitizeFilename(name: string): string { +export function sanitizeFilename( + name: string, + platform: NodeJS.Platform = process.platform +): string { const cleaned = name .replace(/[/\\]/g, '_') .replace(/[\u0000-\u001f]/g, '') .replace(/^\.+/, '') .trim() - return cleaned.slice(0, MAX_FILENAME_LENGTH) + .slice(0, MAX_FILENAME_LENGTH) + return platform === 'win32' ? toWin32SafeFileName(cleaned) : cleaned } /** diff --git a/apps/desktop/src/main/win32-path.test.ts b/apps/desktop/src/main/win32-path.test.ts new file mode 100644 index 00000000000..98fc1e3f223 --- /dev/null +++ b/apps/desktop/src/main/win32-path.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { isWin32SafeSegment, toWin32SafeFileName } from '@/main/win32-path' + +describe('isWin32SafeSegment', () => { + it.each(['CON', 'con', 'Nul.txt', 'COM1', 'lpt9.log', 'CON .txt', 'COM¹'])( + 'refuses the reserved device name %s', + (segment) => { + expect(isWin32SafeSegment(segment)).toBe(false) + } + ) + + it.each(['file.txt:stream', 'ab', 'a"b', 'a|b', 'a?b', 'a*b', 'a\\b', 'a/b', 'a\tb'])( + 'refuses %s, which holds a character NTFS rejects or reads as a stream', + (segment) => { + expect(isWin32SafeSegment(segment)).toBe(false) + } + ) + + it.each(['file.', 'file ', '...'])('refuses %s, which Windows would silently trim', (segment) => { + expect(isWin32SafeSegment(segment)).toBe(false) + }) + + it.each(['notes.txt', 'CONFIG', 'console.log', 'com10', '.env'])('accepts %s', (segment) => { + expect(isWin32SafeSegment(segment)).toBe(true) + }) +}) + +describe('toWin32SafeFileName', () => { + it('replaces characters NTFS rejects', () => { + expect(toWin32SafeFileName('Q3: plan |"final"?*.pdf')).toBe( + 'Q3_ plan _draft___final___.pdf' + ) + }) + + it('drops the trailing dots and spaces Windows would strip', () => { + expect(toWin32SafeFileName('report. . ')).toBe('report') + }) + + it('keeps a reserved device stem from opening the device', () => { + expect(toWin32SafeFileName('nul.txt')).toBe('_nul.txt') + expect(toWin32SafeFileName('AUX')).toBe('_AUX') + }) + + it('leaves an ordinary name alone', () => { + expect(toWin32SafeFileName('Q3 plan.pdf')).toBe('Q3 plan.pdf') + }) + + it('produces a name that passes the segment check', () => { + for (const name of ['CON:x.', 'a*b ', 'lpt1', 'x|y.']) { + const safe = toWin32SafeFileName(name) + expect(isWin32SafeSegment(safe)).toBe(true) + } + }) +}) From 10146e443b5b457aa8edf0355a23b6183c1ae366 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:42:16 -0700 Subject: [PATCH 16/67] fix(desktop): reconcile the Windows parts after merge Gate computer use through the platform feature table, guard the now-optional terminal bridge in the e2e specs, and name the Windows File/Help menus in the self-hosting docs and sim-setup output. --- apps/desktop/e2e/browser-tools.spec.ts | 26 ++++++++++++------- .../e2e/desktop-tools-live-sim.spec.ts | 9 ++++--- apps/desktop/e2e/terminal-cancel.spec.ts | 4 ++- apps/desktop/src/main/index.ts | 4 ++- apps/docs/content/docs/desktop/index.mdx | 2 +- .../docs/platform/self-hosting/desktop.mdx | 2 +- packages/sim-setup/README.md | 2 +- packages/sim-setup/src/desktop.ts | 4 +-- 8 files changed, 34 insertions(+), 19 deletions(-) diff --git a/apps/desktop/e2e/browser-tools.spec.ts b/apps/desktop/e2e/browser-tools.spec.ts index 3b573ad9df3..6d71644e5b1 100644 --- a/apps/desktop/e2e/browser-tools.spec.ts +++ b/apps/desktop/e2e/browser-tools.spec.ts @@ -1309,9 +1309,11 @@ test.describe('browser tools', () => { }) test('local terminal executes through its native PTY and refuses repeated authorization', async () => { await window.evaluate(async (scope) => { - const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop - await api.terminal.activateScope(scope) - await api.terminal.openTerminal(undefined, scope) + const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + .terminal + if (!terminal) throw new Error('No agent terminal on this platform') + await terminal.activateScope(scope) + await terminal.openTerminal(undefined, scope) }, SCOPE) calls.set('local-cwd', { chatId: SCOPE, @@ -1319,8 +1321,10 @@ test.describe('browser tools', () => { args: { operation: 'cwd', args: {} }, }) const cwd = await window.evaluate(async (scope) => { - const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop - return api.terminal.executeTool('local-cwd', 'cwd', {}, scope) + const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + .terminal + if (!terminal) throw new Error('No agent terminal on this platform') + return terminal.executeTool('local-cwd', 'cwd', {}, scope) }, SCOPE) expect(cwd.ok).toBe(true) calls.set('local-run', { @@ -1329,14 +1333,18 @@ test.describe('browser tools', () => { args: { operation: 'run', args: { command: "printf 'SIM_NATIVE_TERMINAL_VERIFIED\\n'" } }, }) const result = await window.evaluate(async (scope) => { - const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop - return api.terminal.executeTool('local-run', 'run', {}, scope) + const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + .terminal + if (!terminal) throw new Error('No agent terminal on this platform') + return terminal.executeTool('local-run', 'run', {}, scope) }, SCOPE) expect(result.ok).toBe(true) expect(JSON.stringify(result)).toContain('SIM_NATIVE_TERMINAL_VERIFIED') const replay = await window.evaluate(async (scope) => { - const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop - return api.terminal.executeTool('local-run', 'run', {}, scope) + const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + .terminal + if (!terminal) throw new Error('No agent terminal on this platform') + return terminal.executeTool('local-run', 'run', {}, scope) }, SCOPE) expect(replay.ok).toBe(false) }) diff --git a/apps/desktop/e2e/desktop-tools-live-sim.spec.ts b/apps/desktop/e2e/desktop-tools-live-sim.spec.ts index f83e6c61917..04bc6c2059f 100644 --- a/apps/desktop/e2e/desktop-tools-live-sim.spec.ts +++ b/apps/desktop/e2e/desktop-tools-live-sim.spec.ts @@ -672,10 +672,13 @@ test.describe('desktop tools against a live Sim', () => { // stays pending and unclaimed. Whether a handed-over command then runs depends on a terminal // being open for the chat, which this test does not set up, so it checks the claim only. const response = await page.evaluate( - ({ toolCallId, command }) => - (globalThis as DesktopWindow).simDesktop.terminal + ({ toolCallId, command }) => { + const terminal = (globalThis as DesktopWindow).simDesktop.terminal + if (!terminal) throw new Error('No agent terminal on this platform') + return terminal .executeTool(toolCallId, 'run', { command }, 'unapproved-e2e') - .catch((error: unknown) => ({ ok: false, error: String(error) })), + .catch((error: unknown) => ({ ok: false, error: String(error) })) + }, { toolCallId: run.toolCallId, command } ) expect(response).toMatchObject({ ok: false }) diff --git a/apps/desktop/e2e/terminal-cancel.spec.ts b/apps/desktop/e2e/terminal-cancel.spec.ts index 1d639b7dacc..98c5f5e688c 100644 --- a/apps/desktop/e2e/terminal-cancel.spec.ts +++ b/apps/desktop/e2e/terminal-cancel.spec.ts @@ -158,6 +158,7 @@ function bridge(window: Page) { const terminalId = await window.evaluate(async (scopeId) => { const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }) .simDesktop.terminal + if (!terminal) throw new Error('No agent terminal on this platform') await terminal.activateScope(scopeId) terminal.setVisible?.(true, scopeId) terminal.setFocused(true, scopeId) @@ -171,7 +172,7 @@ function bridge(window: Page) { ([id, text, scopeId]) => ( globalThis as typeof globalThis & { simDesktop: SimDesktopApi } - ).simDesktop.terminal.write(id, text, scopeId), + ).simDesktop.terminal?.write(id, text, scopeId), [terminalId, `${command}\r`, scope] as const ) }, @@ -180,6 +181,7 @@ function bridge(window: Page) { return window.evaluate(async (scopeId) => { const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }) .simDesktop.terminal + if (!terminal) throw new Error('No agent terminal on this platform') await terminal.activateScope(scopeId) return terminal.suspendScope(scopeId) }, scope) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b23b2ff881b..49419a0b05f 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -117,6 +117,7 @@ import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/m import { installBrowserUserAgent } from '@/main/user-agent' import { createMainWindow, handleSecondInstance, setupPermissionHandlers } from '@/main/window' import { attachWindowOpenPolicy, isPopupContents } from '@/main/windows' +import { desktopFeatures } from '@/shared/platform-features' const logger = createLogger('DesktopMain') @@ -184,7 +185,8 @@ function main(): void { const computerStopShortcut = 'CommandOrControl+Shift+Escape' const computerUse = new ComputerUseService({ config, - supported: process.platform === 'darwin' && Number.parseInt(release(), 10) >= 23, + supported: + desktopFeatures(process.platform).computerUse && Number.parseInt(release(), 10) >= 23, native: computerNative, openPermissionSettings: (permission) => shell.openExternal( diff --git a/apps/docs/content/docs/desktop/index.mdx b/apps/docs/content/docs/desktop/index.mdx index 44842a28a8b..bfe9644715c 100644 --- a/apps/docs/content/docs/desktop/index.mdx +++ b/apps/docs/content/docs/desktop/index.mdx @@ -165,7 +165,7 @@ This section also shows the installed version and a downloaded version waiting f Use **Let Chat run commands** to enable or disable Terminal. **Theme** and **Default zoom** control its appearance. -The **Sim** menu also contains **Settings…**, **Server…**, **Check for Updates…**, and **Sign Out**. +The **Sim** menu also contains **Settings…**, **Server…**, **Check for Updates…**, and **Sign Out**. On Windows, **Settings…**, **Server…** and **Sign Out** are in the **File** menu and **Check for Updates…** is in **Help**; press Alt to show the menu bar. ## Keyboard shortcuts diff --git a/apps/docs/content/docs/platform/self-hosting/desktop.mdx b/apps/docs/content/docs/platform/self-hosting/desktop.mdx index 2a9b3bdcc7c..0c0bdde9215 100644 --- a/apps/docs/content/docs/platform/self-hosting/desktop.mdx +++ b/apps/docs/content/docs/platform/self-hosting/desktop.mdx @@ -53,7 +53,7 @@ On macOS, open the `.dmg` and drag Sim to **Applications**. On Windows, run the ### Connect to your server -Launch Sim, choose **Sim → Server…** (on Windows, **Server…** in the **Sim** menu), enter your deployment origin, and select **Connect**. The app relaunches against that server and saves the setting across updates. +Launch Sim, choose **Sim → Server…** (on Windows, **File → Server…**; press Alt to show the menu bar), enter your deployment origin, and select **Connect**. The app relaunches against that server and saves the setting across updates. diff --git a/packages/sim-setup/README.md b/packages/sim-setup/README.md index 0a20a992931..964e15e7b57 100644 --- a/packages/sim-setup/README.md +++ b/packages/sim-setup/README.md @@ -167,7 +167,7 @@ npx sim-setup desktop --no-open The desktop app is not tied to sim.ai — it bakes in only a default server, and every Sim deployment already serves `/api/desktop/update/download` and the update feed installed apps poll. Install the signed build, then point it at your -deployment with **Server…** in the Sim menu. Nothing has to be built or signed by you. +deployment with **Server…** (in the Sim menu on macOS, the File menu on Windows). Nothing has to be built or signed by you. ### Add or change capabilities diff --git a/packages/sim-setup/src/desktop.ts b/packages/sim-setup/src/desktop.ts index 94099389bf2..2477faa0041 100644 --- a/packages/sim-setup/src/desktop.ts +++ b/packages/sim-setup/src/desktop.ts @@ -24,8 +24,8 @@ const DESKTOP_PLATFORMS = { windows: { downloadPath: '/api/desktop/update/download?platform=windows', feedPath: '/api/desktop/update/latest.yml', - serverCommand: 'Server…', - serverCommandWhere: 'from the Sim menu', + serverCommand: 'File → Server…', + serverCommandWhere: 'in the menu bar (press Alt to show it)', }, } as const From 6d1af690877ec17bcce15bcf92efff4053909e53 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:46:32 -0700 Subject: [PATCH 17/67] test(desktop): run the desktop unit suite on Windows Exclude the suites for subsystems Windows builds do not ship, keep POSIX mode-bit assertions to POSIX, and build expected paths with path.join. --- .../main/browser-agent/file-transfer.test.ts | 6 +-- .../src/main/browser-agent/keyboard.test.ts | 2 +- .../main/browser-credentials/vault.test.ts | 2 +- .../main/desktop-chat-session-store.test.ts | 3 +- .../src/main/desktop-executor/runner.test.ts | 2 +- .../src/main/desktop-executor/service.test.ts | 31 +++++++----- apps/desktop/src/main/downloads.test.ts | 2 +- apps/desktop/src/main/ipc.test.ts | 5 +- apps/desktop/src/main/observability.test.ts | 48 +++++++++++-------- apps/desktop/vitest.config.ts | 13 ++++- 10 files changed, 69 insertions(+), 45 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/file-transfer.test.ts b/apps/desktop/src/main/browser-agent/file-transfer.test.ts index bc4569f37c6..1454fd715a7 100644 --- a/apps/desktop/src/main/browser-agent/file-transfer.test.ts +++ b/apps/desktop/src/main/browser-agent/file-transfer.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { open, rename, rm, truncate } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { BROWSER_FILE_TRANSFER_MAX_BYTES } from '@sim/browser-protocol' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -59,7 +59,7 @@ describe('stageUploadFiles', () => { body: JSON.stringify({ toolCallId: 'call-1', index: 0 }), signal, }) - expect(staged.map((path) => path.split('/').pop())).toEqual(['Q3 plan.pdf', 'granted.txt']) + expect(staged.map((path) => basename(path))).toEqual(['Q3 plan.pdf', 'granted.txt']) expect(readFileSync(staged[0], 'utf8')).toBe('workspace bytes') expect(readFileSync(staged[1], 'utf8')).toBe('local bytes') expect(staged.every((path) => path.startsWith(join(temp, 'sim-browser-uploads')))).toBe(true) @@ -83,7 +83,7 @@ describe('stageUploadFiles', () => { }) expect(staged.startsWith(join(temp, 'sim-browser-uploads'))).toBe(true) - expect(staged.endsWith('/evil')).toBe(true) + expect(basename(staged)).toBe('evil') }) it('refuses a workspace file over the transfer ceiling', async () => { diff --git a/apps/desktop/src/main/browser-agent/keyboard.test.ts b/apps/desktop/src/main/browser-agent/keyboard.test.ts index 25d0e9b5450..129778c46d7 100644 --- a/apps/desktop/src/main/browser-agent/keyboard.test.ts +++ b/apps/desktop/src/main/browser-agent/keyboard.test.ts @@ -189,7 +189,7 @@ describe('dispatchKeyCombo', () => { .mockRejectedValueOnce(new Error('key-up response lost')) .mockRejectedValueOnce(new Error('cleanup unavailable')) - const dispatch = dispatchKeyCombo(contents, parseKeyCombo('Cmd+A')) + const dispatch = dispatchKeyCombo(contents, parseKeyCombo('Cmd+A', 'darwin')) await expect(dispatch).rejects.toEqual( expect.objectContaining({ diff --git a/apps/desktop/src/main/browser-credentials/vault.test.ts b/apps/desktop/src/main/browser-credentials/vault.test.ts index 264bbe7f799..03ac1ec231f 100644 --- a/apps/desktop/src/main/browser-credentials/vault.test.ts +++ b/apps/desktop/src/main/browser-credentials/vault.test.ts @@ -86,7 +86,7 @@ describe('CredentialVault', () => { expect(JSON.parse(onDisk)).toMatchObject({ version: 1, ciphertext: expect.any(String) }) }) - it('writes the vault file owner-only', async () => { + it.skipIf(process.platform === 'win32')('writes the vault file owner-only', async () => { const vault = new CredentialVault(vaultPath, encryption()) await vault.importCredentials(CANDIDATES, 'keep-existing') diff --git a/apps/desktop/src/main/desktop-chat-session-store.test.ts b/apps/desktop/src/main/desktop-chat-session-store.test.ts index cb49ee7f542..ec704ee45c5 100644 --- a/apps/desktop/src/main/desktop-chat-session-store.test.ts +++ b/apps/desktop/src/main/desktop-chat-session-store.test.ts @@ -115,7 +115,8 @@ describe('DesktopChatSessionStore', () => { expect(onDisk).not.toContain('report.csv') expect(JSON.parse(onDisk)).toEqual({ v: 1, ciphertext: expect.any(String) }) expect(provider.encryptString).toHaveBeenCalledOnce() - expect(statSync(filePath).mode & 0o077).toBe(0) + // POSIX mode bits; on Windows the per-user %APPDATA% ACL keeps the file private. + if (process.platform !== 'win32') expect(statSync(filePath).mode & 0o077).toBe(0) }) it('does not replace the durable store with an oversized encrypted envelope', () => { diff --git a/apps/desktop/src/main/desktop-executor/runner.test.ts b/apps/desktop/src/main/desktop-executor/runner.test.ts index 377173e604d..8b316e6718d 100644 --- a/apps/desktop/src/main/desktop-executor/runner.test.ts +++ b/apps/desktop/src/main/desktop-executor/runner.test.ts @@ -191,7 +191,7 @@ describe('local file calls', () => { }) }) -describe('background imports', () => { +describe.skipIf(process.platform === 'win32')('background imports', () => { const roots: string[] = [] afterEach(async () => { diff --git a/apps/desktop/src/main/desktop-executor/service.test.ts b/apps/desktop/src/main/desktop-executor/service.test.ts index c1bbae124bf..77af2fad54d 100644 --- a/apps/desktop/src/main/desktop-executor/service.test.ts +++ b/apps/desktop/src/main/desktop-executor/service.test.ts @@ -337,20 +337,25 @@ describe('desktop executor registration', () => { } }) - it('registers with no install id it could not save', async () => { - const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-')) - await chmod(userData, 0o500) - try { - const { sim, desktopExecutor } = await service(1, userData) - - desktopExecutor.start() - await sleep(200) - - expect(sim.requests.filter((request) => request.includes('/api/desktop/devices'))).toEqual([]) - } finally { - await chmod(userData, 0o700) + it.skipIf(process.platform === 'win32')( + 'registers with no install id it could not save', + async () => { + const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-')) + await chmod(userData, 0o500) + try { + const { sim, desktopExecutor } = await service(1, userData) + + desktopExecutor.start() + await sleep(200) + + expect(sim.requests.filter((request) => request.includes('/api/desktop/devices'))).toEqual( + [] + ) + } finally { + await chmod(userData, 0o700) + } } - }) + ) it('keeps its install id through a read failure instead of minting a new one', async () => { const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-')) diff --git a/apps/desktop/src/main/downloads.test.ts b/apps/desktop/src/main/downloads.test.ts index 951bc151f6c..993c23a22ca 100644 --- a/apps/desktop/src/main/downloads.test.ts +++ b/apps/desktop/src/main/downloads.test.ts @@ -45,7 +45,7 @@ describe('uniqueDownloadPath', () => { ]) expect(new Set([first, second])).toEqual( - new Set(['/Downloads/report.csv', '/Downloads/report (copy-1).csv']) + new Set([join('/Downloads', 'report.csv'), join('/Downloads', 'report (copy-1).csv')]) ) }) }) diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 93f2609d5fc..f379f769f45 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -418,8 +418,9 @@ describe('registerIpcHandlers', () => { expect(await handler?.(evilEvent)).toBe(false) expect(await handler?.(appEvent)).toBe(false) - expect(await handler?.(activeAppEvent)).toBe(process.platform === 'darwin') - expect(shell.openExternal).toHaveBeenCalledTimes(process.platform === 'darwin' ? 1 : 0) + const hasPrivacySettings = process.platform === 'darwin' || process.platform === 'win32' + expect(await handler?.(activeAppEvent)).toBe(hasPrivacySettings) + expect(shell.openExternal).toHaveBeenCalledTimes(hasPrivacySettings ? 1 : 0) }) it('restricts the OAuth connect handoff to an activated app origin', async () => { diff --git a/apps/desktop/src/main/observability.test.ts b/apps/desktop/src/main/observability.test.ts index 82fbfce631b..d1bb2e91fae 100644 --- a/apps/desktop/src/main/observability.test.ts +++ b/apps/desktop/src/main/observability.test.ts @@ -26,31 +26,37 @@ describe('scrubUrl', () => { }) describe('createEventLog', () => { - it('creates its directory and log with private permissions', () => { - const root = mkdtempSync(join(tmpdir(), 'sim-desktop-events-')) - const dir = join(root, 'logs') - const events = createEventLog(dir) - events.record('app_launch') + it.skipIf(process.platform === 'win32')( + 'creates its directory and log with private permissions', + () => { + const root = mkdtempSync(join(tmpdir(), 'sim-desktop-events-')) + const dir = join(root, 'logs') + const events = createEventLog(dir) + events.record('app_launch') - expect(statSync(dir).mode & 0o777).toBe(0o700) - expect(statSync(events.filePath).mode & 0o777).toBe(0o600) - }) + expect(statSync(dir).mode & 0o777).toBe(0o700) + expect(statSync(events.filePath).mode & 0o777).toBe(0o600) + } + ) - it('reports permission failures without exposing local paths or OS errors', () => { - const root = mkdtempSync(join(tmpdir(), 'sim-desktop-events-')) - const overlongDir = join(root, 'x'.repeat(300)) + it.skipIf(process.platform === 'win32')( + 'reports permission failures without exposing local paths or OS errors', + () => { + const root = mkdtempSync(join(tmpdir(), 'sim-desktop-events-')) + const overlongDir = join(root, 'x'.repeat(300)) - const events = createEventLog(overlongDir) - events.record('app_launch') + const events = createEventLog(overlongDir) + events.record('app_launch') - expect(mockLogger.warn.mock.calls).toEqual([ - ['Could not apply private desktop event-log permissions', { target: 'directory' }], - ['Could not apply private desktop event-log permissions', { target: 'current-log' }], - ['Could not apply private desktop event-log permissions', { target: 'rotated-log' }], - ]) - expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain(root) - expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain('ENAMETOOLONG') - }) + expect(mockLogger.warn.mock.calls).toEqual([ + ['Could not apply private desktop event-log permissions', { target: 'directory' }], + ['Could not apply private desktop event-log permissions', { target: 'current-log' }], + ['Could not apply private desktop event-log permissions', { target: 'rotated-log' }], + ]) + expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain(root) + expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain('ENAMETOOLONG') + } + ) }) describe('installMainProcessFailureObservers', () => { diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index d23c4d7e613..c986e8e1a2d 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -8,7 +8,18 @@ export default mergeConfig( test: { setupFiles: ['src/test/setup.ts'], include: ['src/**/*.test.ts'], - exclude: ['**/e2e/**'], + // These suites drive subsystems Windows builds do not ship (tmux, the macOS computer-use + // helper, the POSIX native file module); see src/shared/platform-features.ts. + exclude: [ + '**/e2e/**', + ...(process.platform === 'win32' + ? [ + 'src/main/terminal/tmux.test.ts', + 'src/main/computer-use/native-client.test.ts', + 'src/main/local-files.test.ts', + ] + : []), + ], pool: 'threads', testTimeout: 10000, }, From 046a79328d7262f5639a98b49772bee980010ce7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:48:56 -0700 Subject: [PATCH 18/67] fix(desktop): declare per-platform node-pty prebuilds as optional electron-builder's dependency collector fails on a missing production dependency but skips a missing optional one, and bun installs only the host platform's prebuild, so each OS was failing on the other's packages. --- apps/desktop/package.json | 10 ++++++---- apps/desktop/src/main/browser-agent/keyboard.test.ts | 9 ++++++--- bun.lock | 10 ++++++---- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3fcf3c9c534..024c698c6fb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -33,10 +33,6 @@ }, "dependencies": { "@lydell/node-pty": "1.2.0-beta.12", - "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", - "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", - "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", - "@lydell/node-pty-win32-x64": "1.2.0-beta.12", "@sim/browser-protocol": "workspace:*", "@sim/desktop-bridge": "workspace:*", "@sim/logger": "workspace:*", @@ -50,6 +46,12 @@ "pdf-lib": "1.17.1", "@sim/auth-ui": "workspace:*" }, + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", + "@lydell/node-pty-win32-x64": "1.2.0-beta.12" + }, "devDependencies": { "@electron/fuses": "1.8.0", "@playwright/test": "1.61.1", diff --git a/apps/desktop/src/main/browser-agent/keyboard.test.ts b/apps/desktop/src/main/browser-agent/keyboard.test.ts index 129778c46d7..9c8e0e3d651 100644 --- a/apps/desktop/src/main/browser-agent/keyboard.test.ts +++ b/apps/desktop/src/main/browser-agent/keyboard.test.ts @@ -189,7 +189,7 @@ describe('dispatchKeyCombo', () => { .mockRejectedValueOnce(new Error('key-up response lost')) .mockRejectedValueOnce(new Error('cleanup unavailable')) - const dispatch = dispatchKeyCombo(contents, parseKeyCombo('Cmd+A', 'darwin')) + const dispatch = dispatchKeyCombo(contents, parseKeyCombo('Mod+A')) await expect(dispatch).rejects.toEqual( expect.objectContaining({ @@ -202,7 +202,7 @@ describe('dispatchKeyCombo', () => { const keyEvents = vi .mocked(contents.debugger.sendCommand) .mock.calls.filter(([method]) => method === 'Input.dispatchKeyEvent') - // Meta down, A down, A up (lost), then cleanup re-releases A and releases the held Meta. + // Mod down, A down, A up (lost), then cleanup re-releases A and releases the held Mod. expect(keyEvents).toHaveLength(5) expect(keyEvents[2]).toEqual(keyEvents[3]) expect(keyEvents[2]).toEqual([ @@ -211,7 +211,10 @@ describe('dispatchKeyCombo', () => { ]) expect(keyEvents[4]).toEqual([ 'Input.dispatchKeyEvent', - expect.objectContaining({ type: 'keyUp', key: 'Meta' }), + expect.objectContaining({ + type: 'keyUp', + key: process.platform === 'darwin' ? 'Meta' : 'Control', + }), ]) expect(contents.setIgnoreMenuShortcuts).toHaveBeenNthCalledWith(1, true) expect(contents.setIgnoreMenuShortcuts).toHaveBeenNthCalledWith(2, false) diff --git a/bun.lock b/bun.lock index 5bffb10fcfb..dbea0e2ac1c 100644 --- a/bun.lock +++ b/bun.lock @@ -48,10 +48,6 @@ "version": "0.0.0", "dependencies": { "@lydell/node-pty": "1.2.0-beta.12", - "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", - "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", - "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", - "@lydell/node-pty-win32-x64": "1.2.0-beta.12", "@sim/auth-ui": "workspace:*", "@sim/browser-protocol": "workspace:*", "@sim/desktop-bridge": "workspace:*", @@ -86,6 +82,12 @@ "typescript": "^7.0.2", "vitest": "^5.0.4", }, + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", + "@lydell/node-pty-win32-x64": "1.2.0-beta.12", + }, }, "apps/docs": { "name": "docs", From 7563f8bd3331851e231d0f6ca34d79f86d0a4988 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:54:26 -0700 Subject: [PATCH 19/67] ci(desktop): let the macOS callers grant the Windows job's OIDC scope GitHub validates every job of a reusable workflow against the calling job's permissions at startup, so the new id-token: write request in desktop-release made the whole ci workflow fail to start. --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0fd271d152..30fbcdec4a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1260,8 +1260,12 @@ jobs: needs.desktop-signing.result == 'success' && needs.version.result == 'success' && needs.desktop-signing.outputs.configured == 'true' + # The reusable workflow's Windows job requests id-token: write for Azure + # OIDC, and GitHub checks every called job against the caller's grant at + # startup, even one this macOS call skips. permissions: contents: write + id-token: write uses: ./.github/workflows/desktop-release.yml with: version: ${{ needs.version.outputs.version }} @@ -1407,8 +1411,11 @@ jobs: # The reusable workflow declares contents: write for its stable-release # path. GitHub cannot elevate a caller's token, even though this prerelease # path uses the dedicated cross-repository token for its actual upload. + # id-token: write is required the same way by the Windows job (see + # desktop-release above). permissions: contents: write + id-token: write uses: ./.github/workflows/desktop-release.yml with: version: ${{ needs.prerelease-create.outputs.version }} From 89ad2ea6c3fc6f7d966ce6177c564d2adb975cf5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 21:59:54 -0700 Subject: [PATCH 20/67] test(desktop): give the preferences fixture a terminal-capable shell isTerminalEnabled now requires the bridge to expose a terminal, which a Windows shell omits. --- apps/sim/lib/desktop/preferences-loading.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/desktop/preferences-loading.test.ts b/apps/sim/lib/desktop/preferences-loading.test.ts index 139a8ab545d..df9444d3033 100644 --- a/apps/sim/lib/desktop/preferences-loading.test.ts +++ b/apps/sim/lib/desktop/preferences-loading.test.ts @@ -19,7 +19,7 @@ it('publishes asynchronous startup preferences and later settings changes to sub } const pending = Promise.withResolvers() const getPreferences = vi.fn(() => pending.promise) - vi.stubGlobal('window', { simDesktop: { settings: { getPreferences } } }) + vi.stubGlobal('window', { simDesktop: { settings: { getPreferences }, terminal: {} } }) const listener = vi.fn() const unsubscribe = subscribeDesktopPreferences(listener) const otherListener = vi.fn() From bb5a1bb09ffd78f43e4fd026d6f01e027a1f0fa1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 22:09:48 -0700 Subject: [PATCH 21/67] docs(desktop): keep the install step's existing anchor --- apps/docs/content/docs/desktop/index.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/docs/desktop/index.mdx b/apps/docs/content/docs/desktop/index.mdx index bfe9644715c..f4464c454dd 100644 --- a/apps/docs/content/docs/desktop/index.mdx +++ b/apps/docs/content/docs/desktop/index.mdx @@ -44,11 +44,11 @@ See [Desktop app on your deployment](/platform/self-hosting/desktop) for server -### Install the app +### Install in Applications On macOS, open the disk image and drag Sim to **Applications**. Launch it from there. -On Windows, run the installer. It installs for your user account without an administrator prompt and opens Sim when it finishes. A new installer can show a Microsoft Defender SmartScreen warning until it builds download reputation; check that the publisher is Sim before choosing **More info → Run anyway**. +On Windows, run the installer instead. It installs for your user account without an administrator prompt and opens Sim when it finishes. A new installer can show a Microsoft Defender SmartScreen warning until it builds download reputation; check that the publisher is Sim before choosing **More info → Run anyway**. On macOS, automatic self-update requires a Developer ID build installed in Applications. Builds elsewhere use the manual installer update path. From 9f5991ed5866d90664553dccdbe3f10fd7926135 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:17:45 -0700 Subject: [PATCH 22/67] fix(desktop): address review on Windows gating, prereleases, and renames - Gate browser-credentials:import with the rest of browser import. - Retry the synchronous settings flush on a briefly locked file too. - Let a Windows-only signing setup create and publish prereleases. - State that a user-mapped drive stays allowed by the path guard. --- .github/scripts/ci-plan.gql | 62 ++++++++++++++----- .github/workflows/ci.yml | 34 +++++++--- .../desktop/src/main/atomic-json-file.test.ts | 31 ++++++---- apps/desktop/src/main/atomic-json-file.ts | 53 ++++++++++++---- apps/desktop/src/main/ipc.test.ts | 1 + apps/desktop/src/main/ipc.ts | 3 +- apps/desktop/src/main/win32-path.ts | 16 ++--- apps/desktop/src/preload/index.ts | 14 +++-- packages/desktop-bridge/src/index.ts | 7 ++- 9 files changed, 159 insertions(+), 62 deletions(-) diff --git a/.github/scripts/ci-plan.gql b/.github/scripts/ci-plan.gql index b2a0e6c184f..6a55e49a4a2 100644 --- a/.github/scripts/ci-plan.gql +++ b/.github/scripts/ci-plan.gql @@ -1,22 +1,56 @@ -query($base: String!, $head: String!) { - packages { items { name path } } - affectedTasks(base: $base, head: $head, tasks: [ - "test", "build", "type-check", "ci:integration", "ci:desktop-live", - "ci:e2e-scim", "ci:e2e-cli", "ci:e2e-stop-after", "ci:e2e-desktop-inbox", - "ci:e2e-browser-extension", "ci:e2e-project-files", "ci:e2e-mobile", "ci:e2e-seo" - ]) { +query ($base: String!, $head: String!) { + packages { + items { + name + path + } + } + affectedTasks( + base: $base + head: $head + tasks: [ + "test" + "build" + "type-check" + "ci:integration" + "ci:desktop-live" + "ci:e2e-scim" + "ci:e2e-cli" + "ci:e2e-stop-after" + "ci:e2e-desktop-inbox" + "ci:e2e-browser-extension" + "ci:e2e-project-files" + "ci:e2e-mobile" + "ci:e2e-seo" + ] + ) { items { name fullName - package { name } + package { + name + } reason { __typename - ... on TaskFileChanged { filePath } - ... on TaskGlobalFileChanged { filePath } - ... on TaskGlobalDepsChanged { filePath } - ... on TaskDependencyTaskChanged { taskName packageName } - ... on TaskPackageDependencyChanged { packageName } - ... on TaskAllChanged { description } + ... on TaskFileChanged { + filePath + } + ... on TaskGlobalFileChanged { + filePath + } + ... on TaskGlobalDepsChanged { + filePath + } + ... on TaskDependencyTaskChanged { + taskName + packageName + } + ... on TaskPackageDependencyChanged { + packageName + } + ... on TaskAllChanged { + description + } } } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30fbcdec4a9..9f91c6acec8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1338,15 +1338,20 @@ jobs: name: prerelease-create runs-on: *runner-2vcpu timeout-minutes: 5 - needs: [desktop-changes, desktop-signing] - # Requires the signing probe to have actually succeeded (not just "not + needs: [desktop-changes, desktop-signing, desktop-signing-windows] + # Requires a signing probe to have actually succeeded (not just "not # cancelled") so a probe failure can't produce a release with no build. + # Either platform's signing is enough: each build job gates on its own. if: >- ${{ !cancelled() && needs.desktop-changes.outputs.changed == 'true' && - needs.desktop-signing.result == 'success' && - needs.desktop-signing.outputs.configured == 'true' + ( + (needs.desktop-signing.result == 'success' && + needs.desktop-signing.outputs.configured == 'true') || + (needs.desktop-signing-windows.result == 'success' && + needs.desktop-signing-windows.outputs.configured == 'true') + ) }} permissions: contents: read @@ -1408,6 +1413,11 @@ jobs: prerelease-build: name: prerelease-build needs: [prerelease-create, desktop-signing] + if: >- + !cancelled() && + needs.prerelease-create.result == 'success' && + needs.desktop-signing.result == 'success' && + needs.desktop-signing.outputs.configured == 'true' # The reusable workflow declares contents: write for its stable-release # path. GitHub cannot elevate a caller's token, even though this prerelease # path uses the dedicated cross-repository token for its actual upload. @@ -1430,8 +1440,8 @@ jobs: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - # Attaches the Windows build to the same prerelease, before or after - # prerelease-publish: publication waits only for the macOS build. + # Attaches the Windows build to the same prerelease. A Windows failure never + # holds back a macOS build that succeeded (see prerelease-publish). prerelease-build-windows: name: prerelease-build-windows needs: [prerelease-create, desktop-signing-windows] @@ -1462,7 +1472,17 @@ jobs: name: prerelease-publish runs-on: *runner-2vcpu timeout-minutes: 5 - needs: [prerelease-create, prerelease-build] + needs: [prerelease-create, prerelease-build, prerelease-build-windows] + # Publishes once every configured platform has finished: the macOS build + # when Apple signing is configured, otherwise the Windows build alone. + if: >- + !cancelled() && + needs.prerelease-create.result == 'success' && + ( + needs.prerelease-build.result == 'success' || + (needs.prerelease-build.result == 'skipped' && + needs.prerelease-build-windows.result == 'success') + ) permissions: contents: read env: diff --git a/apps/desktop/src/main/atomic-json-file.test.ts b/apps/desktop/src/main/atomic-json-file.test.ts index 6a946bbdc9e..b6583c2ecb2 100644 --- a/apps/desktop/src/main/atomic-json-file.test.ts +++ b/apps/desktop/src/main/atomic-json-file.test.ts @@ -7,6 +7,7 @@ import { readFileWithinLimit, readFileWithinLimitSync, renameReplacing, + renameReplacingSync, } from '@/main/atomic-json-file' describe('bounded file reads', () => { @@ -29,48 +30,52 @@ describe('bounded file reads', () => { }) }) -describe('renameReplacing', () => { +describe.each([ + { + name: 'renameReplacing', + run: (rename: () => void, platform: NodeJS.Platform) => + renameReplacing('from', 'to', { platform, rename: async () => rename() }), + }, + { + name: 'renameReplacingSync', + run: async (rename: () => void, platform: NodeJS.Platform) => + renameReplacingSync('from', 'to', { platform, rename }), + }, +])('$name', ({ run }) => { const busy = (code: string) => Object.assign(new Error(code), { code }) function flakyRename(failures: number, code = 'EBUSY') { let calls = 0 - const rename = vi.fn(async () => { + return vi.fn(() => { calls += 1 if (calls <= failures) throw busy(code) }) - return rename } it.each(['EPERM', 'EBUSY', 'EACCES'])( 'retries a Windows rename that fails with %s until the holder lets go', async (code) => { const rename = flakyRename(2, code) - await renameReplacing('from', 'to', { platform: 'win32', rename }) + await run(rename, 'win32') expect(rename).toHaveBeenCalledTimes(3) } ) it('gives up on Windows after a bounded number of attempts', async () => { const rename = flakyRename(Number.POSITIVE_INFINITY) - await expect(renameReplacing('from', 'to', { platform: 'win32', rename })).rejects.toThrow( - 'EBUSY' - ) + await expect(run(rename, 'win32')).rejects.toThrow('EBUSY') expect(rename).toHaveBeenCalledTimes(5) }) it('does not retry an error a held file cannot cause', async () => { const rename = flakyRename(1, 'ENOENT') - await expect(renameReplacing('from', 'to', { platform: 'win32', rename })).rejects.toThrow( - 'ENOENT' - ) + await expect(run(rename, 'win32')).rejects.toThrow('ENOENT') expect(rename).toHaveBeenCalledTimes(1) }) it('does not retry on macOS, where these codes mean a real permission failure', async () => { const rename = flakyRename(1, 'EACCES') - await expect(renameReplacing('from', 'to', { platform: 'darwin', rename })).rejects.toThrow( - 'EACCES' - ) + await expect(run(rename, 'darwin')).rejects.toThrow('EACCES') expect(rename).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/desktop/src/main/atomic-json-file.ts b/apps/desktop/src/main/atomic-json-file.ts index 814523183df..d7cc4bcad0c 100644 --- a/apps/desktop/src/main/atomic-json-file.ts +++ b/apps/desktop/src/main/atomic-json-file.ts @@ -90,9 +90,23 @@ function temporaryPathFor(filePath: string): string { const WINDOWS_TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']) const MAX_RENAME_ATTEMPTS = 5 -interface RenameOptions { +interface RenameOptions { platform?: NodeJS.Platform - rename?: (from: string, to: string) => Promise + rename?: (from: string, to: string) => Result +} + +function shouldRetryRename(error: unknown, attempt: number, platform: NodeJS.Platform): boolean { + const code = (error as NodeJS.ErrnoException).code + return ( + platform === 'win32' && + attempt < MAX_RENAME_ATTEMPTS && + code !== undefined && + WINDOWS_TRANSIENT_RENAME_CODES.has(code) + ) +} + +function renameRetryDelayMs(attempt: number): number { + return backoffWithJitter(attempt, null, { baseMs: 20, maxMs: 200 }) } /** @@ -102,22 +116,35 @@ interface RenameOptions { export async function renameReplacing( from: string, to: string, - { platform = process.platform, rename: renameFile = rename }: RenameOptions = {} + { platform = process.platform, rename: renameFile = rename }: RenameOptions> = {} ): Promise { for (let attempt = 1; ; attempt++) { try { await renameFile(from, to) return } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if ( - platform !== 'win32' || - attempt >= MAX_RENAME_ATTEMPTS || - !code || - !WINDOWS_TRANSIENT_RENAME_CODES.has(code) - ) - throw error - await sleep(backoffWithJitter(attempt, null, { baseMs: 20, maxMs: 200 })) + if (!shouldRetryRename(error, attempt, platform)) throw error + await sleep(renameRetryDelayMs(attempt)) + } + } +} + +/** + * {@link renameReplacing} for a caller that cannot await. The waits block the + * thread, bounded by the same few short attempts. + */ +export function renameReplacingSync( + from: string, + to: string, + { platform = process.platform, rename: renameFile = renameSync }: RenameOptions = {} +): void { + for (let attempt = 1; ; attempt++) { + try { + renameFile(from, to) + return + } catch (error) { + if (!shouldRetryRename(error, attempt, platform)) throw error + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, renameRetryDelayMs(attempt)) } } } @@ -153,7 +180,7 @@ export function writeJsonFileAtomicallySync( mkdirSync(dirname(filePath), { recursive: true }) const temporaryPath = temporaryPathFor(filePath) writeFileSync(temporaryPath, JSON.stringify(value, null, indent), { mode: FILE_MODE }) - renameSync(temporaryPath, filePath) + renameReplacingSync(temporaryPath, filePath) } /** diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index f379f769f45..696ccc2cb01 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -375,6 +375,7 @@ describe('registerIpcHandlers', () => { 'terminal-themes:list-profiles', 'computer-use:execute-tool', 'browser-import:all', + 'browser-credentials:import', 'desktop:local-files', 'browser-credentials:copy', ])('registers no handler on Windows for %s, which that shell does not ship', (channel) => { diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index c1156384f7a..a63476acd5a 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -408,7 +408,8 @@ function platformFeatureFor(channel: string): keyof DesktopFeatures | null { if (channel.startsWith('terminal:')) return 'terminal' if (channel.startsWith('terminal-themes:')) return 'terminalThemes' if (channel.startsWith('computer-use:')) return 'computerUse' - if (channel.startsWith('browser-import:')) return 'browserImport' + if (channel.startsWith('browser-import:') || channel === 'browser-credentials:import') + return 'browserImport' if (channel === 'desktop:local-files') return 'localFiles' if (channel === 'browser-credentials:copy') return 'passwordCopy' return null diff --git a/apps/desktop/src/main/win32-path.ts b/apps/desktop/src/main/win32-path.ts index 45a816e4405..df205ce3f55 100644 --- a/apps/desktop/src/main/win32-path.ts +++ b/apps/desktop/src/main/win32-path.ts @@ -38,6 +38,8 @@ export function toWin32SafeFileName(name: string): string { const DRIVE_ABSOLUTE = /^[A-Za-z]:[\\/]/ const HOME_RELATIVE = /^~[\\/]/ +const DRIVE_PATH_REQUIRED = 'Use a drive path such as C:\\Users\\name\\file or a ~\\ path.' + /** * Resolves a model-supplied Windows path without touching the filesystem. * @@ -47,15 +49,15 @@ const HOME_RELATIVE = /^~[\\/]/ * path under `~` is accepted, so UNC, `\\?\` and `\\.\` device paths, * drive-relative `C:foo`, and rooted `\foo` (relative to the current drive) * are all refused. UNC shares are refused outright, even inside a granted - * share, until grants can be matched lexically. Each segment must also pass - * {@link isWin32SafeSegment}. + * share, until grants can be matched lexically. A drive letter the user + * mapped to a share stays allowed: it reaches only the server the user chose, + * so a model-supplied path cannot name a new host through it. Each segment + * must also pass {@link isWin32SafeSegment}. */ export function resolveWin32NativePath(value: string, home: string): string { const homeRelative = value === '~' ? '' : HOME_RELATIVE.test(value) ? value.slice(2) : null - if (homeRelative === null && !DRIVE_ABSOLUTE.test(value)) - throw new Error('Use a drive path such as C:\\Users\\name\\file or a ~\\ path.') - if (homeRelative !== null && /^[\\/]/.test(homeRelative)) - throw new Error('Use a drive path such as C:\\Users\\name\\file or a ~\\ path.') + if (homeRelative === null && !DRIVE_ABSOLUTE.test(value)) throw new Error(DRIVE_PATH_REQUIRED) + if (homeRelative !== null && /^[\\/]/.test(homeRelative)) throw new Error(DRIVE_PATH_REQUIRED) const segments = (homeRelative ?? value.slice(3)).split(/[\\/]/) for (const segment of segments) { if (segment === '' || segment === '.' || segment === '..') continue @@ -63,6 +65,6 @@ export function resolveWin32NativePath(value: string, home: string): string { throw new Error(`"${segment}" is not a plain Windows file or folder name.`) } const resolved = homeRelative === null ? win32.resolve(value) : win32.resolve(home, homeRelative) - if (!DRIVE_ABSOLUTE.test(resolved)) throw new Error('Use a path on a local drive.') + if (!DRIVE_ABSOLUTE.test(resolved)) throw new Error(DRIVE_PATH_REQUIRED) return resolved } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 25b461612ef..8f935253b5b 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -481,11 +481,15 @@ const api: SimDesktopApi = { ipcRenderer.invoke('browser-credentials:copy', id), } : {}), - importFromChrome: ( - profileId?: string, - policy?: BrowserCredentialConflictPolicy - ): Promise => - ipcRenderer.invoke('browser-credentials:import', profileId, policy), + ...(features.browserImport + ? { + importFromChrome: ( + profileId?: string, + policy?: BrowserCredentialConflictPolicy + ): Promise => + ipcRenderer.invoke('browser-credentials:import', profileId, policy), + } + : {}), showChooser: (anchor: { x: number; y: number }, scopeId: string): Promise => ipcRenderer.invoke('browser-credentials:show-chooser', anchor, scopeId), listFillOptions: (scopeId: string): Promise => diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index 2feb254fba0..98373e584a0 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -598,8 +598,11 @@ interface SimDesktopBrowserCredentialsApi { * copy that clearing the clipboard does not remove. */ copy?(id: string): Promise - /** Copy saved passwords out of a Chrome profile into the vault. */ - importFromChrome( + /** + * Copy saved passwords out of a Chrome profile into the vault. Absent where + * browser import is unavailable (Windows). + */ + importFromChrome?( profileId?: string, policy?: BrowserCredentialConflictPolicy ): Promise From b5a2e837703fe09315efc7b9dffc622c51fe73af Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:26:12 -0700 Subject: [PATCH 23/67] feat(desktop): ship the native local-file tools on Windows read_local_file, import_local_files, and background imports now run on Windows through a Node fs backend behind the same openNativeFile seam the POSIX native module serves on macOS and Linux. Node has no descriptor-relative open on Windows, so the backend checks the grant root's identity, requires the resolved path to be canonical beneath the grant (no symlink or junction component), matches the opened handle's identity to the path's, and re-checks path and root after the open; a listing must leave the path naming the opened handle. Entries that Windows enumerates as reparse points are classified by lstat, so junctions list as links and OneDrive placeholders as files. Caps, errors, and the handle shape match the POSIX module, and both backends run the local-files suite where they can. The handle now owns readDirectory, isWithinRoot moves to its own module so both backends share it, platform-features turns localFiles on for win32, and the Windows test leg runs local-files.test.ts and the background-import suite. --- apps/desktop/README.md | 6 +- .../src/main/desktop-executor/runner.test.ts | 60 ++-- .../src/main/desktop-executor/service.test.ts | 10 +- apps/desktop/src/main/ipc.test.ts | 4 +- apps/desktop/src/main/local-files.test.ts | 313 ++++++++++-------- apps/desktop/src/main/local-files.ts | 3 +- apps/desktop/src/main/local-filesystem.ts | 22 +- apps/desktop/src/main/native-directory.ts | 53 ++- apps/desktop/src/main/path-containment.ts | 23 ++ .../src/main/win32-native-files.test.ts | 236 +++++++++++++ apps/desktop/src/main/win32-native-files.ts | 140 ++++++++ apps/desktop/src/shared/platform-features.ts | 7 +- apps/desktop/vitest.config.ts | 8 +- 13 files changed, 672 insertions(+), 213 deletions(-) create mode 100644 apps/desktop/src/main/path-containment.ts create mode 100644 apps/desktop/src/main/win32-native-files.test.ts create mode 100644 apps/desktop/src/main/win32-native-files.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 1823fea5551..e16e45411ca 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -192,7 +192,9 @@ The native `read_local_file` and `import_local_files` tools also accept absolute **Desktop settings → Local files → Full file access** bypasses folder prompts for authorized native reads and imports. It is off by default, persists across ordinary restarts, and resets on sign-out or server changes. Turning it off restores folder consent checks. Call authorization, cancellation, file identity, and resource limits still apply, and VFS access continues to use explicit mounts. A failed settings write reports an error and leaves full access disabled in the running app. -Approved native reads can return bounded text, directory listings, images, or PDFs to the chat. Approved imports transfer file bytes to Workspace Files. Electron revalidates every pending call before using a grant, including remembered grants, checks canonical containment and grant identity throughout the operation, and opens files with no-follow and descriptor identity checks. Directory enumeration uses `fdopendir` on the verified descriptor, so replacing a parent path cannot redirect the listing. Listings scan at most 1,001 entries and return up to 1,000 sorted names with an explicit truncation flag; the cap bounds both memory and filesystem work, rather than promising the globally first 1,000 names in an arbitrarily large directory. Imports reject truncated listings. A model or hosted renderer cannot answer the local consent dialog. These permissions govern the native file tools; the separately enabled terminal still runs with the user's OS privileges. +Approved native reads can return bounded text, directory listings, images, or PDFs to the chat. Approved imports transfer file bytes to Workspace Files. Electron revalidates every pending call before using a grant, including remembered grants, checks canonical containment and grant identity throughout the operation, and opens files with no-follow and descriptor identity checks. On macOS and Linux, a native module opens each path component relative to the verified grant descriptor, and directory enumeration uses `fdopendir` on the verified descriptor, so replacing a parent path cannot redirect the open or the listing. Listings scan at most 1,001 entries and return up to 1,000 sorted names with an explicit truncation flag; the cap bounds both memory and filesystem work, rather than promising the globally first 1,000 names in an arbitrarily large directory. Imports reject truncated listings. A model or hosted renderer cannot answer the local consent dialog. These permissions govern the native file tools; the separately enabled terminal still runs with the user's OS privileges. + +On Windows, Node cannot open a path relative to a directory handle, so the same tools use checked Node fs opens instead (`src/main/win32-native-files.ts`). Paths are validated lexically first: only drive paths and `~\` paths are accepted, and reserved device names, alternate data streams, and trailing dots or spaces are refused. Then Electron checks that the granted folder still has its recorded identity, resolves the path and refuses it unless it is already canonical beneath the granted folder (so any symlink or junction in it, even one pointing back inside, is refused), and opens it only when the opened handle has the identity the path had just before the open; after the open it checks the path and the granted folder again. A directory listing must leave the path naming the opened handle. Listings report symlinks and junctions as links and never follow them, under the same caps as macOS. These checks narrow, but cannot eliminate, a race by a process already running as the user that swaps a path component between a check and the open. The model itself cannot create links or junctions: the tools are read-only. OneDrive Files-On-Demand placeholders are downloaded when they are read or imported. UNC and other network paths, including a drive letter mapped to a share, are refused in this release. ## Windows @@ -205,7 +207,7 @@ The Windows build is a one-click, per-user NSIS installer (`Sim--x64.ex - Updates: the NSIS updater requests `latest.yml` from the same `/api/desktop/update` feed. `ready` is reached when electron-updater's own `update-downloaded` fires (after the publisher check); there is no native staging step. A confirmed restart runs the installer silently and relaunches. Builds without a packaged `app-update.yml` fall back to the manual-download pipeline. Deltas use `.exe.blockmap`. - Publication: Windows publishes independently of macOS. `desktop-signing-windows` probes the Azure configuration and skips with a warning until it exists; unsigned Windows builds are never published. No job waits on the Windows calls, so a Windows failure never blocks the macOS release, and the feed treats a release without `latest.yml` as having no Windows build (Windows shells keep the newest release that has one). `latest.yml` is uploaded last. - SmartScreen: a new signing identity starts without download reputation, so early installers can show "Windows protected your PC" until reputation builds. Keep the same certificate profile across releases. -- Not available on Windows yet: Terminal, native local-file tools, native Computer Use, and importing from other browsers. node-pty still ships but is not exercised. +- Not available on Windows yet: Terminal, native Computer Use, and importing from other browsers. The native local-file tools ship with the checks described in [Local filesystem access](#local-filesystem-access). node-pty still ships but is not exercised. | Secret / variable | Kind | Contents | |---|---|---| diff --git a/apps/desktop/src/main/desktop-executor/runner.test.ts b/apps/desktop/src/main/desktop-executor/runner.test.ts index 8b316e6718d..37b907a4f89 100644 --- a/apps/desktop/src/main/desktop-executor/runner.test.ts +++ b/apps/desktop/src/main/desktop-executor/runner.test.ts @@ -141,37 +141,53 @@ describe('background terminal calls', () => { }) describe('calls for a surface the platform does not ship', () => { - it.each([ - ['terminal', { operation: 'run', args: { command: 'echo hi' } }], - ['read_local_file', { path: '~/notes.txt' }], - ['import_local_files', { path: '~/notes', targetWorkspaceId: 'ws-1' }], - ])('never starts %s on Windows', async (toolName, args) => { - const executeTool = vi.fn() - const request = vi.fn() - const completion = await runner({ - platform: 'win32', - terminal: { executeTool, cancelTool: vi.fn(async () => true) }, - localFiles: { request }, - }).run( + it.each([['terminal', { operation: 'run', args: { command: 'echo hi' } }]])( + 'never starts %s on Windows', + async (toolName, args) => { + const executeTool = vi.fn() + const request = vi.fn() + const completion = await runner({ + platform: 'win32', + terminal: { executeTool, cancelTool: vi.fn(async () => true) }, + localFiles: { request }, + }).run( + { + toolCallId: 'call-1', + toolName, + args, + chatId: 'chat-b', + workspaceId: 'ws-1', + executionToken: 'token-call-1', + }, + new AbortController().signal + ) + + expect(completion.data).toMatchObject({ notStarted: true }) + expect(completion.message).toContain('on this operating system') + expect(executeTool).not.toHaveBeenCalled() + expect(request).not.toHaveBeenCalled() + } + ) +}) + +describe('local file calls', () => { + it.each(['read_local_file', 'import_local_files'])('starts %s on Windows', async (toolName) => { + const request = vi.fn(async () => ({ ok: false as const, error: 'unreadable' })) + await runner({ platform: 'win32', localFiles: { request } }).run( { - toolCallId: 'call-1', + toolCallId: 'local-1', toolName, - args, + args: { path: '~/notes', targetWorkspaceId: 'ws-1' }, chatId: 'chat-b', workspaceId: 'ws-1', - executionToken: 'token-call-1', + executionToken: 'token-local-1', }, new AbortController().signal ) - expect(completion.data).toMatchObject({ notStarted: true }) - expect(completion.message).toContain('on this operating system') - expect(executeTool).not.toHaveBeenCalled() - expect(request).not.toHaveBeenCalled() + expect(request).toHaveBeenCalledOnce() }) -}) -describe('local file calls', () => { it('names a passing storage state, not a setting, when local files are out of reach', async () => { const completion = await runner({ accountDataAvailable: () => false }).run( { @@ -191,7 +207,7 @@ describe('local file calls', () => { }) }) -describe.skipIf(process.platform === 'win32')('background imports', () => { +describe('background imports', () => { const roots: string[] = [] afterEach(async () => { diff --git a/apps/desktop/src/main/desktop-executor/service.test.ts b/apps/desktop/src/main/desktop-executor/service.test.ts index 77af2fad54d..3fd84ddde70 100644 --- a/apps/desktop/src/main/desktop-executor/service.test.ts +++ b/apps/desktop/src/main/desktop-executor/service.test.ts @@ -228,17 +228,17 @@ describe('results recovery will hand to the model', () => { describe('desktop executor registration', () => { it.each([ - ['darwin', true], - ['win32', false], + ['darwin', true, true], + ['win32', false, true], ] as const)( - 'advertises the terminal and native file tools on %s: %s', - async (platform, shipped) => { + 'advertises on %s the terminal: %s, and the native file tools: %s', + async (platform, terminal, localFiles) => { const { sim, desktopExecutor } = await service(1, undefined, platform) desktopExecutor.start() await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) expect(sim.registrationBodies[0]).toMatchObject({ - capabilities: { browser: true, terminal: shipped, localFiles: shipped }, + capabilities: { browser: true, terminal, localFiles }, }) sim.registrations[0]?.(false) await desktopExecutor.signOut() diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index f379f769f45..2565be8cf57 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -375,7 +375,6 @@ describe('registerIpcHandlers', () => { 'terminal-themes:list-profiles', 'computer-use:execute-tool', 'browser-import:all', - 'desktop:local-files', 'browser-credentials:copy', ])('registers no handler on Windows for %s, which that shell does not ship', (channel) => { expect(collectHandlers().invoke.has(channel) || collectHandlers().on.has(channel)).toBe(true) @@ -388,12 +387,13 @@ describe('registerIpcHandlers', () => { expect(invoke.has(channel) || on.has(channel)).toBe(false) }) - it('keeps the user-local folder grants and password reveal on Windows', () => { + it('keeps the folder grants, native file tools, and password reveal on Windows', () => { vi.mocked(ipcMain.handle).mockClear() registerIpcHandlers({ ...deps, platform: 'win32' }) const { invoke } = collectHandlers() expect(invoke.has('desktop:local-filesystem')).toBe(true) + expect(invoke.has('desktop:local-files')).toBe(true) expect(invoke.has('browser-credentials:reveal')).toBe(true) }) diff --git a/apps/desktop/src/main/local-files.test.ts b/apps/desktop/src/main/local-files.test.ts index fd7fe391d4d..d569e175062 100644 --- a/apps/desktop/src/main/local-files.test.ts +++ b/apps/desktop/src/main/local-files.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { app } from 'electron' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -11,155 +11,194 @@ import { executeLocalFileRequest as executeApprovedLocalFileRequest, type LocalFileAuthorization, } from '@/main/local-files' -import { openNativeFile } from '@/main/native-directory' +import { type openNativeFile, openPosixNativeFile } from '@/main/native-directory' +import { openWin32NativeFile } from '@/main/win32-native-files' -/** Parser and import invariants run with explicit fixture access; consent is covered through Electron. */ -function executeLocalFileRequest(request: unknown, authorization: LocalFileAuthorization) { - return executeApprovedLocalFileRequest(request, authorization, { - path: String(authorization.args.path), - resolve: realpath, - open: async (path, directory = false) => - openNativeFile(root, relative(root, path), await stat(root, { bigint: true }), directory), - }) -} +const isWindows = process.platform === 'win32' -let root: string -beforeEach(async () => { - vi.mocked(app.getAppPath).mockReturnValue(fileURLToPath(new URL('../..', import.meta.url))) - root = await realpath(await mkdtemp(join(tmpdir(), 'sim-native-files-'))) -}) -afterEach(async () => { - await rm(root, { recursive: true, force: true }) -}) +/** Both backends run where they can; the POSIX native module is not built on Windows. */ +const BACKENDS: Array<[string, typeof openNativeFile]> = [['Windows', openWin32NativeFile]] +if (!isWindows) BACKENDS.push(['POSIX', openPosixNativeFile]) -it('rejects missing paths and directory cycles with explicit errors before uploading', async () => { - expect( - await executeLocalFileRequest( - { operation: 'read' }, - { toolName: 'read_local_file', args: { path: join(root, 'missing') } } - ) - ).toMatchObject({ ok: false }) - await symlink(root, join(root, 'cycle')) - expect( - await executeLocalFileRequest( - { operation: 'manifest' }, - { toolName: 'import_local_files', args: { path: root, targetWorkspaceId: 'target' } } - ) - ).toMatchObject({ ok: false, error: expect.stringContaining('cycle') }) -}) +/** A junction on Windows, which needs no privilege; a symlink elsewhere. */ +function linkDirectory(target: string, path: string) { + return symlink(target, path, isWindows ? 'junction' : 'dir') +} -it('refuses oversized import files before any workspace mutation or bulk allocation', async () => { - const path = join(root, 'large.bin') - await writeFile(path, '') - await truncate(path, 64 * 1024 * 1024 + 1) - expect( - await executeLocalFileRequest( - { operation: 'manifest' }, - { toolName: 'import_local_files', args: { path, targetWorkspaceId: 'workspace' } } - ) - ).toMatchObject({ ok: false, error: expect.stringContaining('64 MB') }) -}) +/** File symlinks need Developer Mode or elevation on Windows. */ +async function canLinkFiles(): Promise { + const probe = await mkdtemp(join(tmpdir(), 'sim-symlink-probe-')) + try { + await writeFile(join(probe, 'target'), '') + await symlink(join(probe, 'target'), join(probe, 'link'), 'file') + return true + } catch { + return false + } finally { + await rm(probe, { recursive: true, force: true }) + } +} -it.each(['file', 'directory'] as const)( - 'rejects %s symlinks outside the import source during manifest and chunk reads', - async (kind) => { - const source = join(root, 'selected') - const outside = join(root, 'selected-other') - await mkdir(source) - await mkdir(outside) - await writeFile(join(outside, 'private.txt'), 'outside content') - const target = kind === 'file' ? join(outside, 'private.txt') : outside - await symlink(target, join(source, 'link')) - const authorization = { - toolName: 'import_local_files', - args: { path: source, targetWorkspaceId: 'target' }, - } - expect(await executeLocalFileRequest({ operation: 'manifest' }, authorization)).toMatchObject({ - ok: false, - error: expect.stringContaining('outside this import source'), +const fileLinks = await canLinkFiles() + +describe.each(BACKENDS)('local file requests on the %s backend', (_name, openBeneath) => { + /** Parser and import invariants run with explicit fixture access; consent is covered through Electron. */ + function executeLocalFileRequest(request: unknown, authorization: LocalFileAuthorization) { + return executeApprovedLocalFileRequest(request, authorization, { + path: String(authorization.args.path), + resolve: realpath, + open: async (path, directory = false) => + openBeneath(root, relative(root, path), await stat(root, { bigint: true }), directory), }) - const outsideManifest = await executeLocalFileRequest( - { operation: 'manifest' }, - { toolName: 'import_local_files', args: { path: target, targetWorkspaceId: 'target' } } - ) - if (!outsideManifest.ok || outsideManifest.data.kind !== 'manifest') - throw new Error('Expected manifest') - const entry = outsideManifest.data.entries.find((item) => item.kind === 'file')! + } + + let root: string + beforeEach(async () => { + vi.mocked(app.getAppPath).mockReturnValue(fileURLToPath(new URL('../..', import.meta.url))) + root = await realpath(await mkdtemp(join(tmpdir(), 'sim-native-files-'))) + }) + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + it('rejects missing paths and directory cycles with explicit errors before uploading', async () => { expect( await executeLocalFileRequest( - { - operation: 'chunk', - relativePath: kind === 'file' ? 'link' : 'link/private.txt', - revision: entry.revision, - offset: 0, - }, - authorization + { operation: 'read' }, + { toolName: 'read_local_file', args: { path: join(root, 'missing') } } ) - ).toMatchObject({ ok: false, error: expect.stringContaining('outside this import source') }) - } -) - -it('supports internal symlinks and an explicitly selected symlink root, but rejects a retargeted child', async () => { - const source = join(root, 'selected') - await mkdir(source) - await writeFile(join(source, 'notes.txt'), 'inside') - await symlink(join(source, 'notes.txt'), join(source, 'alias.txt')) - const selectedAlias = join(root, 'selected-alias') - await symlink(source, selectedAlias) - const authorization = { - toolName: 'import_local_files', - args: { path: selectedAlias, targetWorkspaceId: 'target' }, - } - const manifest = await executeLocalFileRequest({ operation: 'manifest' }, authorization) - if (!manifest.ok || manifest.data.kind !== 'manifest') throw new Error('Expected manifest') - const entry = manifest.data.entries.find((item) => item.relativePath === 'alias.txt')! - const request = { - operation: 'chunk', - relativePath: 'alias.txt', - revision: entry.revision, - offset: 0, - } - expect(await executeLocalFileRequest(request, authorization)).toEqual({ - ok: true, - data: { kind: 'chunk', bytes: new Uint8Array(Buffer.from('inside')), eof: true }, + ).toMatchObject({ ok: false }) + await linkDirectory(root, join(root, 'cycle')) + expect( + await executeLocalFileRequest( + { operation: 'manifest' }, + { toolName: 'import_local_files', args: { path: root, targetWorkspaceId: 'target' } } + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('cycle') }) }) - await writeFile(join(root, 'outside.txt'), 'outside') - await rm(join(source, 'alias.txt')) - await symlink(join(root, 'outside.txt'), join(source, 'alias.txt')) - expect(await executeLocalFileRequest(request, authorization)).toMatchObject({ - ok: false, - error: expect.stringContaining('outside this import source'), + + it('refuses oversized import files before any workspace mutation or bulk allocation', async () => { + const path = join(root, 'large.bin') + await writeFile(path, '') + await truncate(path, 64 * 1024 * 1024 + 1) + expect( + await executeLocalFileRequest( + { operation: 'manifest' }, + { toolName: 'import_local_files', args: { path, targetWorkspaceId: 'workspace' } } + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('64 MB') }) }) -}) -it('preserves a UTF-8 BOM and rejects split offsets and limits that cannot fit a character', async () => { - const path = join(root, 'unicode.txt') - await writeFile(path, '\uFEFF😀end') - expect( - await executeLocalFileRequest( - { operation: 'read' }, - { - toolName: 'read_local_file', - args: { path, limit: 4 }, + it.for(['file', 'directory'] as const)( + 'rejects %s symlinks outside the import source during manifest and chunk reads', + async (kind, { skip }) => { + if (kind === 'file' && !fileLinks) + skip('creating file symlinks needs Developer Mode or elevation') + const source = join(root, 'selected') + const outside = join(root, 'selected-other') + await mkdir(source) + await mkdir(outside) + await writeFile(join(outside, 'private.txt'), 'outside content') + const target = kind === 'file' ? join(outside, 'private.txt') : outside + await (kind === 'file' + ? symlink(target, join(source, 'link'), 'file') + : linkDirectory(target, join(source, 'link'))) + const authorization = { + toolName: 'import_local_files', + args: { path: source, targetWorkspaceId: 'target' }, } - ) - ).toMatchObject({ ok: true, data: { text: '\uFEFF', nextOffset: 3, truncated: true } }) - expect( - await executeLocalFileRequest( - { operation: 'read' }, - { - toolName: 'read_local_file', - args: { path, offset: 4 }, + expect(await executeLocalFileRequest({ operation: 'manifest' }, authorization)).toMatchObject( + { + ok: false, + error: expect.stringContaining('outside this import source'), + } + ) + const outsideManifest = await executeLocalFileRequest( + { operation: 'manifest' }, + { toolName: 'import_local_files', args: { path: target, targetWorkspaceId: 'target' } } + ) + if (!outsideManifest.ok || outsideManifest.data.kind !== 'manifest') + throw new Error('Expected manifest') + const entry = outsideManifest.data.entries.find((item) => item.kind === 'file')! + expect( + await executeLocalFileRequest( + { + operation: 'chunk', + relativePath: kind === 'file' ? 'link' : 'link/private.txt', + revision: entry.revision, + offset: 0, + }, + authorization + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('outside this import source') }) + } + ) + + it.runIf(fileLinks)( + 'supports internal symlinks and an explicitly selected symlink root, but rejects a retargeted child', + async () => { + const source = join(root, 'selected') + await mkdir(source) + await writeFile(join(source, 'notes.txt'), 'inside') + await symlink(join(source, 'notes.txt'), join(source, 'alias.txt'), 'file') + const selectedAlias = join(root, 'selected-alias') + await linkDirectory(source, selectedAlias) + const authorization = { + toolName: 'import_local_files', + args: { path: selectedAlias, targetWorkspaceId: 'target' }, } - ) - ).toMatchObject({ ok: false, error: expect.stringContaining('UTF-8') }) - expect( - await executeLocalFileRequest( - { operation: 'read' }, - { - toolName: 'read_local_file', - args: { path, offset: 3, limit: 1 }, + const manifest = await executeLocalFileRequest({ operation: 'manifest' }, authorization) + if (!manifest.ok || manifest.data.kind !== 'manifest') throw new Error('Expected manifest') + const entry = manifest.data.entries.find((item) => item.relativePath === 'alias.txt')! + const request = { + operation: 'chunk', + relativePath: 'alias.txt', + revision: entry.revision, + offset: 0, } - ) - ).toMatchObject({ ok: false, error: expect.stringContaining('limit') }) + expect(await executeLocalFileRequest(request, authorization)).toEqual({ + ok: true, + data: { kind: 'chunk', bytes: new Uint8Array(Buffer.from('inside')), eof: true }, + }) + await writeFile(join(root, 'outside.txt'), 'outside') + await rm(join(source, 'alias.txt')) + await symlink(join(root, 'outside.txt'), join(source, 'alias.txt'), 'file') + expect(await executeLocalFileRequest(request, authorization)).toMatchObject({ + ok: false, + error: expect.stringContaining('outside this import source'), + }) + } + ) + + it('preserves a UTF-8 BOM and rejects split offsets and limits that cannot fit a character', async () => { + const path = join(root, 'unicode.txt') + await writeFile(path, '\uFEFF😀end') + expect( + await executeLocalFileRequest( + { operation: 'read' }, + { + toolName: 'read_local_file', + args: { path, limit: 4 }, + } + ) + ).toMatchObject({ ok: true, data: { text: '\uFEFF', nextOffset: 3, truncated: true } }) + expect( + await executeLocalFileRequest( + { operation: 'read' }, + { + toolName: 'read_local_file', + args: { path, offset: 4 }, + } + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('UTF-8') }) + expect( + await executeLocalFileRequest( + { operation: 'read' }, + { + toolName: 'read_local_file', + args: { path, offset: 3, limit: 1 }, + } + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('limit') }) + }) }) diff --git a/apps/desktop/src/main/local-files.ts b/apps/desktop/src/main/local-files.ts index 45a6e6328e0..32f8466fc1a 100644 --- a/apps/desktop/src/main/local-files.ts +++ b/apps/desktop/src/main/local-files.ts @@ -14,7 +14,6 @@ import { isRecordLike } from '@sim/utils/object' import { compareStrings } from '@sim/utils/string' import { PDFDocument } from 'pdf-lib' import type { LocalFileAccess } from '@/main/local-filesystem' -import { readNativeDirectory } from '@/main/native-directory' const CHUNK_BYTES = 8 * 1024 * 1024 const MAX_ENTRIES = 1000 @@ -47,7 +46,7 @@ function assertImportPath(root: string, candidate: string): void { async function readApprovedDirectory(path: string, access: LocalFileAccess) { const handle = await access.open(path, true) try { - const listing = await readNativeDirectory(handle.fd, MAX_ENTRIES) + const listing = await handle.readDirectory(MAX_ENTRIES) const canonical = await access.resolve(path) // bigint: NTFS file IDs exceed 2^53, and rounded IDs could compare equal. const current = await lstat(canonical, { bigint: true }) diff --git a/apps/desktop/src/main/local-filesystem.ts b/apps/desktop/src/main/local-filesystem.ts index c032f1fe5c3..4577ae3b404 100644 --- a/apps/desktop/src/main/local-filesystem.ts +++ b/apps/desktop/src/main/local-filesystem.ts @@ -38,6 +38,7 @@ import type { PersistedLocalFilesystemGrant, } from '@/main/local-filesystem-grant-store' import { openNativeFile } from '@/main/native-directory' +import { isWithinRoot } from '@/main/path-containment' import { isWin32SafeSegment } from '@/main/win32-path' const MAX_URI_LENGTH = 4096 @@ -129,27 +130,6 @@ export interface LocalFilesystemToolAuthorization { args: Record } -/** - * Whether a resolved path is the granted root or sits beneath it. - * - * Uses `relative()` rather than prefix arithmetic. The `${root}${sep}` sentinel - * that form needs to reject `/granted-evil` against `/granted` becomes `//` when - * the root IS a separator — and the picker lets the user grant a volume root — - * so every path under a granted `/` was denied while the bare root passed. - * `relative()` has no such edge case: it returns `''` for the root itself and a - * leading `..` segment for anything outside, on both separators. - */ -function isWithinRoot(rootPath: string, candidatePath: string): boolean { - const rel = relative(rootPath, candidatePath) - if (rel === '') return true - // A leading `..` SEGMENT, not the two characters: `..config` is an ordinary - // child name, and matching it as an escape would deny a real dotfile. - if (rel === '..' || rel.startsWith(`..${sep}`)) return false - // A different Windows volume relativizes to an absolute path rather than a - // `..` walk, so it has to be rejected separately. - return !isAbsolute(rel) -} - function entryKind(entry: { isFile(): boolean isDirectory(): boolean diff --git a/apps/desktop/src/main/native-directory.ts b/apps/desktop/src/main/native-directory.ts index 224d16b37dd..7aa7a4ec5b4 100644 --- a/apps/desktop/src/main/native-directory.ts +++ b/apps/desktop/src/main/native-directory.ts @@ -1,14 +1,34 @@ -import { fstat, read } from 'node:fs' +import { fstat, read, type Stats } from 'node:fs' import { join } from 'node:path' import { promisify } from 'node:util' import type { DesktopLocalFileRead } from '@sim/desktop-bridge' import { app } from 'electron' +import { openWin32NativeFile } from '@/main/win32-native-files' -interface NativeDirectoryListing { +/** At most `limit` entries of one directory, and whether more were left unread. */ +export interface NativeDirectoryListing { entries: NonNullable truncated: boolean } +/** A file or directory opened beneath the identity of a granted directory. */ +export interface NativeFileHandle { + readonly fd: number + stat: () => Promise + read: ( + buffer: Buffer, + offset: number, + length: number, + position: number + ) => Promise<{ bytesRead: number }> + /** + * Lists the opened directory, scanning at most `limit + 1` names. Symlinks + * (and, on Windows, junctions) are reported as `symlink`, never followed. + */ + readDirectory: (limit: number) => Promise + close: () => Promise +} + interface NativeDirectoryBridge { closeFile: (descriptor: number) => Promise readDirectory: (descriptor: number, limit: number) => Promise @@ -32,13 +52,17 @@ function nativeBridge(): NativeDirectoryBridge { return bridge } -/** Owns a descriptor opened beneath the identity of a granted directory. */ -export async function openNativeFile( +/** + * Opens through the POSIX native module, which walks each component with + * `openat(O_NOFOLLOW)` from a descriptor verified against the grant identity, + * and enumerates with `fdopendir` on the opened descriptor. + */ +export async function openPosixNativeFile( root: string, relativePath: string, identity: { dev: bigint; ino: bigint }, directory: boolean -) { +): Promise { let descriptor = await nativeBridge().openApproved( root, relativePath, @@ -51,8 +75,9 @@ export async function openNativeFile( return descriptor }, stat: () => statDescriptor(descriptor), - read: (buffer: Buffer, offset: number, length: number, position: number) => + read: (buffer, offset, length, position) => readDescriptor(descriptor, buffer, offset, length, position), + readDirectory: (limit) => nativeBridge().readDirectory(descriptor, limit), close: async () => { if (descriptor < 0) return const closing = descriptor @@ -62,10 +87,12 @@ export async function openNativeFile( } } -/** Enumerates the already-validated descriptor without resolving a pathname again. */ -export function readNativeDirectory( - descriptor: number, - limit: number -): Promise { - return nativeBridge().readDirectory(descriptor, limit) -} +/** + * Opens `relativePath` (in the host's separators) beneath `root`. Refuses + * unless `root` still has the granted `identity` and the target is a regular + * file, or a directory when `directory` is set. Node has no descriptor-relative + * open on Windows, so Windows uses {@link openWin32NativeFile}; macOS and Linux + * use the POSIX native module. + */ +export const openNativeFile = + process.platform === 'win32' ? openWin32NativeFile : openPosixNativeFile diff --git a/apps/desktop/src/main/path-containment.ts b/apps/desktop/src/main/path-containment.ts new file mode 100644 index 00000000000..5dc14795768 --- /dev/null +++ b/apps/desktop/src/main/path-containment.ts @@ -0,0 +1,23 @@ +import { isAbsolute, relative, sep } from 'node:path' + +/** + * Whether a resolved path is the granted root or sits beneath it. + * + * Uses `relative()` rather than prefix arithmetic. The `${root}${sep}` sentinel + * that form needs to reject `/granted-evil` against `/granted` becomes `//` when + * the root IS a separator — and the picker lets the user grant a volume root — + * so every path under a granted `/` was denied while the bare root passed. + * `relative()` has no such edge case: it returns `''` for the root itself and a + * leading `..` segment for anything outside, on both separators. On Windows it + * compares case-insensitively, as NTFS resolves names. + */ +export function isWithinRoot(rootPath: string, candidatePath: string): boolean { + const rel = relative(rootPath, candidatePath) + if (rel === '') return true + // A leading `..` SEGMENT, not the two characters: `..config` is an ordinary + // child name, and matching it as an escape would deny a real dotfile. + if (rel === '..' || rel.startsWith(`..${sep}`)) return false + // A different Windows volume relativizes to an absolute path rather than a + // `..` walk, so it has to be rejected separately. + return !isAbsolute(rel) +} diff --git a/apps/desktop/src/main/win32-native-files.test.ts b/apps/desktop/src/main/win32-native-files.test.ts new file mode 100644 index 00000000000..702205e5fbe --- /dev/null +++ b/apps/desktop/src/main/win32-native-files.test.ts @@ -0,0 +1,236 @@ +import { + mkdir, + mkdtemp, + open, + opendir, + realpath, + rename, + rm, + stat, + symlink, + unlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { app } from 'electron' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { LocalFilePermissions } from '@/main/local-file-permissions' +import { LocalFilesystemService } from '@/main/local-filesystem' +import { type NativeFileHandle, openPosixNativeFile } from '@/main/native-directory' +import { isWithinRoot } from '@/main/path-containment' +import { openWin32NativeFile, type Win32NativeFileSystem } from '@/main/win32-native-files' + +/* + * How the Windows backend could fail, one test each: + * 1. a junction inside the grant that points outside is opened, or followed by a listing; + * 2. a file beneath a junction parent resolves outside the grant and is opened; + * 3. a file replaced between its identity check and the open is read; + * 4. a case-variant spelling of the grant is refused, or a case-variant sibling + * (`Root2` beside `Root`) is accepted; + * 5. listing caps or the truncation flag drift from the POSIX module's; + * 6. a directory replaced while it is listed returns the replacement's names; + * 7. a reserved name, stream, or trailing dot reaches the filesystem; + * 8. a directory opened as a file fails differently than on POSIX; + * 9. a granted folder replaced since the grant is still opened. + */ + +const isWindows = process.platform === 'win32' + +/** Both backends run where they can; the POSIX native module is not built on Windows. */ +const BACKENDS: Array<[string, typeof openWin32NativeFile]> = [['Windows', openWin32NativeFile]] +if (!isWindows) BACKENDS.push(['POSIX', openPosixNativeFile]) + +/** A junction on Windows, which needs no privilege; a symlink elsewhere. */ +function linkDirectory(target: string, path: string) { + return symlink(target, path, isWindows ? 'junction' : 'dir') +} + +async function readAll(handle: NativeFileHandle): Promise { + try { + const buffer = Buffer.alloc(64) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) + return buffer.toString('utf8', 0, bytesRead) + } finally { + await handle.close() + } +} + +async function list(handle: NativeFileHandle) { + try { + return await handle.readDirectory(1000) + } finally { + await handle.close() + } +} + +let base: string +let root: string +let outside: string + +beforeEach(async () => { + vi.mocked(app.getAppPath).mockReturnValue(fileURLToPath(new URL('../..', import.meta.url))) + base = await realpath(await mkdtemp(join(tmpdir(), 'sim-win32-files-'))) + root = join(base, 'Root') + outside = join(base, 'Outside') + await mkdir(root) + await mkdir(outside) + await writeFile(join(outside, 'secret.txt'), 'outside') +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +async function openBeneath(relativePath: string, directory = false, fs?: Win32NativeFileSystem) { + return openWin32NativeFile(root, relativePath, await stat(root, { bigint: true }), directory, fs) +} + +describe('the Windows native file backend', () => { + it('refuses a junction inside the grant that points outside, and lists it as a link', async () => { + await linkDirectory(outside, join(root, 'escape')) + + await expect(openBeneath('escape', true)).rejects.toThrow('within the approved folder') + expect(await list(await openBeneath('', true))).toEqual({ + entries: [{ name: 'escape', kind: 'symlink' }], + truncated: false, + }) + }) + + it('refuses a file whose parent is a junction leading outside the grant', async () => { + await linkDirectory(outside, join(root, 'docs')) + + await expect(openBeneath(join('docs', 'secret.txt'))).rejects.toThrow( + 'within the approved folder' + ) + }) + + it('refuses a file replaced between its identity check and the open', async () => { + await writeFile(join(root, 'notes.txt'), 'original') + const replaceThenOpen: Win32NativeFileSystem = { + open: async (path) => { + await rename(path, join(base, 'moved.txt')) + await writeFile(path, 'replacement') + return open(path, 'r') + }, + opendir, + } + + await expect(openBeneath('notes.txt', false, replaceThenOpen)).rejects.toThrow( + 'changed while it was being opened' + ) + }) + + it.runIf(isWindows)('opens an in-root file through a case-variant spelling', async () => { + await writeFile(join(root, 'FILE.txt'), 'inside') + + const file = await openWin32NativeFile( + root.toLowerCase(), + 'file.txt', + await stat(root, { bigint: true }), + false + ) + + expect(await readAll(file)).toBe('inside') + }) + + it.runIf(isWindows)('refuses a case-variant sibling that extends the grant name', () => { + expect(isWithinRoot('C:\\Root', 'c:\\root\\file.txt')).toBe(true) + expect(isWithinRoot('C:\\Root', 'c:\\ROOT2\\file.txt')).toBe(false) + }) + + it('refuses a listing of a directory replaced while it was read', async () => { + await mkdir(join(root, 'docs')) + await writeFile(join(root, 'docs', 'real.txt'), 'real') + const replaceThenList: Win32NativeFileSystem = { + open: (path) => open(path, 'r'), + opendir: async (path) => { + await rename(path, join(root, 'docs-before')) + await mkdir(path) + await writeFile(join(path, 'planted.txt'), 'planted') + return opendir(path) + }, + } + const directory = await openBeneath('docs', true, replaceThenList) + + await expect(list(directory)).rejects.toThrow('changed while it was being read') + }) + + it.each([ + ['an alternate data stream', 'notes.txt:secret'], + ['a reserved device name', 'NUL'], + ['a reserved device name with an extension', 'con.txt'], + ['a trailing dot', 'notes.txt.'], + ['a trailing space', 'notes.txt '], + ])('refuses %s in the requested path', async (_label, name) => { + // POSIX can store these names, so only the segment check stands between them and the open. + if (!isWindows) await writeFile(join(root, name), 'named') + + await expect(openBeneath(name)).rejects.toThrow('within the approved folder') + }) + + it.runIf(isWindows)( + 'refuses an alternate data stream in a model path before asking for consent', + async () => { + const service = new LocalFilesystemService({ chooseDirectory: async () => null }) + await writeFile(join(root, 'notes.txt'), 'inside') + + await expect( + new LocalFilePermissions(service).authorize( + { toolName: 'read_local_file', args: { path: join(root, 'notes.txt:secret') } }, + { + parent: async () => null, + origin: 'https://sim.ai', + generation: 0, + signal: new AbortController().signal, + isCurrent: () => true, + revalidate: async () => true, + } + ) + ).rejects.toThrow('is not a plain Windows file or folder name') + } + ) +}) + +describe.each(BACKENDS)('the %s backend contract', (_name, openApproved) => { + async function openDirectory() { + return openApproved(root, '', await stat(root, { bigint: true }), true) + } + + it('lists 1,000 entries and flags truncation once a directory holds 1,001', async () => { + await Promise.all( + Array.from({ length: 1001 }, (_, index) => writeFile(join(root, `${index}.txt`), '')) + ) + + const listing = await list(await openDirectory()) + expect(listing.entries).toHaveLength(1000) + expect(listing.truncated).toBe(true) + + await unlink(join(root, '0.txt')) + const exact = await list(await openDirectory()) + expect(exact.entries).toHaveLength(1000) + expect(exact.truncated).toBe(false) + }) + + it('refuses a directory opened as a file with the regular-file error', async () => { + await mkdir(join(root, 'docs')) + + await expect( + openApproved(root, 'docs', await stat(root, { bigint: true }), false) + ).rejects.toThrow('The approved path is not a regular file or directory.') + }) + + it('refuses a granted folder replaced since the grant', async () => { + const granted = await stat(root, { bigint: true }) + await rename(root, join(base, 'Root-before')) + await mkdir(root) + + await expect(openApproved(root, '', granted, true)).rejects.toThrow( + 'The approved folder changed. Request access again.' + ) + }) +}) diff --git a/apps/desktop/src/main/win32-native-files.ts b/apps/desktop/src/main/win32-native-files.ts new file mode 100644 index 00000000000..d2a175415c5 --- /dev/null +++ b/apps/desktop/src/main/win32-native-files.ts @@ -0,0 +1,140 @@ +import type { Dir, Dirent } from 'node:fs' +import { type FileHandle, lstat, open, opendir, realpath } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' +import type { NativeDirectoryListing, NativeFileHandle } from '@/main/native-directory' +import { isWithinRoot } from '@/main/path-containment' +import { isWin32SafeSegment } from '@/main/win32-path' + +/** The opens a swap has to race. Tests interpose on them to swap a path mid-check. */ +export interface Win32NativeFileSystem { + open: (path: string) => Promise + opendir: (path: string) => Promise +} + +const NODE_FILE_SYSTEM: Win32NativeFileSystem = { open: (path) => open(path, 'r'), opendir } + +type EntryKind = NativeDirectoryListing['entries'][number]['kind'] + +interface Identity { + dev: bigint + ino: bigint +} + +interface KindInfo { + isFile(): boolean + isDirectory(): boolean + isSymbolicLink(): boolean +} + +const OUTSIDE_GRANT = 'The path must stay within the approved folder.' + +function sameIdentity(left: Identity, right: Identity): boolean { + return left.dev === right.dev && left.ino === right.ino +} + +function isRequestedKind(info: KindInfo, directory: boolean): boolean { + return directory ? info.isDirectory() : info.isFile() +} + +function kindOf(info: KindInfo): EntryKind { + if (info.isFile()) return 'file' + if (info.isDirectory()) return 'directory' + if (info.isSymbolicLink()) return 'symlink' + return 'other' +} + +/** + * Windows enumeration marks every reparse point as a link, OneDrive placeholders + * included, though those are ordinary files. `lstat` reports only symlinks and + * junctions as links, so it settles the kind without following anything. + */ +async function entryKind(directory: string, entry: Dirent): Promise { + if (!entry.isSymbolicLink()) return kindOf(entry) + try { + return kindOf(await lstat(join(directory, entry.name))) + } catch { + return 'other' + } +} + +/** + * Resolves `path` and requires that it is already canonical and beneath + * `rootReal`. A link or junction anywhere in it, even one pointing back inside + * the grant, is refused, as the POSIX module refuses any symlink component. + * A path that resolves to a network share is refused too. + */ +async function canonicalBeneath(rootReal: string, path: string): Promise { + const real = await realpath(path) + if (real.startsWith('\\\\') || !isWithinRoot(rootReal, real) || relative(path, real) !== '') + throw new Error(OUTSIDE_GRANT) + return real +} + +/** + * The Windows backend for {@link openNativeFile}, on Node fs. Node cannot open + * relative to a descriptor here, so each check resolves a pathname: the root's + * identity, a canonical path with no link component, the target's identity + * before the open matching the opened handle's, and the root and path again + * after it. A directory listing must leave the path naming the opened handle. + * This narrows, but cannot close, a race against a process that swaps a path + * component between a check and the open. Written against the host's + * `node:path`, so it also runs on POSIX, where tests exercise it. + */ +export async function openWin32NativeFile( + root: string, + relativePath: string, + identity: Identity, + directory: boolean, + fs: Win32NativeFileSystem = NODE_FILE_SYSTEM +): Promise { + const segments = relativePath === '' ? [] : relativePath.split(sep) + if (!segments.every(isWin32SafeSegment)) throw new Error(OUTSIDE_GRANT) + const assertRootCurrent = async () => { + const info = await lstat(root, { bigint: true }) + if (!info.isDirectory() || !sameIdentity(info, identity)) + throw new Error('The approved folder changed. Request access again.') + } + await assertRootCurrent() + const rootReal = await realpath(root) + const path = await canonicalBeneath(rootReal, join(root, ...segments)) + const checked = await lstat(path, { bigint: true }) + if (!isRequestedKind(checked, directory)) + throw new Error('The approved path is not a regular file or directory.') + + const handle = await fs.open(path) + try { + const opened = await handle.stat({ bigint: true }) + if (!sameIdentity(opened, checked) || !isRequestedKind(opened, directory)) + throw new Error('The local path changed while it was being opened. Try again.') + await canonicalBeneath(rootReal, path) + await assertRootCurrent() + } catch (error) { + await handle.close() + throw error + } + + return { + get fd() { + return handle.fd + }, + stat: () => handle.stat(), + read: (buffer, offset, length, position) => handle.read(buffer, offset, length, position), + readDirectory: async (limit) => { + const entries: NativeDirectoryListing['entries'] = [] + let truncated = false + for await (const entry of await fs.opendir(path)) { + if (entries.length === limit) { + truncated = true + break + } + entries.push({ name: entry.name, kind: await entryKind(path, entry) }) + } + const listed = await lstat(path, { bigint: true }) + if (!sameIdentity(listed, checked)) + throw new Error('The local directory changed while it was being read. Try again.') + await canonicalBeneath(rootReal, path) + return { entries, truncated } + }, + close: () => handle.close(), + } +} diff --git a/apps/desktop/src/shared/platform-features.ts b/apps/desktop/src/shared/platform-features.ts index ac6477fff6b..85946e1ae8b 100644 --- a/apps/desktop/src/shared/platform-features.ts +++ b/apps/desktop/src/shared/platform-features.ts @@ -15,8 +15,9 @@ export interface DesktopFeatures { terminalThemes: boolean /** * `read_local_file` and `import_local_files`. They open files beneath a - * granted directory's identity through the POSIX native module. The - * user-local VFS grants are separate and run on Node fs everywhere. + * granted directory's identity: through the POSIX native module on macOS + * and Linux, and through checked Node fs opens on Windows. The user-local + * VFS grants are separate and run on Node fs everywhere. */ localFiles: boolean /** Chrome cookie and password import. */ @@ -43,7 +44,7 @@ const WINDOWS_FEATURES: DesktopFeatures = { helpSearch: false, terminal: false, terminalThemes: false, - localFiles: false, + localFiles: true, browserImport: false, passwordCopy: false, } diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index c986e8e1a2d..093e5dbce4e 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -9,15 +9,11 @@ export default mergeConfig( setupFiles: ['src/test/setup.ts'], include: ['src/**/*.test.ts'], // These suites drive subsystems Windows builds do not ship (tmux, the macOS computer-use - // helper, the POSIX native file module); see src/shared/platform-features.ts. + // helper); see src/shared/platform-features.ts. exclude: [ '**/e2e/**', ...(process.platform === 'win32' - ? [ - 'src/main/terminal/tmux.test.ts', - 'src/main/computer-use/native-client.test.ts', - 'src/main/local-files.test.ts', - ] + ? ['src/main/terminal/tmux.test.ts', 'src/main/computer-use/native-client.test.ts'] : []), ], pool: 'threads', From 5a87b83e19cd96bbab9bfa8181b08a0146d74efa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:34:18 -0700 Subject: [PATCH 24/67] feat(terminal-protocol): name Windows programs and carry each terminal's shell describeRunningCommand splits a program path on either separator, drops .exe, and reads a backslash literally in PowerShell and cmd. Tab state gains an optional shell, its program name. --- packages/terminal-protocol/src/index.test.ts | 9 ++++++ packages/terminal-protocol/src/index.ts | 30 +++++++++++++++----- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/terminal-protocol/src/index.test.ts b/packages/terminal-protocol/src/index.test.ts index 541d7e2d8ed..4b5348b4038 100644 --- a/packages/terminal-protocol/src/index.test.ts +++ b/packages/terminal-protocol/src/index.test.ts @@ -26,4 +26,13 @@ describe('describeRunningCommand', () => { expect(describeRunningCommand('sudo /usr/bin/docker compose up')).toBe('docker') expect(describeRunningCommand('env FOO=1 nohup python train.py')).toBe('python') }) + + it('names a Windows program by its file name without the directory or .exe', () => { + expect(describeRunningCommand('C:\\tools\\node.exe server.js', 'pwsh')).toBe('node') + expect(describeRunningCommand('& "C:\\Git\\bin\\git.EXE" status', 'pwsh')).toBe('git') + }) + + it('reads a backslash as a path separator in Windows shells, never an escape', () => { + expect(describeRunningCommand('cd C:\\; npm test', 'powershell')).toBe('npm') + }) }) diff --git a/packages/terminal-protocol/src/index.ts b/packages/terminal-protocol/src/index.ts index 78c9817a2ec..ad32d3a5e4b 100644 --- a/packages/terminal-protocol/src/index.ts +++ b/packages/terminal-protocol/src/index.ts @@ -273,6 +273,12 @@ export interface TerminalTabState { */ interactive: boolean active: boolean + /** + * The shell running in this terminal, as its program name without a + * directory or `.exe` (`zsh`, `bash`, `pwsh`, `powershell`, `cmd`). Absent + * from shells that predate the field, which all run on macOS. + */ + shell?: string /** * The tmux session attached in this terminal, when one is. Its windows and * panes are tmux's to manage — `panes` lists them; Sim's tab strip stays a @@ -302,12 +308,19 @@ const COMMAND_PREFIX_WORDS = new Set([ /** `NAME=value`, the other thing that can sit in front of the program. */ const ENVIRONMENT_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/ +/** + * Windows shells, where a backslash separates path segments rather than + * escaping the next character (PowerShell escapes with a backtick, cmd with a + * caret). + */ +const LITERAL_BACKSLASH_SHELLS: ReadonlySet = new Set(['pwsh', 'powershell', 'cmd']) + /** * The last command in a shell line, ignoring separators inside quotes. A * quote-blind split would cut `claude "a && b"` in half and report `b"` as the * program. */ -function lastCommandSegment(command: string): string { +function lastCommandSegment(command: string, backslashEscapes: boolean): string { let start = 0 let quote: "'" | '"' | null = null for (let index = 0; index < command.length; index++) { @@ -315,7 +328,7 @@ function lastCommandSegment(command: string): string { if (quote) { // Only double quotes honor backslash escapes; inside single quotes a // backslash is a literal character and cannot hide the closing quote. - if (char === '\\' && quote === '"') index++ + if (char === '\\' && quote === '"' && backslashEscapes) index++ else if (char === quote) quote = null continue } @@ -323,7 +336,7 @@ function lastCommandSegment(command: string): string { quote = char continue } - if (char === '\\') { + if (char === '\\' && backslashEscapes) { index++ continue } @@ -353,12 +366,14 @@ function lastCommandSegment(command: string): string { * @example * describeRunningCommand('cd /repo && export PATH=/bin && claude "fix it"') // 'claude' * describeRunningCommand('sudo /usr/bin/docker compose up') // 'docker' + * describeRunningCommand('cd C:\repo; C:\tools\node.exe x', 'pwsh') // 'node' */ -export function describeRunningCommand(running: string): string { +export function describeRunningCommand(running: string, shell?: string): string { const line = running.trim() if (!line) return 'a command' - const segment = lastCommandSegment(line) || line + const backslashEscapes = !LITERAL_BACKSLASH_SHELLS.has(shell ?? '') + const segment = lastCommandSegment(line, backslashEscapes) || line const program = segment .split(/\s+/) .filter(Boolean) @@ -373,9 +388,10 @@ export function describeRunningCommand(running: string): string { (program ? (program .replace(/^['"]|['"]$/g, '') - .split('/') + .split(backslashEscapes ? '/' : /[\\/]/) .filter(Boolean) - .pop() ?? '') + .pop() + ?.replace(/\.exe$/i, '') ?? '') : '') || segment return truncate(label, MAX_RUNNING_COMMAND_LABEL, '…') } From 2e39dfeba47167340f5d22e4af7655658dc439d4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:34:19 -0700 Subject: [PATCH 25/67] feat(desktop): run and instrument Windows shells in the agent terminal Windows resolves PowerShell 7 on PATH, then Windows PowerShell 5.1, then cmd, and honours a SHELL set to a Windows path such as Git Bash. PowerShell loads the user's profiles itself after the startup marker and wraps their prompt and PSReadLine's PSConsoleHostReadLine to emit the same OSC 633 markers, delivered as -EncodedCommand so the default Restricted policy does not block it. Git Bash directories are reported in Windows form, an escaped backslash now decodes to one, the tab title splits on either separator, and a terminating kill ends the shell without a signal, which node-pty refuses on Windows. --- .../desktop/src/main/terminal/session.test.ts | 96 +++++++++-- apps/desktop/src/main/terminal/session.ts | 51 ++++-- .../main/terminal/shell-integration.test.ts | 32 ++++ .../src/main/terminal/shell-integration.ts | 163 ++++++++++++++++-- .../src/main/terminal/shell-path.test.ts | 57 ++++++ apps/desktop/src/main/terminal/shell-path.ts | 72 ++++++++ 6 files changed, 436 insertions(+), 35 deletions(-) create mode 100644 apps/desktop/src/main/terminal/shell-path.test.ts create mode 100644 apps/desktop/src/main/terminal/shell-path.ts diff --git a/apps/desktop/src/main/terminal/session.test.ts b/apps/desktop/src/main/terminal/session.test.ts index ce2725af3ae..3f5b87c319e 100644 --- a/apps/desktop/src/main/terminal/session.test.ts +++ b/apps/desktop/src/main/terminal/session.test.ts @@ -11,23 +11,34 @@ const ptyStub = vi.hoisted(() => ({ dataHandler: null as ((data: string) => void) | null, exitHandler: null as (() => void) | null, writes: [] as string[], + args: [] as string[], + /** node-pty on Windows throws when `kill` is given a signal. */ + signalsSupported: true, + ended: false, })) vi.mock('@lydell/node-pty', () => ({ - spawn: () => ({ - pid: 1234, - onData: (handler: (data: string) => void) => { - ptyStub.dataHandler = handler - }, - onExit: (handler: () => void) => { - ptyStub.exitHandler = handler - }, - write: (data: string) => ptyStub.writes.push(data), - resize: vi.fn(), - kill: vi.fn(), - pause: vi.fn(), - resume: vi.fn(), - }), + spawn: (_file: string, args: string[]) => { + ptyStub.args = args + return { + pid: 1234, + onData: (handler: (data: string) => void) => { + ptyStub.dataHandler = handler + }, + onExit: (handler: () => void) => { + ptyStub.exitHandler = handler + }, + write: (data: string) => ptyStub.writes.push(data), + resize: vi.fn(), + kill: (signal?: string) => { + if (signal && !ptyStub.signalsSupported) + throw new Error('Signals not supported on windows.') + ptyStub.ended = true + }, + pause: vi.fn(), + resume: vi.fn(), + } + }, })) vi.mock('@/main/terminal/shell-integration', async (importOriginal) => { @@ -40,6 +51,8 @@ afterEach(() => { ptyStub.dataHandler = null ptyStub.exitHandler = null ptyStub.writes.length = 0 + ptyStub.signalsSupported = true + ptyStub.ended = false }) describe('toInputChunks', () => { @@ -221,3 +234,58 @@ describe('TerminalSession command lifecycle', () => { } }) }) + +describe('TerminalSession on Windows', () => { + const marker = (body: string) => `\u001b]633;${body};test-nonce\u0007` + + function windowsSession(): TerminalSession { + return TerminalSession.create({ + terminalId: 'terminal-windows', + cwd: 'C:\\Users\\me', + cols: 80, + rows: 24, + platform: 'win32', + callbacks: { onData: () => {}, onState: () => {}, onCommand: () => {}, onExit: () => {} }, + }) + } + + it('starts the shell without the POSIX login flag, which Windows shells reject', () => { + const session = windowsSession() + try { + expect(ptyStub.args).not.toContain('-l') + } finally { + session.dispose() + } + }) + + it('ends the shell for a terminating signal, which node-pty refuses to send there', () => { + ptyStub.signalsSupported = false + const session = windowsSession() + try { + session.kill('SIGTERM') + expect(ptyStub.ended).toBe(true) + } finally { + session.dispose() + } + }) + + it("reports Git Bash's directory in the form Windows opens", () => { + const session = windowsSession() + try { + ptyStub.dataHandler?.(marker('P;Cwd=/c/Users/me/repo')) + expect(session.currentCwd).toBe('C:\\Users\\me\\repo') + } finally { + session.dispose() + } + }) + + it('names the tab after the last folder of a Windows path', () => { + const session = windowsSession() + try { + ptyStub.dataHandler?.(marker('P;Cwd=D:\\\\work\\\\project')) + expect(session.tabState(true).title).toBe('project') + } finally { + session.dispose() + } + }) +}) diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts index 60afc3e54e3..9d6517f04e3 100644 --- a/apps/desktop/src/main/terminal/session.ts +++ b/apps/desktop/src/main/terminal/session.ts @@ -6,7 +6,7 @@ * batching, the scrollback ring buffer, and the command lifecycle derived from * shell-integration markers. */ -import { mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { IPty } from '@lydell/node-pty' @@ -36,7 +36,10 @@ import { createNonce, detectShell, ShellIntegrationParser, + shellKind, + toWindowsPath, } from '@/main/terminal/shell-integration' +import { resolveShellPath } from '@/main/terminal/shell-path' const logger = createLogger('DesktopTerminalSession') @@ -294,6 +297,23 @@ export interface TerminalSessionOptions { cols: number rows: number callbacks: TerminalSessionCallbacks + /** The operating system the shell runs on; the current one by default. */ + platform?: NodeJS.Platform +} + +/** + * The Windows shell, found once for each `SHELL`: the search stats every PATH + * entry, and which shells are installed does not change while Sim runs. + */ +let windowsShell: { configured: string | undefined; path: string } | null = null + +function shellPathFor(platform: NodeJS.Platform): string { + const context = { platform, env: process.env, exists: existsSync } + if (platform !== 'win32') return resolveShellPath(context) + if (windowsShell === null || windowsShell.configured !== process.env.SHELL) { + windowsShell = { configured: process.env.SHELL, path: resolveShellPath(context) } + } + return windowsShell.path } export class TerminalSession { @@ -353,7 +373,8 @@ export class TerminalSession { nonce: string, shellName: string, shellEnv: NodeJS.ProcessEnv, - private readonly instrumented: boolean + private readonly instrumented: boolean, + private readonly platform: NodeJS.Platform ) { this.callbacks = options.callbacks this.terminalId = options.terminalId @@ -371,7 +392,8 @@ export class TerminalSession { } static create(options: TerminalSessionOptions): TerminalSession { - const shellPath = process.env.SHELL || '/bin/zsh' + const platform = options.platform ?? process.platform + const shellPath = shellPathFor(platform) const shell = detectShell(shellPath) const nonce = createNonce() const integrationDir = mkdtempSync(join(tmpdir(), 'sim-terminal-')) @@ -383,10 +405,11 @@ export class TerminalSession { const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = process.env as Record // A shell we cannot instrument still gives the user a working terminal; - // the agent is refused separately via NO_SHELL_INTEGRATION. + // the agent is refused separately via NO_SHELL_INTEGRATION. Windows shells + // have no login mode. const launch = shell ? buildShellLaunch(shell, integrationDir, nonce, env) - : { args: ['-l'], env: {} } + : { args: platform === 'win32' ? [] : ['-l'], env: {} } const shellEnv = { ...env, ...launch.env, TERM: 'xterm-256color', TERM_PROGRAM: 'Sim' } const pty = spawn(shellPath, launch.args, { @@ -405,7 +428,8 @@ export class TerminalSession { nonce, shell ?? shellPath, shellEnv, - shell !== null + shell !== null, + platform ) } @@ -485,7 +509,7 @@ export class TerminalSession { * at a glance. */ tabState(active: boolean): TerminalTabState { - const directory = this.cwd ? (this.cwd.split('/').filter(Boolean).pop() ?? '/') : null + const directory = this.cwd ? (this.cwd.split(/[\\/]/).filter(Boolean).pop() ?? '/') : null return { terminalId: this.terminalId, // The directory, always: whether to show the running command instead is @@ -502,6 +526,7 @@ export class TerminalSession { running: this.foregroundCommand === null ? null : this.foregroundCommand.trim() || 'command', interactive: this.altScreen, active, + shell: shellKind(this.shellName), } } @@ -643,7 +668,9 @@ export class TerminalSession { return } try { - this.pty.kill(signal) + // node-pty on Windows throws when given a signal: there it can only end the shell. + if (this.platform === 'win32') this.pty.kill() + else this.pty.kill(signal) } catch (error) { logger.warn('Failed to signal pty', { signal, error: (error as Error).message }) } @@ -884,12 +911,14 @@ export class TerminalSession { case 'output-end': this.finishCommand(marker.exitCode) break - case 'cwd': - if (marker.cwd && marker.cwd !== this.cwd) { - this.cwd = marker.cwd + case 'cwd': { + const cwd = this.platform === 'win32' ? toWindowsPath(marker.cwd) : marker.cwd + if (cwd && cwd !== this.cwd) { + this.cwd = cwd this.emitState() } break + } } } diff --git a/apps/desktop/src/main/terminal/shell-integration.test.ts b/apps/desktop/src/main/terminal/shell-integration.test.ts index 9c16588b5f0..5d02a1dc300 100644 --- a/apps/desktop/src/main/terminal/shell-integration.test.ts +++ b/apps/desktop/src/main/terminal/shell-integration.test.ts @@ -7,6 +7,7 @@ import { buildShellLaunch, detectShell, ShellIntegrationParser, + toWindowsPath, } from '@/main/terminal/shell-integration' const NONCE = 'testnonce' @@ -73,6 +74,14 @@ describe('ShellIntegrationParser', () => { expect(markers).toEqual([{ kind: 'cwd', cwd: '/tmp/some dir' }]) }) + it('reads an escaped backslash as one, so a Windows cwd survives intact', () => { + const parser = new ShellIntegrationParser(NONCE) + // A folder named x3b: its escaped form must not decode the `\x3b` into a `;`. + const { markers } = parser.parse(osc(`P;Cwd=C:\\\\Users\\\\x3b;${NONCE}`)) + + expect(markers).toEqual([{ kind: 'cwd', cwd: 'C:\\Users\\x3b' }]) + }) + it("recognises the startup marker sent before the user's startup files", () => { const parser = new ShellIntegrationParser(NONCE) const { text, markers } = parser.parse(`${osc(`SimStartup;${NONCE}`)}loading`) @@ -104,12 +113,35 @@ describe('detectShell', () => { expect(detectShell('/usr/local/bin/bash')).toBe('bash') }) + it('recognises Windows shells by their executable names', () => { + expect(detectShell('C:\\Program Files\\PowerShell\\7\\pwsh.exe')).toBe('pwsh') + expect(detectShell('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\PowerShell.exe')).toBe( + 'powershell' + ) + expect(detectShell('C:\\Program Files\\Git\\bin\\bash.exe')).toBe('bash') + expect(detectShell('C:\\Windows\\System32\\cmd.exe')).toBeNull() + }) + it('returns null for shells without hooks, leaving the terminal uninstrumented', () => { expect(detectShell('/usr/bin/fish')).toBeNull() expect(detectShell('/bin/sh')).toBeNull() }) }) +describe('toWindowsPath', () => { + it('turns the drive paths Git Bash reports into ones Windows can open', () => { + expect(toWindowsPath('/c/Users/me/my repo')).toBe('C:\\Users\\me\\my repo') + expect(toWindowsPath('/cygdrive/d/work')).toBe('D:\\work') + expect(toWindowsPath('/c')).toBe('C:\\') + }) + + it('leaves Windows paths and MSYS-only directories alone', () => { + expect(toWindowsPath('C:\\Users\\me')).toBe('C:\\Users\\me') + expect(toWindowsPath('\\\\server\\share')).toBe('\\\\server\\share') + expect(toWindowsPath('/tmp')).toBe('/tmp') + }) +}) + /** * The generated startup files run in a real shell, with the user's own files standing in as a * temp home whose startup prints a line. The startup marker has to reach the terminal before that diff --git a/apps/desktop/src/main/terminal/shell-integration.ts b/apps/desktop/src/main/terminal/shell-integration.ts index da7349bbb27..c15af44397e 100644 --- a/apps/desktop/src/main/terminal/shell-integration.ts +++ b/apps/desktop/src/main/terminal/shell-integration.ts @@ -17,16 +17,34 @@ */ import { randomBytes } from 'node:crypto' import { mkdirSync, writeFileSync } from 'node:fs' -import { basename, join } from 'node:path' +import { join, sep } from 'node:path' /** Shells we can install prompt hooks into. */ -export type SupportedShell = 'zsh' | 'bash' +export type SupportedShell = 'zsh' | 'bash' | 'pwsh' | 'powershell' + +const SUPPORTED_SHELLS: ReadonlySet = new Set([ + 'zsh', + 'bash', + 'pwsh', + 'powershell', +]) + +/** + * A shell's program name, as it is reported to the agent: no directory (either + * separator), no login dash, no `.exe`. `C:\Windows\...\PowerShell.exe` is + * `powershell`, `-zsh` is `zsh`. + */ +export function shellKind(shellPath: string): string { + const name = shellPath.split(/[\\/]/).pop() ?? '' + return name + .replace(/^-/, '') + .replace(/\.exe$/i, '') + .toLowerCase() +} export function detectShell(shellPath: string): SupportedShell | null { - const name = basename(shellPath) - if (name === 'zsh' || name === '-zsh') return 'zsh' - if (name === 'bash' || name === '-bash') return 'bash' - return null + const kind = shellKind(shellPath) + return SUPPORTED_SHELLS.has(kind) ? (kind as SupportedShell) : null } export function createNonce(): string { @@ -55,13 +73,30 @@ export interface ParseResult { markers: ShellMarker[] } -/** Undoes the escaping applied by the shell hooks. */ +/** + * Undoes the escaping applied by the shell hooks: `\\` is one backslash and + * `\xHH` one character. Both are read in one pass, so `C:\x3b`, which the + * hooks send as `C:\\x3b`, comes back unchanged rather than as `C:\;`. + */ function unescapeValue(value: string): string { - return value.replace(/\\x([0-9a-fA-F]{2})/g, (_match, hex: string) => - String.fromCharCode(Number.parseInt(hex, 16)) + return value.replace(/\\(?:\\|x([0-9a-fA-F]{2}))/g, (_match, hex: string | undefined) => + hex === undefined ? '\\' : String.fromCharCode(Number.parseInt(hex, 16)) ) } +/** + * The Windows form of a directory Git Bash reports. MSYS shows drives as + * `/c/Users/me` (Cygwin as `/cygdrive/c/...`), which nothing outside the shell + * can open. Anything else, including a path already in Windows form, is + * returned unchanged. + */ +export function toWindowsPath(cwd: string): string { + const drive = /^\/(?:cygdrive\/)?([a-zA-Z])(?=\/|$)(.*)$/.exec(cwd) + if (!drive) return cwd + const [, letter, rest] = drive + return `${letter.toUpperCase()}:${rest.replaceAll('/', '\\') || '\\'}` +} + /** * Incremental parser. PTY chunks split escape sequences at arbitrary byte * offsets, so a partial trailing sequence is held back until the rest arrives @@ -286,6 +321,99 @@ PROMPT_COMMAND="__sim_precmd\${PROMPT_COMMAND:+; $PROMPT_COMMAND}" return rcPath } +/** + * PowerShell (7 and Windows PowerShell 5.1) has no preexec hook either. The + * command line is caught by wrapping PSReadLine's `PSConsoleHostReadLine`, + * which returns each line the user accepts, and the finish by wrapping + * `prompt`. Without PSReadLine there is no line to report, so nothing is + * installed and the agent is refused as for any uninstrumented shell. + * + * The shell starts with `-NoProfile` and this script loads the profiles + * itself, in PowerShell's own order, so the startup marker goes out before any + * of the user's code runs and the hooks wrap the prompt their profile set + * (oh-my-posh, starship) rather than being replaced by it. The script travels + * as `-EncodedCommand` because a `.ps1` file is refused under Windows + * PowerShell's default `Restricted` execution policy; the profiles stay under + * the user's policy exactly as they would without us. + * + * The exit code is 0 when the line succeeded, else `$LASTEXITCODE` when a + * native program set a failing one, else 1 (a failing cmdlet has no code). + */ +function powerShellScript(nonce: string): string { + return String.raw`$Global:__SimNonce = '${nonce}' +$Global:__SimInCommand = $false + +function Global:__Sim-Osc([string]$Body) { + "$([char]27)]633;$Body;$Global:__SimNonce$([char]7)" +} + +function Global:__Sim-Escape([string]$Value) { + [regex]::Replace($Value, '[\\;\x00-\x1f]', { + param($m) + if ($m.Value -eq '\') { '\\' } else { '\x{0:x2}' -f [int][char]$m.Value } + }) +} + +[Console]::Write((__Sim-Osc 'SimStartup')) + +foreach ($__simProfile in @($PROFILE.AllUsersAllHosts, $PROFILE.AllUsersCurrentHost, $PROFILE.CurrentUserAllHosts, $PROFILE.CurrentUserCurrentHost)) { + if ($__simProfile -and (Test-Path -LiteralPath $__simProfile -PathType Leaf)) { + try { . $__simProfile } catch { Write-Error -ErrorRecord $_ } + } +} +Remove-Variable -Name __simProfile -ErrorAction SilentlyContinue + +if (-not (Get-Module -Name PSReadLine)) { Import-Module PSReadLine -ErrorAction SilentlyContinue } + +if (Test-Path -Path Function:\PSConsoleHostReadLine) { + # Sim clears a half-typed line with Ctrl+U before typing a command; Windows mode leaves it unbound. + try { + if (-not (Get-PSReadLineKeyHandler -Bound | Where-Object { $_.Key -eq 'Ctrl+u' })) { + Set-PSReadLineKeyHandler -Chord 'Ctrl+u' -Function BackwardDeleteLine + } + } catch {} + + $Global:__SimReadLine = $function:PSConsoleHostReadLine + function Global:PSConsoleHostReadLine { + $line = & $Global:__SimReadLine + if (-not [string]::IsNullOrWhiteSpace($line)) { + $Global:__SimInCommand = $true + [Console]::Write((__Sim-Osc "E;$(__Sim-Escape $line)") + (__Sim-Osc 'C')) + } + $line + } + + $Global:__SimPrompt = $function:prompt + if (-not $Global:__SimPrompt) { $Global:__SimPrompt = { "PS $($PWD.Path)> " } } + function Global:prompt { + $succeeded = $global:? + $nativeExitCode = $global:LASTEXITCODE + # Cwd is reported before the finish marker so a cd is already visible by the time the + # command's result is resolved. + $markers = '' + if ($PWD.Provider.Name -eq 'FileSystem') { + $markers += (__Sim-Osc "P;Cwd=$(__Sim-Escape $PWD.ProviderPath)") + } + if ($Global:__SimInCommand) { + $Global:__SimInCommand = $false + $exitCode = if ($succeeded) { 0 } elseif ($nativeExitCode -is [int] -and $nativeExitCode -ne 0) { $nativeExitCode } else { 1 } + $markers += (__Sim-Osc "D;$exitCode") + } + $markers += (__Sim-Osc 'A') + # Hands the user's prompt the $? it would have seen, for prompts that show the last result. + if (-not $succeeded) { Write-Error 'failure' -ErrorAction Ignore } + $userPrompt = (& $Global:__SimPrompt) -join '' + $markers + $userPrompt + (__Sim-Osc 'B') + } +} +` +} + +/** PowerShell's `-EncodedCommand` takes base64 of UTF-16LE text. */ +export function encodePowerShellCommand(script: string): string { + return Buffer.from(script, 'utf16le').toString('base64') +} + export interface ShellLaunch { args: string[] env: Record @@ -301,6 +429,19 @@ export function buildShellLaunch( nonce: string, env: Record ): ShellLaunch { + if (shell === 'pwsh' || shell === 'powershell') { + return { + args: [ + '-NoLogo', + '-NoProfile', + '-NoExit', + '-EncodedCommand', + encodePowerShellCommand(powerShellScript(nonce)), + ], + env: {}, + } + } + mkdirSync(dir, { recursive: true }) if (shell === 'zsh') { @@ -312,7 +453,9 @@ export function buildShellLaunch( } const rcPath = writeBashFile(dir, nonce) + // Git Bash reads `C:/...` without the MS-DOS path warning `C:\...` can raise. + const initFile = sep === '\\' ? rcPath.replaceAll('\\', '/') : rcPath // `--init-file` is honoured only by interactive non-login bash, so the login // flag is deliberately omitted here; the generated file sources ~/.bashrc. - return { args: ['--init-file', rcPath, '-i'], env: {} } + return { args: ['--init-file', initFile, '-i'], env: {} } } diff --git a/apps/desktop/src/main/terminal/shell-path.test.ts b/apps/desktop/src/main/terminal/shell-path.test.ts new file mode 100644 index 00000000000..5450ebedf84 --- /dev/null +++ b/apps/desktop/src/main/terminal/shell-path.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { resolveShellPath } from '@/main/terminal/shell-path' + +const WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' + +/** A Windows machine whose disk holds exactly `files`. */ +function windows(env: NodeJS.ProcessEnv, files: string[]) { + return resolveShellPath({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows', ...env }, + exists: (path) => files.includes(path), + }) +} + +describe('resolveShellPath on Windows', () => { + it('prefers PowerShell 7 on PATH over Windows PowerShell', () => { + const pwsh = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' + expect( + windows({ Path: 'C:\\Windows;C:\\Program Files\\PowerShell\\7\\' }, [ + pwsh, + WINDOWS_POWERSHELL, + ]) + ).toBe(pwsh) + }) + + it('finds PATH and SystemRoot whatever case Windows stored them in', () => { + const pwsh = 'D:\\pwsh\\pwsh.exe' + expect(windows({ PATH: 'D:\\pwsh' }, [pwsh])).toBe(pwsh) + expect( + resolveShellPath({ + platform: 'win32', + env: { SYSTEMROOT: 'E:\\Win' }, + exists: (path) => path === 'E:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + }) + ).toBe('E:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe') + }) + + it('never takes a pwsh.exe from a relative PATH entry, which resolves against the cwd', () => { + expect( + windows({ Path: '.;tools' }, ['.\\pwsh.exe', 'tools\\pwsh.exe', WINDOWS_POWERSHELL]) + ).toBe(WINDOWS_POWERSHELL) + }) + + it('falls back to cmd when no PowerShell is installed', () => { + expect(windows({ ComSpec: 'C:\\Windows\\system32\\cmd.exe' }, [])).toBe( + 'C:\\Windows\\system32\\cmd.exe' + ) + }) + + it('runs a SHELL set to a Windows path, and ignores the POSIX one MSYS exports', () => { + const gitBash = 'C:\\Program Files\\Git\\bin\\bash.exe' + expect(windows({ SHELL: gitBash }, [gitBash, WINDOWS_POWERSHELL])).toBe(gitBash) + expect(windows({ SHELL: '/usr/bin/bash' }, ['/usr/bin/bash', WINDOWS_POWERSHELL])).toBe( + WINDOWS_POWERSHELL + ) + }) +}) diff --git a/apps/desktop/src/main/terminal/shell-path.ts b/apps/desktop/src/main/terminal/shell-path.ts new file mode 100644 index 00000000000..2019dbacd30 --- /dev/null +++ b/apps/desktop/src/main/terminal/shell-path.ts @@ -0,0 +1,72 @@ +/** + * Which shell a new terminal runs. + * + * macOS and Linux run the user's login shell from `SHELL`. Windows has no such + * variable of its own, so the terminal picks the best shell installed: + * PowerShell 7 when it is on PATH, else the Windows PowerShell 5.1 every + * Windows ships, else cmd. + */ +import { win32 } from 'node:path' + +export interface ShellPathContext { + platform: NodeJS.Platform + env: NodeJS.ProcessEnv + /** Whether a file exists at an absolute path. */ + exists(path: string): boolean +} + +/** A Windows path anchored to a drive (`C:\`) or a share (`\\server\share`). */ +const WINDOWS_ABSOLUTE_PATH = /^(?:[a-zA-Z]:[\\/]|\\\\)/ + +/** The shell program a new terminal spawns. */ +export function resolveShellPath(context: ShellPathContext): string { + if (context.platform !== 'win32') return context.env.SHELL || '/bin/zsh' + return configuredWindowsShell(context) ?? defaultWindowsShell(context) +} + +/** + * `SHELL` set to a Windows path, such as Git Bash's `bash.exe`. The MSYS + * environment exports a POSIX `SHELL` (`/usr/bin/bash`) that nothing outside + * it can launch, so only a Windows path that exists counts. + */ +function configuredWindowsShell({ env, exists }: ShellPathContext): string | null { + const shell = env.SHELL + return shell && WINDOWS_ABSOLUTE_PATH.test(shell) && exists(shell) ? shell : null +} + +function defaultWindowsShell(context: ShellPathContext): string { + const systemRoot = windowsEnv(context.env, 'SystemRoot') || 'C:\\Windows' + const windowsPowerShell = win32.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ) + return ( + findOnPath('pwsh.exe', context) ?? + (context.exists(windowsPowerShell) ? windowsPowerShell : null) ?? + (windowsEnv(context.env, 'ComSpec') || win32.join(systemRoot, 'System32', 'cmd.exe')) + ) +} + +function findOnPath(program: string, { env, exists }: ShellPathContext): string | null { + for (const entry of (windowsEnv(env, 'Path') ?? '').split(';')) { + const directory = entry.trim().replace(/^"|"$/g, '') + if (!WINDOWS_ABSOLUTE_PATH.test(directory)) continue + const candidate = win32.join(directory, program) + if (exists(candidate)) return candidate + } + return null +} + +/** + * Reads a Windows environment variable, whose name is case-insensitive. A copy + * of `process.env` loses the case-insensitive lookup Node gives the original, + * and Windows itself stores `Path` and `SystemRoot` in mixed case. + */ +function windowsEnv(env: NodeJS.ProcessEnv, name: string): string | undefined { + const lower = name.toLowerCase() + const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === lower) + return key === undefined ? undefined : env[key] +} From e5e376a3f8a1c8df00971f77ccbabe6f457e4056 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:34:20 -0700 Subject: [PATCH 26/67] feat(desktop): stop a Windows terminal command by its process tree Windows has no process groups, so Stop reads the shell's child processes and escalates with taskkill /T /F on each, never the shell. Escalation now signals only those of the stopped command's processes that still hold the foreground. --- apps/desktop/src/main/terminal/index.ts | 30 ++-- .../src/main/terminal/process-group.ts | 140 ++++++++++++++---- .../desktop/src/main/terminal/service.test.ts | 29 +++- 3 files changed, 147 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts index c9334dab154..52114ddfdc1 100644 --- a/apps/desktop/src/main/terminal/index.ts +++ b/apps/desktop/src/main/terminal/index.ts @@ -37,7 +37,7 @@ import { isResourceTabSelectionShortcut, resourceTabTargetIndex, } from '@/main/resource-shortcuts' -import { readForegroundProcessGroup, signalProcessGroup } from '@/main/terminal/process-group' +import { osProcessGroups, type TerminalProcessGroups } from '@/main/terminal/process-group' import type { RunLedger } from '@/main/terminal/run-ledger' import { elide, type ShellStartupBounds, TerminalSession } from '@/main/terminal/session' import { @@ -160,22 +160,12 @@ export interface TerminalServiceOptions { */ loadCwd?(): string | undefined canSpawn?(): boolean - /** Reads and signals a terminal's foreground process group; the OS's by default. */ + /** Reads and signals a terminal's foreground command; the OS's by default. */ processGroups?: TerminalProcessGroups /** Where tagged tmux runs are recorded, so a later process can still stop them. */ runLedger?: RunLedger } -interface TerminalProcessGroups { - foreground(shellPid: number): Promise - signal(pgid: number, signal: NodeJS.Signals): void -} - -const OS_PROCESS_GROUPS: TerminalProcessGroups = { - foreground: readForegroundProcessGroup, - signal: signalProcessGroup, -} - /** * One tool call's Stop: remembered if it arrives before the call's command starts, and handed to * whatever is running once it does. @@ -966,25 +956,27 @@ export class TerminalService { } /** - * Interrupts the command a run started. Escalation is bound to that command's own process - * group, read while it still holds the foreground, and each signal is sent only while the same - * call and the same group still hold it, so a command the user starts meanwhile is never hit. + * Interrupts the command a run started. Escalation is bound to that command's own processes, + * read while it still holds the foreground, and each signal goes only to those of them that still + * hold it while the same call does, so a command the user starts meanwhile is never hit. */ private async stopCommand(session: TerminalSession, toolCallId: string): Promise { if (session.agentCommandToolCallId !== toolCallId) return - const groups = this.options.processGroups ?? OS_PROCESS_GROUPS - const pgid = await groups.foreground(session.pid) + const groups = this.options.processGroups ?? osProcessGroups(process.platform) + const command = await groups.foreground(session.pid) if (session.agentCommandToolCallId !== toolCallId) return session.kill('SIGINT') for (const escalation of ['SIGTERM', 'SIGKILL'] as const) { if (await this.commandEnds(session, toolCallId, STOP_ESCALATION_MS)) return - if (pgid === null || (await groups.foreground(session.pid)) !== pgid) return + const foreground = await groups.foreground(session.pid) + const remaining = command.filter((id) => foreground.includes(id)) + if (remaining.length === 0) return if (session.agentCommandToolCallId !== toolCallId) return logger.info('Stopped command ignored the previous signal; escalating', { toolCallId, signal: escalation, }) - groups.signal(pgid, escalation) + groups.signal(remaining, escalation) } } diff --git a/apps/desktop/src/main/terminal/process-group.ts b/apps/desktop/src/main/terminal/process-group.ts index 6e5a4e10a87..b33a12f5660 100644 --- a/apps/desktop/src/main/terminal/process-group.ts +++ b/apps/desktop/src/main/terminal/process-group.ts @@ -1,8 +1,12 @@ /** - * The process group a terminal's tty currently delivers keyboard signals to: the whole pipeline - * the user would stop with Ctrl-C, including its children, and never the shell itself. + * The processes running a terminal's foreground command: the whole pipeline the user would stop + * with Ctrl-C, including its children, and never the shell itself. + * + * On macOS and Linux that is the tty's foreground process group. Windows has no process groups or + * job control, so there it is the shell's child processes, each ended with its whole tree. */ import { spawn } from 'node:child_process' +import { win32 } from 'node:path' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -10,25 +14,124 @@ const logger = createLogger('DesktopTerminalProcessGroup') const LOOKUP_TIMEOUT_MS = 2_000 +/** Windows PowerShell takes a second or more to start on a cold machine. */ +const WINDOWS_LOOKUP_TIMEOUT_MS = 5_000 + +/** Reads and signals what holds a terminal's foreground. */ +export interface TerminalProcessGroups { + /** + * Ids of what runs the foreground command: process group ids on macOS and Linux, process ids on + * Windows. Empty when the shell itself holds the foreground (it is sitting at its prompt) or the + * lookup failed. + */ + foreground(shellPid: number): Promise + /** Sends `signal` to each id {@link foreground} returned. */ + signal(ids: number[], signal: NodeJS.Signals): void +} + +const POSIX_PROCESS_GROUPS: TerminalProcessGroups = { + foreground: async (shellPid) => { + const pgid = await readForegroundProcessGroup(shellPid) + return pgid === null ? [] : [pgid] + }, + signal: (pgids, signal) => { + for (const pgid of pgids) signalProcessGroup(pgid, signal) + }, +} + +/** + * Every escalation is a forced tree kill: a Windows console program cannot be asked to terminate, + * and Ctrl-C, the one request it can trap, has already been sent. + */ +const WINDOWS_PROCESS_GROUPS: TerminalProcessGroups = { + foreground: readChildProcesses, + signal: (pids) => { + for (const pid of pids) killProcessTree(pid) + }, +} + +/** The operating system's way of reading and stopping a terminal's foreground command. */ +export function osProcessGroups(platform: NodeJS.Platform): TerminalProcessGroups { + return platform === 'win32' ? WINDOWS_PROCESS_GROUPS : POSIX_PROCESS_GROUPS +} + /** * The tty's foreground process group, read from `ps`. Null when it cannot be read, or when the - * shell itself holds the foreground (it is sitting at its prompt). + * shell itself holds the foreground. */ -export function readForegroundProcessGroup(shellPid: number): Promise { - if (!Number.isInteger(shellPid) || shellPid <= 0) return Promise.resolve(null) +async function readForegroundProcessGroup(shellPid: number): Promise { + if (!isProcessId(shellPid)) return null + const stdout = await readOutput('ps', ['-o', 'tpgid=', '-p', String(shellPid)], LOOKUP_TIMEOUT_MS) + const pgid = Number.parseInt(stdout?.trim() ?? '', 10) + return isProcessId(pgid) && pgid !== shellPid ? pgid : null +} + +/** Sends `signal` to every process in one group. */ +function signalProcessGroup(pgid: number, signal: NodeJS.Signals): void { + try { + process.kill(-pgid, signal) + } catch (error) { + logger.warn('Could not signal a terminal process group', { + signal, + error: getErrorMessage(error), + }) + } +} + +/** `%SystemRoot%\System32`, named in full so a program on PATH cannot stand in for the system's. */ +function windowsSystem32(): string { + return win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32') +} + +/** The shell's direct children, from the process table; each one's tree is what it runs. */ +async function readChildProcesses(shellPid: number): Promise { + if (!isProcessId(shellPid)) return [] + const stdout = await readOutput( + win32.join(windowsSystem32(), 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + `(Get-CimInstance -ClassName Win32_Process -Filter 'ParentProcessId=${shellPid}').ProcessId`, + ], + WINDOWS_LOOKUP_TIMEOUT_MS + ) + return (stdout ?? '') + .split(/\s+/) + .map((value) => Number.parseInt(value, 10)) + .filter((pid) => isProcessId(pid) && pid !== shellPid) +} + +/** Ends a process and everything it started. */ +function killProcessTree(pid: number): void { + const taskkill = win32.join(windowsSystem32(), 'taskkill.exe') + const child = spawn(taskkill, ['/T', '/F', '/PID', String(pid)], { + stdio: 'ignore', + windowsHide: true, + }) + child.on('error', (error) => { + logger.warn('Could not end a terminal process tree', { error: getErrorMessage(error) }) + }) +} + +function isProcessId(value: number): boolean { + return Number.isInteger(value) && value > 0 +} + +/** A short-lived command's stdout, or null when it could not run or outlived `timeoutMs`. */ +function readOutput(command: string, args: string[], timeoutMs: number): Promise { return new Promise((resolve) => { let child: ReturnType try { - child = spawn('ps', ['-o', 'tpgid=', '-p', String(shellPid)], { - stdio: ['ignore', 'pipe', 'ignore'], - }) + child = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }) } catch { resolve(null) return } let stdout = '' let settled = false - const finish = (value: number | null) => { + const finish = (value: string | null) => { if (settled) return settled = true clearTimeout(timer) @@ -37,26 +140,11 @@ export function readForegroundProcessGroup(shellPid: number): Promise { child.kill('SIGKILL') finish(null) - }, LOOKUP_TIMEOUT_MS) + }, timeoutMs) child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) child.on('error', () => finish(null)) - child.on('close', () => { - const pgid = Number.parseInt(stdout.trim(), 10) - finish(Number.isInteger(pgid) && pgid > 0 && pgid !== shellPid ? pgid : null) - }) + child.on('close', () => finish(stdout)) }) } - -/** Sends `signal` to every process in one group. */ -export function signalProcessGroup(pgid: number, signal: NodeJS.Signals): void { - try { - process.kill(-pgid, signal) - } catch (error) { - logger.warn('Could not signal a terminal process group', { - signal, - error: getErrorMessage(error), - }) - } -} diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts index 6ff6a0a53af..6729a4455d9 100644 --- a/apps/desktop/src/main/terminal/service.test.ts +++ b/apps/desktop/src/main/terminal/service.test.ts @@ -481,11 +481,11 @@ describe('stopping a tool call', () => { const COMMAND_GROUP = 4242 function cancellableService() { - /** The tty's foreground group as the OS would report it at each read. */ - let foreground: number | null = COMMAND_GROUP + /** What holds the terminal's foreground as the OS would report it at each read. */ + let foreground: number[] = [COMMAND_GROUP] const processGroups = { foreground: vi.fn(async (_shellPid: number) => foreground), - signal: vi.fn((_pgid: number, _signal: NodeJS.Signals) => {}), + signal: vi.fn((_ids: number[], _signal: NodeJS.Signals) => {}), } const terminal = new TerminalService({ loadCwd: () => '/tmp', processGroups }) const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 }) @@ -495,8 +495,8 @@ describe('stopping a tool call', () => { terminal, session, processGroups, - setForeground: (pgid: number | null) => { - foreground = pgid + setForeground: (ids: number[]) => { + foreground = ids }, } } @@ -531,15 +531,30 @@ describe('stopping a tool call', () => { await terminal.cancelTool('call-stubborn') - expect(processGroups.signal).toHaveBeenCalledWith(COMMAND_GROUP, 'SIGTERM') + expect(processGroups.signal).toHaveBeenCalledWith([COMMAND_GROUP], 'SIGTERM') await expect(running).resolves.toMatchObject({ ok: true, result: { exitCode: 143 } }) }) + it('signals only the processes of the stopped command that are still running', async () => { + const { terminal, session, processGroups, setForeground } = cancellableService() + session.setInterruptible(false) + setForeground([101, 102]) + processGroups.signal.mockImplementation(() => session.finishRun(1)) + const { running } = await startRun(terminal, session, 'call-pipeline') + // One of a Windows pipeline's processes exits on Ctrl-C and its pid is handed to a new one. + session.kill.mockImplementation(() => setForeground([102, 103])) + + await terminal.cancelTool('call-pipeline') + + expect(processGroups.signal).toHaveBeenCalledWith([102], 'SIGTERM') + await running + }) + it('never signals a different command that took the foreground meanwhile', async () => { const { terminal, session, processGroups, setForeground } = cancellableService() session.setInterruptible(false) const { running } = await startRun(terminal, session, 'call-replaced') - session.kill.mockImplementation(() => setForeground(9999)) + session.kill.mockImplementation(() => setForeground([9999])) await terminal.cancelTool('call-replaced') From 386a0cd8c0e1b60ad455e45d9ec8fd3508935e03 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:34:21 -0700 Subject: [PATCH 27/67] feat(desktop): keep tmux off on Windows An MSYS or Cygwin tmux on PATH cannot be matched to a ConPTY shell. --- apps/desktop/src/main/terminal/tmux.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/terminal/tmux.ts b/apps/desktop/src/main/terminal/tmux.ts index 782b1a84990..9c7b729f2db 100644 --- a/apps/desktop/src/main/terminal/tmux.ts +++ b/apps/desktop/src/main/terminal/tmux.ts @@ -89,11 +89,6 @@ export interface TmuxAttachment { clientTty: string } -/** - * Runs one tmux command. Never throws: a missing binary, a dead server, and a - * bad target all arrive as `ok: false` with tmux's own message, which is more - * useful to the model than an exception. - */ /** * Set once a `tmux` spawn fails with ENOENT: the binary is not installed, and * it will not appear mid-session, so every later call short-circuits instead @@ -102,13 +97,23 @@ export interface TmuxAttachment { */ let tmuxBinaryMissing = false +/** + * Whether tmux is off the table. Always on Windows: attaching matches tmux + * clients to the shell through POSIX ttys and `ps`, and an MSYS or Cygwin tmux + * on PATH lives in a process and path world the Windows shell is not part of. + */ export function isTmuxUnavailable(): boolean { - return tmuxBinaryMissing + return process.platform === 'win32' || tmuxBinaryMissing } +/** + * Runs one tmux command. Never throws: a missing binary, a dead server, and a + * bad target all arrive as `ok: false` with tmux's own message, which is more + * useful to the model than an exception. + */ export function runTmux(args: string[], env: NodeJS.ProcessEnv): Promise { return new Promise((resolve) => { - if (tmuxBinaryMissing) { + if (isTmuxUnavailable()) { resolve({ ok: false, stdout: '', stderr: 'tmux is not installed' }) return } From 4b6b17b962235aeb48564f6a6fa5f8c24f07f0dd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:34:30 -0700 Subject: [PATCH 28/67] feat(desktop): ship the agent terminal on Windows Flips the Windows terminal feature on, so the preload, IPC table, background executor, and device registration all expose it. Adds a Windows-only suite that drives PowerShell 7, Windows PowerShell 5.1 and Git Bash through real ConPTY: exit codes, cwd after cd, and Stop ending a command that ignores Ctrl-C. --- .../src/main/desktop-executor/runner.test.ts | 14 +- .../src/main/desktop-executor/service.test.ts | 6 +- apps/desktop/src/main/ipc.test.ts | 9 +- .../src/main/terminal/windows-shells.test.ts | 198 ++++++++++++++++++ apps/desktop/src/shared/platform-features.ts | 2 +- 5 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/main/terminal/windows-shells.test.ts diff --git a/apps/desktop/src/main/desktop-executor/runner.test.ts b/apps/desktop/src/main/desktop-executor/runner.test.ts index 8b316e6718d..7cd66f8c180 100644 --- a/apps/desktop/src/main/desktop-executor/runner.test.ts +++ b/apps/desktop/src/main/desktop-executor/runner.test.ts @@ -140,9 +140,21 @@ describe('background terminal calls', () => { }) }) +describe('terminal calls on Windows', () => { + it('runs them, since the Windows shell ships the terminal', async () => { + const executeTool = vi.fn(async () => ({ ok: true, result: { exitCode: 0 } })) + const completion = await runner({ + platform: 'win32', + terminal: { executeTool, cancelTool: vi.fn(async () => true) }, + }).run(terminalCall('call-windows', 'run'), new AbortController().signal) + + expect(completion.data).not.toMatchObject({ notStarted: true }) + expect(executeTool).toHaveBeenCalledTimes(1) + }) +}) + describe('calls for a surface the platform does not ship', () => { it.each([ - ['terminal', { operation: 'run', args: { command: 'echo hi' } }], ['read_local_file', { path: '~/notes.txt' }], ['import_local_files', { path: '~/notes', targetWorkspaceId: 'ws-1' }], ])('never starts %s on Windows', async (toolName, args) => { diff --git a/apps/desktop/src/main/desktop-executor/service.test.ts b/apps/desktop/src/main/desktop-executor/service.test.ts index 77af2fad54d..525e5d270ee 100644 --- a/apps/desktop/src/main/desktop-executor/service.test.ts +++ b/apps/desktop/src/main/desktop-executor/service.test.ts @@ -231,14 +231,14 @@ describe('desktop executor registration', () => { ['darwin', true], ['win32', false], ] as const)( - 'advertises the terminal and native file tools on %s: %s', - async (platform, shipped) => { + 'advertises the terminal everywhere, and native file tools on %s: %s', + async (platform, localFiles) => { const { sim, desktopExecutor } = await service(1, undefined, platform) desktopExecutor.start() await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) expect(sim.registrationBodies[0]).toMatchObject({ - capabilities: { browser: true, terminal: shipped, localFiles: shipped }, + capabilities: { browser: true, terminal: true, localFiles }, }) sim.registrations[0]?.(false) await desktopExecutor.signOut() diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index f379f769f45..7e4156fea38 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -370,8 +370,6 @@ describe('registerIpcHandlers', () => { }) it.each([ - 'terminal:execute-tool', - 'terminal:write', 'terminal-themes:list-profiles', 'computer-use:execute-tool', 'browser-import:all', @@ -388,11 +386,14 @@ describe('registerIpcHandlers', () => { expect(invoke.has(channel) || on.has(channel)).toBe(false) }) - it('keeps the user-local folder grants and password reveal on Windows', () => { + it('keeps the terminal, user-local folder grants and password reveal on Windows', () => { vi.mocked(ipcMain.handle).mockClear() + vi.mocked(ipcMain.on).mockClear() registerIpcHandlers({ ...deps, platform: 'win32' }) - const { invoke } = collectHandlers() + const { invoke, on } = collectHandlers() + expect(invoke.has('terminal:execute-tool')).toBe(true) + expect(on.has('terminal:write')).toBe(true) expect(invoke.has('desktop:local-filesystem')).toBe(true) expect(invoke.has('browser-credentials:reveal')).toBe(true) }) diff --git a/apps/desktop/src/main/terminal/windows-shells.test.ts b/apps/desktop/src/main/terminal/windows-shells.test.ts new file mode 100644 index 00000000000..1c9888ff5b0 --- /dev/null +++ b/apps/desktop/src/main/terminal/windows-shells.test.ts @@ -0,0 +1,198 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { TerminalRunResult } from '@sim/terminal-protocol' +import { sleep } from '@sim/utils/helpers' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalService } from '@/main/terminal' + +/** + * The agent terminal against the real Windows shells: node-pty's ConPTY, the PowerShell and Git + * Bash integration, and the Windows Stop path, nothing stubbed. Runs only on Windows, and each + * shell only where it is installed (windows-latest has all three). + */ +const WINDOWS_POWERSHELL = join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' +) + +interface ShellUnderTest { + name: string + path: string + succeed: string + fail: string + /** A cmdlet that fails, which sets no exit code of its own. */ + failingCmdlet?: string + /** A Windows path as a quoted argument this shell reads. */ + quote(path: string): string + cd: string + /** What runs a program named by a quoted path. */ + call: string +} + +const POWERSHELL: Omit = { + succeed: 'Write-Output sim-ok', + fail: 'cmd /c exit 3', + failingCmdlet: 'Get-Item -LiteralPath C:\\sim-does-not-exist', + quote: (path) => `'${path}'`, + cd: 'Set-Location -LiteralPath', + call: '&', +} + +const SHELLS: ShellUnderTest[] = [ + { name: 'PowerShell 7', path: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', ...POWERSHELL }, + { name: 'Windows PowerShell 5.1', path: WINDOWS_POWERSHELL, ...POWERSHELL }, + { + name: 'Git Bash', + path: 'C:\\Program Files\\Git\\bin\\bash.exe', + succeed: 'echo sim-ok', + fail: "sh -c 'exit 3'", + quote: (path) => `'${path.replaceAll('\\', '/')}'`, + cd: 'cd', + call: '', + }, +] + +/** Ctrl-C reaches this as a keystroke rather than a signal, so it cannot interrupt it. */ +const STUBBORN_SCRIPT = `param($PidFile) +[Console]::TreatControlCAsInput = $true +Set-Content -LiteralPath $PidFile -Value $PID +Start-Sleep -Seconds 120 +` + +/** A shell's first start on a cold CI runner, then the Stop escalation, take a while. */ +const SHELL_TEST_TIMEOUT_MS = 90_000 + +function scratchDirectory(): string { + return realpathSync.native(mkdtempSync(join(tmpdir(), 'sim-windows-shell-'))) +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function run( + service: TerminalService, + toolCallId: string, + command: string +): Promise { + const response = await service.executeTool(toolCallId, 'run', { command, waitSeconds: 60 }) + expect(response).toMatchObject({ ok: true }) + return response.result as TerminalRunResult +} + +describe.runIf(process.platform === 'win32')('the agent terminal on Windows', () => { + let service: TerminalService | null = null + + afterEach(() => { + service?.dispose() + service = null + vi.unstubAllEnvs() + }) + + function open(shell: ShellUnderTest, cwd = scratchDirectory()): TerminalService { + vi.stubEnv('SHELL', shell.path) + service = new TerminalService({ loadCwd: () => cwd }) + return service + } + + describe.each(SHELLS)('$name', (shell) => { + const installed = existsSync(shell.path) + + it.runIf(installed)( + "reports a successful command's output and exit code", + async () => { + const result = await run(open(shell), 'call-ok', shell.succeed) + + expect(result).toMatchObject({ status: 'completed', exitCode: 0 }) + expect(result.output).toContain('sim-ok') + }, + SHELL_TEST_TIMEOUT_MS + ) + + it.runIf(installed)( + "reports a failing program's own exit code", + async () => { + const result = await run(open(shell), 'call-fail', shell.fail) + + expect(result).toMatchObject({ status: 'completed', exitCode: 3 }) + }, + SHELL_TEST_TIMEOUT_MS + ) + + it.runIf(installed && shell.failingCmdlet !== undefined)( + 'reports a failing cmdlet as a failure', + async () => { + const result = await run(open(shell), 'call-cmdlet', shell.failingCmdlet ?? '') + + expect(result.status).toBe('completed') + expect(result.exitCode).not.toBe(0) + }, + SHELL_TEST_TIMEOUT_MS + ) + + it.runIf(installed)( + 'follows the working directory across cd, in Windows form', + async () => { + const directory = join(scratchDirectory(), 'my project') + mkdirSync(directory) + + const result = await run(open(shell), 'call-cd', `${shell.cd} ${shell.quote(directory)}`) + + expect(result.exitCode).toBe(0) + expect(result.cwd?.toLowerCase()).toBe(directory.toLowerCase()) + }, + SHELL_TEST_TIMEOUT_MS + ) + + it.runIf(installed)( + 'Stop ends a command that ignores Ctrl-C and leaves the shell usable', + async () => { + const scratch = scratchDirectory() + const script = join(scratch, 'stubborn.ps1') + const pidFile = join(scratch, 'pid') + writeFileSync(script, STUBBORN_SCRIPT) + const terminal = open(shell, scratch) + const command = [ + shell.call, + shell.quote(WINDOWS_POWERSHELL), + '-NoProfile -ExecutionPolicy Bypass -File', + shell.quote(script), + shell.quote(pidFile), + ] + .filter(Boolean) + .join(' ') + + const running = run(terminal, 'call-stubborn', command) + await vi.waitFor(() => expect(existsSync(pidFile)).toBe(true), { + timeout: 30_000, + interval: 100, + }) + const pid = Number.parseInt(readFileSync(pidFile, 'utf8'), 10) + // Ctrl-C is already a keystroke once the pid is written; this lets Start-Sleep begin. + await sleep(500) + + await expect(terminal.cancelTool('call-stubborn')).resolves.toBe(true) + expect(await running).toMatchObject({ status: 'completed' }) + expect(isAlive(pid)).toBe(false) + expect(await run(terminal, 'call-after', shell.succeed)).toMatchObject({ exitCode: 0 }) + }, + SHELL_TEST_TIMEOUT_MS + ) + }) +}) diff --git a/apps/desktop/src/shared/platform-features.ts b/apps/desktop/src/shared/platform-features.ts index ac6477fff6b..abf93fca968 100644 --- a/apps/desktop/src/shared/platform-features.ts +++ b/apps/desktop/src/shared/platform-features.ts @@ -41,7 +41,7 @@ const MAC_FEATURES: DesktopFeatures = { const WINDOWS_FEATURES: DesktopFeatures = { computerUse: false, helpSearch: false, - terminal: false, + terminal: true, terminalThemes: false, localFiles: false, browserImport: false, From 99d67bbdef6f36342ea8aa2d3757bdebb42560e3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:36:45 -0700 Subject: [PATCH 29/67] feat(desktop): send Mothership the desktop's platform and each terminal's shell Terminal hints carry the shell's program name, bounded to 32 characters, and the chat payload forwards desktopCapabilities.platform, which the route accepted but dropped, as desktop.platform. The tab tooltip reads a backslash literally in Windows shells. --- apps/sim/lib/desktop/index.test.ts | 2 ++ apps/sim/lib/desktop/index.ts | 6 ++++++ .../mothership/chat/desktop-capabilities.ts | 2 ++ apps/sim/lib/mothership/chat/payload.test.ts | 6 ++++-- apps/sim/lib/mothership/chat/payload.ts | 5 +++++ apps/sim/lib/mothership/chat/post.test.ts | 20 +++++++++++++++++++ apps/sim/lib/mothership/chat/post.ts | 9 +++++++++ apps/sim/lib/terminal/tab-label.ts | 2 +- 8 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/desktop/index.test.ts b/apps/sim/lib/desktop/index.test.ts index 1a7436c7e83..330f8350215 100644 --- a/apps/sim/lib/desktop/index.test.ts +++ b/apps/sim/lib/desktop/index.test.ts @@ -201,6 +201,7 @@ describe('desktop surface availability', () => { running: oversizedValue, interactive: false, active: true, + shell: oversizedValue, }, ], })), @@ -219,6 +220,7 @@ describe('desktop surface availability', () => { cwd: 'x'.repeat(1024), running: 'x'.repeat(1024), active: true, + shell: 'x'.repeat(32), }, ]) }) diff --git a/apps/sim/lib/desktop/index.ts b/apps/sim/lib/desktop/index.ts index 4bb0ef52e5a..0a76cdb465b 100644 --- a/apps/sim/lib/desktop/index.ts +++ b/apps/sim/lib/desktop/index.ts @@ -31,6 +31,7 @@ import { } from '@/lib/api/contracts/computer-use' import { DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH, + DESKTOP_TERMINAL_HINT_SHELL_MAX_LENGTH, DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH, } from '@/lib/mothership/chat/desktop-capabilities' @@ -164,6 +165,8 @@ export interface DesktopTerminalHint { running?: string interactive?: boolean active?: boolean + /** The shell's program name (`zsh`, `pwsh`, `powershell`), so commands are written for it. */ + shell?: string } export interface DesktopChatCapabilities { @@ -235,6 +238,9 @@ export async function getDesktopChatCapabilities( : {}), ...(tab.interactive ? { interactive: true as const } : {}), ...(tab.active ? { active: true as const } : {}), + ...(tab.shell + ? { shell: truncate(tab.shell, DESKTOP_TERMINAL_HINT_SHELL_MAX_LENGTH, '') } + : {}), })) ) .catch(() => []) diff --git a/apps/sim/lib/mothership/chat/desktop-capabilities.ts b/apps/sim/lib/mothership/chat/desktop-capabilities.ts index b8fb040f927..e619a9fb4e6 100644 --- a/apps/sim/lib/mothership/chat/desktop-capabilities.ts +++ b/apps/sim/lib/mothership/chat/desktop-capabilities.ts @@ -1,3 +1,5 @@ /** Wire limits shared by the desktop capability snapshot and its request schema. */ export const DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH = 64 export const DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH = 1024 +/** A shell's program name (`zsh`, `pwsh`), never a path. */ +export const DESKTOP_TERMINAL_HINT_SHELL_MAX_LENGTH = 32 diff --git a/apps/sim/lib/mothership/chat/payload.test.ts b/apps/sim/lib/mothership/chat/payload.test.ts index c82da7f3b93..8271e39528a 100644 --- a/apps/sim/lib/mothership/chat/payload.test.ts +++ b/apps/sim/lib/mothership/chat/payload.test.ts @@ -894,7 +894,8 @@ describe('desktop request capabilities', () => { model: 'gpt-6-astra', browser: true, terminalCapable: true, - terminals: [{ id: 'terminal-1', cwd: '/work/app', active: true }], + desktopPlatform: 'win32', + terminals: [{ id: 'terminal-1', cwd: 'C:\\work\\app', active: true, shell: 'pwsh' }], browserSessions: [ { hostname: 'example.com', evidence: 'cookies', lastObservedAt: '2026-09-08' }, ], @@ -903,7 +904,8 @@ describe('desktop request capabilities', () => { computerUse: false, browser: true, terminal: true, - terminals: [{ id: 'terminal-1', cwd: '/work/app', active: true }], + platform: 'win32', + terminals: [{ id: 'terminal-1', cwd: 'C:\\work\\app', active: true, shell: 'pwsh' }], browserSessions: [ { hostname: 'example.com', evidence: 'cookies', lastObservedAt: '2026-09-08' }, ], diff --git a/apps/sim/lib/mothership/chat/payload.ts b/apps/sim/lib/mothership/chat/payload.ts index 33b758e3afb..81d58e61524 100644 --- a/apps/sim/lib/mothership/chat/payload.ts +++ b/apps/sim/lib/mothership/chat/payload.ts @@ -1,5 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { BrowserKnownSession } from '@sim/browser-protocol' +import type { DesktopPlatform } from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' import { isPermissionType, permissionSatisfies } from '@sim/platform-authz/predicates' import { toError } from '@sim/utils/errors' @@ -98,12 +99,15 @@ interface BuildPayloadParams { browser?: boolean terminalCapable?: boolean computerUse?: boolean + /** The desktop shell's operating system, so commands and paths are written for it. */ + desktopPlatform?: DesktopPlatform terminals?: Array<{ id: string cwd?: string running?: string interactive?: boolean active?: boolean + shell?: string }> browserSessions?: BrowserKnownSession[] } @@ -555,6 +559,7 @@ export async function buildCopilotRequestPayload(params: BuildPayloadParams): Pr browser: params.browser === true, terminal: params.terminalCapable === true, computerUse, + ...(params.desktopPlatform ? { platform: params.desktopPlatform } : {}), terminals: params.terminalCapable ? (params.terminals ?? []).slice(0, 20) : [], browserSessions: params.browser ? (params.browserSessions ?? []).slice(0, 20) : [], }, diff --git a/apps/sim/lib/mothership/chat/post.test.ts b/apps/sim/lib/mothership/chat/post.test.ts index 2b32e7fed05..17b005b3257 100644 --- a/apps/sim/lib/mothership/chat/post.test.ts +++ b/apps/sim/lib/mothership/chat/post.test.ts @@ -1052,6 +1052,26 @@ describe('handleUnifiedChatPost', () => { expect(persistChatResources).not.toHaveBeenCalled() }) + it("forwards the desktop's platform and each terminal's shell", async () => { + const terminals = [{ id: '1', cwd: 'C:\\work', active: true, shell: 'powershell' }] + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ + message: 'Build it', + workspaceId: 'ws-1', + createNewChat: true, + desktopCapabilities: { terminal: true, platform: 'win32', terminals }, + }), + }) + ) + + expect(response.status).toBe(200) + expect(buildCopilotRequestPayload).toHaveBeenCalledWith( + expect.objectContaining({ desktopPlatform: 'win32', terminals }) + ) + }) + it('accepts and forwards more than eight open terminal hints', async () => { const terminals = Array.from({ length: 12 }, (_, index) => ({ id: String(index + 1), diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index 8831b5233fe..336bdddd081 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -40,6 +40,7 @@ import { buildOnComplete, buildOnError } from '@/lib/mothership/chat/completion' import { MAX_CHAT_MESSAGE_LENGTH } from '@/lib/mothership/chat/context-limits' import { DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH, + DESKTOP_TERMINAL_HINT_SHELL_MAX_LENGTH, DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH, } from '@/lib/mothership/chat/desktop-capabilities' import { @@ -173,6 +174,7 @@ const ChatMessageSchema = z running: z.string().max(DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH).optional(), interactive: z.boolean().optional(), active: z.boolean().optional(), + shell: z.string().max(DESKTOP_TERMINAL_HINT_SHELL_MAX_LENGTH).optional(), }) ) .optional(), @@ -244,6 +246,7 @@ function backgroundExecutorDeviceId(body: UnifiedChatRequest): string | undefine type BrowserSessions = NonNullable['browserSessions'] type Terminals = NonNullable['terminals'] +type DesktopPlatform = NonNullable['platform'] type UnifiedChatBranch = | { kind: 'workflow' @@ -289,6 +292,7 @@ type UnifiedChatBranch = browser?: boolean terminalCapable?: boolean computerUse?: boolean + desktopPlatform?: DesktopPlatform terminals?: Terminals browserSessions?: BrowserSessions }) => Promise @@ -331,6 +335,7 @@ type UnifiedChatBranch = browser?: boolean terminalCapable?: boolean computerUse?: boolean + desktopPlatform?: DesktopPlatform terminals?: Terminals browserSessions?: BrowserSessions }) => Promise @@ -629,6 +634,7 @@ async function resolveBranch(params: { browser: payloadParams.browser, terminalCapable: payloadParams.terminalCapable, computerUse: payloadParams.computerUse, + desktopPlatform: payloadParams.desktopPlatform, terminals: payloadParams.terminals, browserSessions: payloadParams.browserSessions, }), @@ -693,6 +699,7 @@ async function resolveBranch(params: { browser: payloadParams.browser, terminalCapable: payloadParams.terminalCapable, computerUse: payloadParams.computerUse, + desktopPlatform: payloadParams.desktopPlatform, terminals: payloadParams.terminals, browserSessions: payloadParams.browserSessions, }), @@ -1208,6 +1215,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { browser: body.desktopCapabilities?.browser === true, terminalCapable: body.desktopCapabilities?.terminal === true, computerUse: body.desktopCapabilities?.computerUse === true, + desktopPlatform: body.desktopCapabilities?.platform, terminals: body.desktopCapabilities?.terminals, browserSessions: body.desktopCapabilities?.browserSessions, }) @@ -1236,6 +1244,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { browser: body.desktopCapabilities?.browser === true, terminalCapable: body.desktopCapabilities?.terminal === true, computerUse: body.desktopCapabilities?.computerUse === true, + desktopPlatform: body.desktopCapabilities?.platform, terminals: body.desktopCapabilities?.terminals, browserSessions: body.desktopCapabilities?.browserSessions, }) diff --git a/apps/sim/lib/terminal/tab-label.ts b/apps/sim/lib/terminal/tab-label.ts index 08f783888f7..fdc41157cfa 100644 --- a/apps/sim/lib/terminal/tab-label.ts +++ b/apps/sim/lib/terminal/tab-label.ts @@ -3,7 +3,7 @@ import { describeRunningCommand, type TerminalTabState } from '@sim/terminal-pro /** Full working directory, plus a concise name for whatever the shell is running. */ export function terminalTooltip(tab: TerminalTabState): string { const where = tab.cwd ?? 'Terminal' - return tab.running ? `${where} — ${describeRunningCommand(tab.running)}` : where + return tab.running ? `${where} — ${describeRunningCommand(tab.running, tab.shell)}` : where } /** From ba0fbe675f9cb9193d1169b0fcf0f8d3969a3587 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:37:33 -0700 Subject: [PATCH 30/67] docs(desktop): document the agent terminal on Windows --- apps/desktop/README.md | 3 ++- apps/docs/content/docs/desktop/index.mdx | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 1823fea5551..0421e110b54 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -205,7 +205,8 @@ The Windows build is a one-click, per-user NSIS installer (`Sim--x64.ex - Updates: the NSIS updater requests `latest.yml` from the same `/api/desktop/update` feed. `ready` is reached when electron-updater's own `update-downloaded` fires (after the publisher check); there is no native staging step. A confirmed restart runs the installer silently and relaunches. Builds without a packaged `app-update.yml` fall back to the manual-download pipeline. Deltas use `.exe.blockmap`. - Publication: Windows publishes independently of macOS. `desktop-signing-windows` probes the Azure configuration and skips with a warning until it exists; unsigned Windows builds are never published. No job waits on the Windows calls, so a Windows failure never blocks the macOS release, and the feed treats a release without `latest.yml` as having no Windows build (Windows shells keep the newest release that has one). `latest.yml` is uploaded last. - SmartScreen: a new signing identity starts without download reputation, so early installers can show "Windows protected your PC" until reputation builds. Keep the same certificate profile across releases. -- Not available on Windows yet: Terminal, native local-file tools, native Computer Use, and importing from other browsers. node-pty still ships but is not exercised. +- Terminal: runs PowerShell 7 (`pwsh.exe`) when it is on `Path`, else Windows PowerShell 5.1, else cmd, which has no shell integration, so the agent cannot run commands in it. `SHELL` set to a Windows path (`C:\Program Files\Git\bin\bash.exe`) selects Git Bash, whose `/c/...` directories are reported in Windows form. PowerShell starts with `-NoProfile -NoExit -EncodedCommand`: the script emits the startup marker, loads the user's profiles in PowerShell's order under their own execution policy, then wraps `prompt` and PSReadLine's `PSConsoleHostReadLine` to emit the OSC 633 markers (no PSReadLine, no integration). Stop sends Ctrl-C, then `taskkill /T /F` to each of the shell's child processes. tmux and terminal themes are macOS and Linux only. `src/main/terminal/windows-shells.test.ts` drives PowerShell 7, Windows PowerShell 5.1 and Git Bash through ConPTY on the Windows CI leg. +- Not available on Windows yet: native local-file tools, native Computer Use, and importing from other browsers. | Secret / variable | Kind | Contents | |---|---|---| diff --git a/apps/docs/content/docs/desktop/index.mdx b/apps/docs/content/docs/desktop/index.mdx index f4464c454dd..ed47fc30864 100644 --- a/apps/docs/content/docs/desktop/index.mdx +++ b/apps/docs/content/docs/desktop/index.mdx @@ -76,13 +76,13 @@ The app has its own session. Signing out of the app does not sign you out of Sim ## Browser, terminal, and local files - On Windows, Terminal, local file tools, native app control, and importing from other browsers are not available yet. The built-in browser, notifications, and updates work as on macOS. + On Windows, local file tools, native app control, and importing from other browsers are not available yet. The built-in browser, Terminal, notifications, and updates work as on macOS, except that tmux is not available in Terminal. | Capability | Behavior | | --- | --- | | **Browser** | Open websites in tabs with their own sessions and saved passwords. Chat can use the sites you sign into here. | -| **Terminal** | Open local shell sessions beside Chat. Chat can run commands in those sessions; tmux and shell integration are supported. | +| **Terminal** | Open local shell sessions beside Chat. Chat can run commands in those sessions; tmux (macOS) and shell integration are supported. On Windows, Terminal opens PowerShell 7 when it is installed, otherwise Windows PowerShell; set the `SHELL` environment variable to the path of Git Bash's `bash.exe` to use Git Bash instead. | | **Local file tools** | Read and search folders you approve, import files, and use authorized files in browser uploads. | | **Notifications** | Receive a notification when Chat finishes responding. Clicking it opens that chat. Errors and turns with queued follow-up messages do not produce completion notifications. | | **Control Center** | Open recent chats from Sim's menu-bar icon. | @@ -118,7 +118,7 @@ Use **Revoke** under **Approved apps** to remove a saved permission, or switch C ### Terminal access -Terminal commands run with your macOS account's permissions. They can modify files outside selected-folder grants; those grants do not confine the shell. Disable **Settings → Account → Terminal → Let Chat run commands** to remove this capability. +Terminal commands run with your macOS or Windows account's permissions. They can modify files outside selected-folder grants; those grants do not confine the shell. Disable **Settings → Account → Terminal → Let Chat run commands** to remove this capability. Command confirmation depends on Chat's tool-permission settings and whether the run is interactive. With approvals enabled, command runs can wait for approval unless the tool is auto-allowed. Noninteractive runs do not wait for a per-command decision. From 6fe2b11e15ed76432fbb91f42082ee9b4cf9c737 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:40:03 -0700 Subject: [PATCH 31/67] docs(desktop): list local file tools as available on Windows --- apps/docs/content/docs/desktop/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/docs/desktop/index.mdx b/apps/docs/content/docs/desktop/index.mdx index ed47fc30864..2ad245c4193 100644 --- a/apps/docs/content/docs/desktop/index.mdx +++ b/apps/docs/content/docs/desktop/index.mdx @@ -76,7 +76,7 @@ The app has its own session. Signing out of the app does not sign you out of Sim ## Browser, terminal, and local files - On Windows, local file tools, native app control, and importing from other browsers are not available yet. The built-in browser, Terminal, notifications, and updates work as on macOS, except that tmux is not available in Terminal. + On Windows, native app control and importing from other browsers are not available yet. The built-in browser, Terminal, local file tools, notifications, and updates work as on macOS, except that tmux is not available in Terminal. | Capability | Behavior | From 1f21b0269d332791797990760c12c3f50ad94a80 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:40:31 -0700 Subject: [PATCH 32/67] chore(mothership): sync the desktop platform and terminal shell protocol fields --- apps/sim/lib/mothership/generated/protocol.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts index 2b252886dde..fe4ec78b475 100644 --- a/apps/sim/lib/mothership/generated/protocol.ts +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -106,6 +106,8 @@ export type ModelSelection = z.infer; /** Desktop capabilities and bounded session hints, supplied by Sim for this turn. */ export const DesktopContextSchema = z.object({ + /** The desktop host OS; absent from builds that predate Windows support, which means macOS. */ + platform: z.enum(["darwin", "win32", "linux"]).optional(), localFiles: z.boolean().optional(), computerUse: z.boolean().default(false), browser: z.boolean().default(false), @@ -118,6 +120,8 @@ export const DesktopContextSchema = z.object({ running: z.string().max(1024).optional(), interactive: z.boolean().optional(), active: z.boolean().optional(), + /** The short shell kind, e.g. "zsh", "bash", "pwsh", "powershell" or "cmd". */ + shell: z.string().max(32).optional(), }), ) .max(20) From 954229b88e3238f294dcf28bf542c1b68947e30e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:46:27 -0700 Subject: [PATCH 33/67] refactor(desktop): drop the terminal and local-file platform gates Both ship on every platform now, so their feature-table entries, the executor and IPC gates, the optional bridge terminal, and the tests that restated them go away. Also tightens comments the cleanup pass flagged. --- apps/desktop/e2e/browser-tools.spec.ts | 26 +++------ .../e2e/desktop-tools-live-sim.spec.ts | 9 +-- apps/desktop/e2e/terminal-cancel.spec.ts | 4 +- apps/desktop/scripts/build-native.ts | 6 +- .../src/main/browser-credentials/os-auth.ts | 5 +- .../src/main/desktop-executor/runner.test.ts | 31 ---------- .../src/main/desktop-executor/runner.ts | 20 +------ .../src/main/desktop-executor/service.test.ts | 58 +++++-------------- .../src/main/desktop-executor/service.ts | 11 +--- apps/desktop/src/main/ipc.test.ts | 9 +-- apps/desktop/src/main/ipc.ts | 2 - apps/desktop/src/main/menu.test.ts | 10 +--- apps/desktop/src/main/menu.ts | 13 +---- apps/desktop/src/main/path-containment.ts | 8 +-- .../src/main/terminal/process-group.ts | 2 - apps/desktop/src/main/terminal/shell-path.ts | 1 - apps/desktop/src/main/updater.ts | 8 +-- .../src/main/win32-native-files.test.ts | 24 -------- apps/desktop/src/preload/index.ts | 20 ++----- apps/desktop/src/shared/platform-features.ts | 14 +---- apps/sim/lib/desktop/index.ts | 6 +- .../lib/desktop/preferences-loading.test.ts | 2 +- packages/desktop-bridge/src/index.ts | 10 +--- scripts/check-api-validation-contracts.ts | 5 +- 24 files changed, 65 insertions(+), 239 deletions(-) diff --git a/apps/desktop/e2e/browser-tools.spec.ts b/apps/desktop/e2e/browser-tools.spec.ts index 6d71644e5b1..3b573ad9df3 100644 --- a/apps/desktop/e2e/browser-tools.spec.ts +++ b/apps/desktop/e2e/browser-tools.spec.ts @@ -1309,11 +1309,9 @@ test.describe('browser tools', () => { }) test('local terminal executes through its native PTY and refuses repeated authorization', async () => { await window.evaluate(async (scope) => { - const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop - .terminal - if (!terminal) throw new Error('No agent terminal on this platform') - await terminal.activateScope(scope) - await terminal.openTerminal(undefined, scope) + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + await api.terminal.activateScope(scope) + await api.terminal.openTerminal(undefined, scope) }, SCOPE) calls.set('local-cwd', { chatId: SCOPE, @@ -1321,10 +1319,8 @@ test.describe('browser tools', () => { args: { operation: 'cwd', args: {} }, }) const cwd = await window.evaluate(async (scope) => { - const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop - .terminal - if (!terminal) throw new Error('No agent terminal on this platform') - return terminal.executeTool('local-cwd', 'cwd', {}, scope) + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + return api.terminal.executeTool('local-cwd', 'cwd', {}, scope) }, SCOPE) expect(cwd.ok).toBe(true) calls.set('local-run', { @@ -1333,18 +1329,14 @@ test.describe('browser tools', () => { args: { operation: 'run', args: { command: "printf 'SIM_NATIVE_TERMINAL_VERIFIED\\n'" } }, }) const result = await window.evaluate(async (scope) => { - const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop - .terminal - if (!terminal) throw new Error('No agent terminal on this platform') - return terminal.executeTool('local-run', 'run', {}, scope) + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + return api.terminal.executeTool('local-run', 'run', {}, scope) }, SCOPE) expect(result.ok).toBe(true) expect(JSON.stringify(result)).toContain('SIM_NATIVE_TERMINAL_VERIFIED') const replay = await window.evaluate(async (scope) => { - const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop - .terminal - if (!terminal) throw new Error('No agent terminal on this platform') - return terminal.executeTool('local-run', 'run', {}, scope) + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + return api.terminal.executeTool('local-run', 'run', {}, scope) }, SCOPE) expect(replay.ok).toBe(false) }) diff --git a/apps/desktop/e2e/desktop-tools-live-sim.spec.ts b/apps/desktop/e2e/desktop-tools-live-sim.spec.ts index 04bc6c2059f..f83e6c61917 100644 --- a/apps/desktop/e2e/desktop-tools-live-sim.spec.ts +++ b/apps/desktop/e2e/desktop-tools-live-sim.spec.ts @@ -672,13 +672,10 @@ test.describe('desktop tools against a live Sim', () => { // stays pending and unclaimed. Whether a handed-over command then runs depends on a terminal // being open for the chat, which this test does not set up, so it checks the claim only. const response = await page.evaluate( - ({ toolCallId, command }) => { - const terminal = (globalThis as DesktopWindow).simDesktop.terminal - if (!terminal) throw new Error('No agent terminal on this platform') - return terminal + ({ toolCallId, command }) => + (globalThis as DesktopWindow).simDesktop.terminal .executeTool(toolCallId, 'run', { command }, 'unapproved-e2e') - .catch((error: unknown) => ({ ok: false, error: String(error) })) - }, + .catch((error: unknown) => ({ ok: false, error: String(error) })), { toolCallId: run.toolCallId, command } ) expect(response).toMatchObject({ ok: false }) diff --git a/apps/desktop/e2e/terminal-cancel.spec.ts b/apps/desktop/e2e/terminal-cancel.spec.ts index 98c5f5e688c..1d639b7dacc 100644 --- a/apps/desktop/e2e/terminal-cancel.spec.ts +++ b/apps/desktop/e2e/terminal-cancel.spec.ts @@ -158,7 +158,6 @@ function bridge(window: Page) { const terminalId = await window.evaluate(async (scopeId) => { const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }) .simDesktop.terminal - if (!terminal) throw new Error('No agent terminal on this platform') await terminal.activateScope(scopeId) terminal.setVisible?.(true, scopeId) terminal.setFocused(true, scopeId) @@ -172,7 +171,7 @@ function bridge(window: Page) { ([id, text, scopeId]) => ( globalThis as typeof globalThis & { simDesktop: SimDesktopApi } - ).simDesktop.terminal?.write(id, text, scopeId), + ).simDesktop.terminal.write(id, text, scopeId), [terminalId, `${command}\r`, scope] as const ) }, @@ -181,7 +180,6 @@ function bridge(window: Page) { return window.evaluate(async (scopeId) => { const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }) .simDesktop.terminal - if (!terminal) throw new Error('No agent terminal on this platform') await terminal.activateScope(scopeId) return terminal.suspendScope(scopeId) }, scope) diff --git a/apps/desktop/scripts/build-native.ts b/apps/desktop/scripts/build-native.ts index b188017e1f5..648b4dd8939 100644 --- a/apps/desktop/scripts/build-native.ts +++ b/apps/desktop/scripts/build-native.ts @@ -5,9 +5,9 @@ import { createLogger } from '@sim/logger' const logger = createLogger('DesktopNativeBuild') if (process.platform === 'win32') { - // Both modules back features the Windows build disables (native local-file - // tools and macOS Help search), so there is nothing to compile. - logger.info('Skipping native desktop modules on Windows: their features are macOS/Linux only') + // Windows opens local files through Node fs (win32-native-files.ts) and has + // no Help search, so neither module is needed. + logger.info('Skipping native desktop modules on Windows: local files use Node fs there') process.exit(0) } if (process.platform !== 'darwin' && process.platform !== 'linux') { diff --git a/apps/desktop/src/main/browser-credentials/os-auth.ts b/apps/desktop/src/main/browser-credentials/os-auth.ts index c46dc1b1c6d..0c90d683326 100644 --- a/apps/desktop/src/main/browser-credentials/os-auth.ts +++ b/apps/desktop/src/main/browser-credentials/os-auth.ts @@ -24,10 +24,9 @@ const AUTH_GRACE_MS = 30_000 * `reveal` hands the plaintext to the Sim renderer; `copy` publishes it to the * system clipboard, which every process on the machine can read, which * clipboard managers persist to disk beyond the 30s `clipboard.clear()`, and - * which Universal Clipboard syncs to the user's other devices. (Windows keeps - * the same text in Clipboard History and the cloud clipboard, which is why - * copy is not offered there at all.) Ordering them + * which Universal Clipboard syncs to the user's other devices. Ordering them * either way lets one consent authorize an exposure the prompt never described. + * Windows does not offer copy at all: Clipboard History keeps the text. */ export type SecretOperation = 'reveal' | 'copy' diff --git a/apps/desktop/src/main/desktop-executor/runner.test.ts b/apps/desktop/src/main/desktop-executor/runner.test.ts index 7697ae3912b..e3df0c0bcf9 100644 --- a/apps/desktop/src/main/desktop-executor/runner.test.ts +++ b/apps/desktop/src/main/desktop-executor/runner.test.ts @@ -33,7 +33,6 @@ function terminalCall(toolCallId: string, operation: string): ClaimedDesktopCall function runner(overrides: Partial = {}) { return createDesktopToolRunner({ - platform: 'darwin', preferences: () => ({ browserEnabled: true, terminalEnabled: true }), accountDataAvailable: () => true, browser: { @@ -140,37 +139,7 @@ describe('background terminal calls', () => { }) }) -describe('terminal calls on Windows', () => { - it('runs them, since the Windows shell ships the terminal', async () => { - const executeTool = vi.fn(async () => ({ ok: true, result: { exitCode: 0 } })) - const completion = await runner({ - platform: 'win32', - terminal: { executeTool, cancelTool: vi.fn(async () => true) }, - }).run(terminalCall('call-windows', 'run'), new AbortController().signal) - - expect(completion.data).not.toMatchObject({ notStarted: true }) - expect(executeTool).toHaveBeenCalledTimes(1) - }) -}) - describe('local file calls', () => { - it.each(['read_local_file', 'import_local_files'])('starts %s on Windows', async (toolName) => { - const request = vi.fn(async () => ({ ok: false as const, error: 'unreadable' })) - await runner({ platform: 'win32', localFiles: { request } }).run( - { - toolCallId: 'local-1', - toolName, - args: { path: '~/notes', targetWorkspaceId: 'ws-1' }, - chatId: 'chat-b', - workspaceId: 'ws-1', - executionToken: 'token-local-1', - }, - new AbortController().signal - ) - - expect(request).toHaveBeenCalledOnce() - }) - it('names a passing storage state, not a setting, when local files are out of reach', async () => { const completion = await runner({ accountDataAvailable: () => false }).run( { diff --git a/apps/desktop/src/main/desktop-executor/runner.ts b/apps/desktop/src/main/desktop-executor/runner.ts index 359a271d947..8f1f789e365 100644 --- a/apps/desktop/src/main/desktop-executor/runner.ts +++ b/apps/desktop/src/main/desktop-executor/runner.ts @@ -52,7 +52,6 @@ import type { DesktopImportEntryRequest, DesktopImportedEntry, } from '@/main/desktop-executor/protocol' -import { desktopFeatures } from '@/shared/platform-features' const logger = createLogger('DesktopExecutorRunner') @@ -79,15 +78,7 @@ function unsupported(toolName: string): DesktopToolCompletion { return { status: 'error', message, data: { error: message, notStarted: true } } } -/** The tool needs a surface this platform's shell does not ship, so no update helps. */ -function notOnThisPlatform(toolName: string): DesktopToolCompletion { - const message = `Not run: this action never started, because the Sim desktop app cannot run ${toolName} on this operating system. Nothing happened on the user’s computer. Do not retry it; continue without it.` - return { status: 'error', message, data: { error: message, notStarted: true } } -} - export interface DesktopToolRunnerDeps { - /** Selects which {@link desktopFeatures} may run. Defaults to the host. */ - platform?: NodeJS.Platform preferences: () => { browserEnabled: boolean; terminalEnabled: boolean } /** False while account-bearing local storage is unavailable; local tools then refuse. */ accountDataAvailable: () => boolean @@ -167,7 +158,6 @@ async function withDeadline( } export function createDesktopToolRunner(deps: DesktopToolRunnerDeps): DesktopToolRunner { - const features = desktopFeatures(deps.platform ?? process.platform) /** Each chat's last terminal operation, until it actually settles. */ const unsettledTerminalWork = new Map>() @@ -345,15 +335,7 @@ export function createDesktopToolRunner(deps: DesktopToolRunnerDeps): DesktopToo async run(call, signal) { try { if (isCurrentBrowserToolName(call.toolName)) return await runBrowser(call, call.toolName) - if (call.toolName === 'terminal') { - return features.terminal ? await runTerminal(call, signal) : notOnThisPlatform('terminal') - } - if ( - !features.localFiles && - (call.toolName === 'read_local_file' || call.toolName === 'import_local_files') - ) { - return notOnThisPlatform(call.toolName) - } + if (call.toolName === 'terminal') return await runTerminal(call, signal) if (!deps.accountDataAvailable()) return localAccessUnavailable() if (call.toolName === 'read_local_file') { return localFileReadCompletion( diff --git a/apps/desktop/src/main/desktop-executor/service.test.ts b/apps/desktop/src/main/desktop-executor/service.test.ts index 242e4959c5a..9542c34ff5e 100644 --- a/apps/desktop/src/main/desktop-executor/service.test.ts +++ b/apps/desktop/src/main/desktop-executor/service.test.ts @@ -44,13 +44,10 @@ function fakeSim(protocolVersion = 1) { * `'offline'` for a request that never reached Sim. */ const registrations: Array<(answer: boolean | number | 'offline') => void> = [] - /** The JSON body of each registration request, in order. */ - const registrationBodies: unknown[] = [] const fetch = vi.fn(async (url: string, init: RequestInit): Promise => { const path = new URL(url).pathname requests.push(`${init.method} ${path}`) if (path === '/api/desktop/devices') { - registrationBodies.push(JSON.parse(String(init.body))) const answer = await new Promise((resolve) => registrations.push(resolve) ) @@ -96,14 +93,10 @@ function fakeSim(protocolVersion = 1) { init.signal?.addEventListener('abort', () => reject(new Error('aborted'))) ) }) - return { fetch, requests, registrations, registrationBodies, offered } + return { fetch, requests, registrations, offered } } -async function service( - protocolVersion = 1, - userDataPath?: string, - platform: NodeJS.Platform = 'darwin' -) { +async function service(protocolVersion = 1, userDataPath?: string) { const sim = fakeSim(protocolVersion) /** What the sleep blocker was told, in order. */ const busy: boolean[] = [] @@ -124,7 +117,6 @@ async function service( cancel: async () => {}, }, onBusyChange: (value) => busy.push(value), - platform, }) return { sim, desktopExecutor, busy } } @@ -227,21 +219,6 @@ describe('results recovery will hand to the model', () => { }) describe('desktop executor registration', () => { - it.each(['darwin', 'win32'] as const)( - 'advertises the terminal and native file tools on %s', - async (platform) => { - const { sim, desktopExecutor } = await service(1, undefined, platform) - desktopExecutor.start() - await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) - - expect(sim.registrationBodies[0]).toMatchObject({ - capabilities: { browser: true, terminal: true, localFiles: true }, - }) - sim.registrations[0]?.(false) - await desktopExecutor.signOut() - } - ) - it('offers the device for binding once Sim enables it', async () => { const { sim, desktopExecutor } = await service() desktopExecutor.start() @@ -334,25 +311,20 @@ describe('desktop executor registration', () => { } }) - it.skipIf(process.platform === 'win32')( - 'registers with no install id it could not save', - async () => { - const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-')) - await chmod(userData, 0o500) - try { - const { sim, desktopExecutor } = await service(1, userData) - - desktopExecutor.start() - await sleep(200) - - expect(sim.requests.filter((request) => request.includes('/api/desktop/devices'))).toEqual( - [] - ) - } finally { - await chmod(userData, 0o700) - } + it('registers with no install id it could not save', async () => { + const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-')) + await chmod(userData, 0o500) + try { + const { sim, desktopExecutor } = await service(1, userData) + + desktopExecutor.start() + await sleep(200) + + expect(sim.requests.filter((request) => request.includes('/api/desktop/devices'))).toEqual([]) + } finally { + await chmod(userData, 0o700) } - ) + }) it('keeps its install id through a read failure instead of minting a new one', async () => { const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-')) diff --git a/apps/desktop/src/main/desktop-executor/service.ts b/apps/desktop/src/main/desktop-executor/service.ts index c172d00e0f6..b51d5afcde2 100644 --- a/apps/desktop/src/main/desktop-executor/service.ts +++ b/apps/desktop/src/main/desktop-executor/service.ts @@ -41,7 +41,6 @@ import { type DesktopImportEntryRequest, type DesktopImportedEntry, } from '@/main/desktop-executor/protocol' -import { desktopFeatures } from '@/shared/platform-features' const logger = createLogger('DesktopExecutorService') @@ -68,8 +67,6 @@ export interface DesktopExecutorServiceDeps { onResultDelivered?: (toolCallId: string) => void /** Sim is done with a call whose real result the model never got. */ onResultNotDelivered?: (toolCallId: string) => void - /** Selects which {@link desktopFeatures} the device advertises. Defaults to the host. */ - platform?: NodeJS.Platform } export interface DesktopExecutorService { @@ -323,8 +320,6 @@ export function createDesktopExecutorService( return } const preferences = deps.preferences() - const platform = deps.platform ?? process.platform - const features = desktopFeatures(platform) const registrationClient = createDesktopExecutorClient({ origin: deps.origin, fetch: fetchWithAppSession, @@ -335,12 +330,12 @@ export function createDesktopExecutorService( deviceId: id, name: deviceName(), appVersion: app.getVersion(), - platform: `${platform}-${process.arch}`, + platform: `${process.platform}-${process.arch}`, capabilities: { executor: DESKTOP_EXECUTOR_PROTOCOL_VERSION, browser: preferences.browserEnabled, - terminal: features.terminal && preferences.terminalEnabled, - localFiles: features.localFiles && deps.accountDataAvailable(), + terminal: preferences.terminalEnabled, + localFiles: deps.accountDataAvailable(), }, }) if (registrationGeneration !== generation || id !== deviceId) return diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index d3609984728..0faafe51f6e 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -386,17 +386,12 @@ describe('registerIpcHandlers', () => { expect(invoke.has(channel) || on.has(channel)).toBe(false) }) - it('keeps the terminal, folder grants, native file tools, and password reveal on Windows', () => { + it('keeps password reveal on Windows while copy and import are withheld', () => { vi.mocked(ipcMain.handle).mockClear() vi.mocked(ipcMain.on).mockClear() registerIpcHandlers({ ...deps, platform: 'win32' }) - const { invoke, on } = collectHandlers() - expect(invoke.has('terminal:execute-tool')).toBe(true) - expect(on.has('terminal:write')).toBe(true) - expect(invoke.has('desktop:local-filesystem')).toBe(true) - expect(invoke.has('desktop:local-files')).toBe(true) - expect(invoke.has('browser-credentials:reveal')).toBe(true) + expect(collectHandlers().invoke.has('browser-credentials:reveal')).toBe(true) }) it('opens validated external URLs only after recent user input', async () => { diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index a63476acd5a..fe2d7453fc7 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -405,12 +405,10 @@ export interface IpcDeps { * them too, so a renderer that calls one anyway finds no handler. */ function platformFeatureFor(channel: string): keyof DesktopFeatures | null { - if (channel.startsWith('terminal:')) return 'terminal' if (channel.startsWith('terminal-themes:')) return 'terminalThemes' if (channel.startsWith('computer-use:')) return 'computerUse' if (channel.startsWith('browser-import:') || channel === 'browser-credentials:import') return 'browserImport' - if (channel === 'desktop:local-files') return 'localFiles' if (channel === 'browser-credentials:copy') return 'passwordCopy' return null } diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts index 63a77726ee7..352310d4b59 100644 --- a/apps/desktop/src/main/menu.test.ts +++ b/apps/desktop/src/main/menu.test.ts @@ -64,15 +64,9 @@ describe('buildMenuTemplate', () => { expect(windows).toContain('Alt+Right') }) - it('builds the Windows menu without macOS-only app roles', () => { - // windowMenu is excluded too: off macOS it adds a Close item on Ctrl+W - // that would steal the hidden Close Tab accelerator. + it('leaves out windowMenu on Windows, whose Close item would steal Ctrl+W from Close Tab', () => { const roles = flatten(buildMenuTemplate(makeDeps('win32'))).map((item) => item.role) - for (const role of ['hide', 'hideOthers', 'unhide', 'services', 'windowMenu', 'appMenu']) { - expect(roles).not.toContain(role) - } - expect(roles).toContain('editMenu') - expect(roles).toContain('quit') + expect(roles).not.toContain('windowMenu') }) it('binds each Windows accelerator to exactly one item', () => { diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index 017803e29ff..2d023e02886 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -255,16 +255,9 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] ] const resourceFileItems: MenuItemConstructorOptions[] = [ - /** - * Resource-scoped shortcuts: these act on whichever Browser/Terminal - * panel is focused, not on the app, so they stay out of the visible - * File menu. The accelerators still fire — macOS registers a hidden - * item's accelerator (`acceleratorWorksWhenHidden` defaults to true), - * and Windows always does. - * The numbered tab items sit flat here rather than under a "Select - * Tab" submenu because children of a hidden submenu do not reliably - * register their accelerators. - */ + // Panel-scoped shortcuts stay out of the visible File menu; hidden items' + // accelerators still fire on both platforms. Tab items sit flat because a + // hidden submenu's children do not reliably register accelerators. { label: 'New Tab', accelerator: 'CmdOrCtrl+T', diff --git a/apps/desktop/src/main/path-containment.ts b/apps/desktop/src/main/path-containment.ts index 5dc14795768..f8bb4621e41 100644 --- a/apps/desktop/src/main/path-containment.ts +++ b/apps/desktop/src/main/path-containment.ts @@ -3,11 +3,9 @@ import { isAbsolute, relative, sep } from 'node:path' /** * Whether a resolved path is the granted root or sits beneath it. * - * Uses `relative()` rather than prefix arithmetic. The `${root}${sep}` sentinel - * that form needs to reject `/granted-evil` against `/granted` becomes `//` when - * the root IS a separator — and the picker lets the user grant a volume root — - * so every path under a granted `/` was denied while the bare root passed. - * `relative()` has no such edge case: it returns `''` for the root itself and a + * Uses `relative()` rather than a `${root}${sep}` prefix check, which breaks + * when the root is itself a separator (the picker can grant a volume root). + * `relative()` returns `''` for the root itself and a * leading `..` segment for anything outside, on both separators. On Windows it * compares case-insensitively, as NTFS resolves names. */ diff --git a/apps/desktop/src/main/terminal/process-group.ts b/apps/desktop/src/main/terminal/process-group.ts index b33a12f5660..e7daee01081 100644 --- a/apps/desktop/src/main/terminal/process-group.ts +++ b/apps/desktop/src/main/terminal/process-group.ts @@ -66,7 +66,6 @@ async function readForegroundProcessGroup(shellPid: number): Promise { .filter((pid) => isProcessId(pid) && pid !== shellPid) } -/** Ends a process and everything it started. */ function killProcessTree(pid: number): void { const taskkill = win32.join(windowsSystem32(), 'taskkill.exe') const child = spawn(taskkill, ['/T', '/F', '/PID', String(pid)], { diff --git a/apps/desktop/src/main/terminal/shell-path.ts b/apps/desktop/src/main/terminal/shell-path.ts index 2019dbacd30..6711cb6a464 100644 --- a/apps/desktop/src/main/terminal/shell-path.ts +++ b/apps/desktop/src/main/terminal/shell-path.ts @@ -11,7 +11,6 @@ import { win32 } from 'node:path' export interface ShellPathContext { platform: NodeJS.Platform env: NodeJS.ProcessEnv - /** Whether a file exists at an absolute path. */ exists(path: string): boolean } diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index b2b55ffaf0e..df2e8ec7a9b 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -761,7 +761,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { markReady(version) }) - /** Network failures belong to their request promises; native errors invalidate staging. */ + // Network failures belong to their request promises; native errors invalidate staging. squirrelUpdater.on('error', (error) => { if ( state.status !== 'downloading' && @@ -774,10 +774,8 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { failNativeInstall(error) }) } else { - /** - * Without a native stage, the only installer failure left is launching - * it; download and signature failures reject their request promises. - */ + // Without a native stage, only launching the installer can fail here; + // download and signature failures reject their request promises. autoUpdater.on('error', (error) => { if (!installInFlight && !relaunchRequested) return failNativeInstall(error) diff --git a/apps/desktop/src/main/win32-native-files.test.ts b/apps/desktop/src/main/win32-native-files.test.ts index 702205e5fbe..980f2f1774b 100644 --- a/apps/desktop/src/main/win32-native-files.test.ts +++ b/apps/desktop/src/main/win32-native-files.test.ts @@ -19,8 +19,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) -import { LocalFilePermissions } from '@/main/local-file-permissions' -import { LocalFilesystemService } from '@/main/local-filesystem' import { type NativeFileHandle, openPosixNativeFile } from '@/main/native-directory' import { isWithinRoot } from '@/main/path-containment' import { openWin32NativeFile, type Win32NativeFileSystem } from '@/main/win32-native-files' @@ -172,28 +170,6 @@ describe('the Windows native file backend', () => { await expect(openBeneath(name)).rejects.toThrow('within the approved folder') }) - - it.runIf(isWindows)( - 'refuses an alternate data stream in a model path before asking for consent', - async () => { - const service = new LocalFilesystemService({ chooseDirectory: async () => null }) - await writeFile(join(root, 'notes.txt'), 'inside') - - await expect( - new LocalFilePermissions(service).authorize( - { toolName: 'read_local_file', args: { path: join(root, 'notes.txt:secret') } }, - { - parent: async () => null, - origin: 'https://sim.ai', - generation: 0, - signal: new AbortController().signal, - isCurrent: () => true, - revalidate: async () => true, - } - ) - ).rejects.toThrow('is not a plain Windows file or folder name') - } - ) }) describe.each(BACKENDS)('the %s backend contract', (_name, openApproved) => { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 8f935253b5b..ac421deced7 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -46,7 +46,6 @@ import type { LocalFilesystemRequest, LocalFilesystemResponse, SimDesktopApi, - SimDesktopTerminalApi, TerminalShortcutCommand, TerminalThemeProfile, } from '@sim/desktop-bridge' @@ -118,7 +117,7 @@ function shellVersion(): string { return version || '0.0.0' } -/** Every other platform keeps the Linux surface set, which is what it has always had. */ +/** Any other platform reports as Linux. */ function bridgePlatform(): DesktopPlatform { return process.platform === 'darwin' || process.platform === 'win32' ? process.platform : 'linux' } @@ -190,13 +189,8 @@ const api: SimDesktopApi = { }, localFilesystem: (request: LocalFilesystemRequest): Promise => ipcRenderer.invoke('desktop:local-filesystem', request), - ...(features.localFiles - ? { - localFiles: (request: DesktopLocalFileRequest): Promise => - ipcRenderer.invoke('desktop:local-files', request), - } - : {}), - // Claims cover user-local VFS reads too, which run on every platform. + localFiles: (request: DesktopLocalFileRequest): Promise => + ipcRenderer.invoke('desktop:local-files', request), localReadClaims: true, onCommand: (callback: (command: DesktopCommand) => void): (() => void) => { const listener = (_event: unknown, command: DesktopCommand) => callback(command) @@ -498,11 +492,7 @@ const api: SimDesktopApi = { ipcRenderer.invoke('browser-credentials:fill-selected', id, scopeId), onFillAvailability: subscribeFillAvailability, }, - ...(features.terminal ? { terminal: terminalApi() } : {}), -} - -function terminalApi(): SimDesktopTerminalApi { - return { + terminal: { restoreScope: (scopeId: string): Promise => ipcRenderer.invoke('terminal:restore-scope', scopeId), // The tool name rides alongside the call because the main process @@ -623,7 +613,7 @@ function terminalApi(): SimDesktopTerminalApi { ipcRenderer.removeListener('terminal:scope-suspended', listener) } }, - } + }, } contextBridge.exposeInMainWorld('simDesktop', api) diff --git a/apps/desktop/src/shared/platform-features.ts b/apps/desktop/src/shared/platform-features.ts index 12826b2c8b3..13b7438d0ad 100644 --- a/apps/desktop/src/shared/platform-features.ts +++ b/apps/desktop/src/shared/platform-features.ts @@ -10,16 +10,8 @@ export interface DesktopFeatures { computerUse: boolean /** The native macOS Help menu search field. */ helpSearch: boolean - terminal: boolean /** Terminal.app and iTerm2 color profiles. */ terminalThemes: boolean - /** - * `read_local_file` and `import_local_files`. They open files beneath a - * granted directory's identity: through the POSIX native module on macOS - * and Linux, and through checked Node fs opens on Windows. The user-local - * VFS grants are separate and run on Node fs everywhere. - */ - localFiles: boolean /** Chrome cookie and password import. */ browserImport: boolean /** @@ -32,9 +24,7 @@ export interface DesktopFeatures { const MAC_FEATURES: DesktopFeatures = { computerUse: true, helpSearch: true, - terminal: true, terminalThemes: true, - localFiles: true, browserImport: true, passwordCopy: true, } @@ -42,14 +32,12 @@ const MAC_FEATURES: DesktopFeatures = { const WINDOWS_FEATURES: DesktopFeatures = { computerUse: false, helpSearch: false, - terminal: true, terminalThemes: false, - localFiles: true, browserImport: false, passwordCopy: false, } -/** Linux is unpackaged; it keeps what the shell has always exposed there. */ +/** Linux is unpackaged: macOS's surfaces minus the macOS-only ones. */ const LINUX_FEATURES: DesktopFeatures = { ...MAC_FEATURES, computerUse: false, diff --git a/apps/sim/lib/desktop/index.ts b/apps/sim/lib/desktop/index.ts index 0a76cdb465b..2ebc7874b75 100644 --- a/apps/sim/lib/desktop/index.ts +++ b/apps/sim/lib/desktop/index.ts @@ -48,7 +48,7 @@ export function isDesktopApp(): boolean { /** True when the shell can serve read-only local-directory grants. */ export function hasLocalFilesystem(): boolean { - return typeof getDesktopBridge()?.localFilesystem === 'function' + return isDesktopApp() } /** True when the shell hosts the embedded agent browser. */ @@ -58,7 +58,7 @@ export function hasBrowserAgent(): boolean { /** True when the shell can run an interactive local shell for the agent. */ export function hasTerminal(): boolean { - return getDesktopBridge()?.terminal !== undefined + return isDesktopApp() } /** True when the shell exposes device-level desktop preferences. */ @@ -220,7 +220,7 @@ export async function getDesktopChatCapabilities( // spending a tool call to ask — and, more importantly, so it notices a // terminal that is occupied instead of launching a second copy into it. const terminals: DesktopTerminalHint[] = - terminal && bridge?.terminal + terminal && bridge ? await bridge.terminal .getTabs(scopeId) .then((state) => diff --git a/apps/sim/lib/desktop/preferences-loading.test.ts b/apps/sim/lib/desktop/preferences-loading.test.ts index df9444d3033..139a8ab545d 100644 --- a/apps/sim/lib/desktop/preferences-loading.test.ts +++ b/apps/sim/lib/desktop/preferences-loading.test.ts @@ -19,7 +19,7 @@ it('publishes asynchronous startup preferences and later settings changes to sub } const pending = Promise.withResolvers() const getPreferences = vi.fn(() => pending.promise) - vi.stubGlobal('window', { simDesktop: { settings: { getPreferences }, terminal: {} } }) + vi.stubGlobal('window', { simDesktop: { settings: { getPreferences } } }) const listener = vi.fn() const unsubscribe = subscribeDesktopPreferences(listener) const otherListener = vi.fn() diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index 98373e584a0..1b97f420a17 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -600,7 +600,7 @@ interface SimDesktopBrowserCredentialsApi { copy?(id: string): Promise /** * Copy saved passwords out of a Chrome profile into the vault. Absent where - * browser import is unavailable (Windows). + * browser import is unavailable (everything but macOS). */ importFromChrome?( profileId?: string, @@ -1212,10 +1212,7 @@ export interface SimDesktopApi { */ server?: SimDesktopServerApi localFilesystem(request: LocalFilesystemRequest): Promise - /** - * Optional so older installed shells do not advertise the new native tools. Also absent on - * platforms whose shell cannot run them (Windows today). - */ + /** Optional so older installed shells do not advertise the new native tools. */ localFiles?(request: DesktopLocalFileRequest): Promise /** * Present on shells that claim each local read (`read_local_file`, user-local VFS reads) through @@ -1236,8 +1233,7 @@ export interface SimDesktopApi { browserImport?: SimDesktopBrowserImportApi /** Saved passwords and user-driven fill for the built-in browser. */ browserCredentials: SimDesktopBrowserCredentialsApi - /** Absent on platforms without the agent terminal (Windows today). */ - terminal?: SimDesktopTerminalApi + terminal: SimDesktopTerminalApi /** * Runs desktop tools for chats the user is not viewing. Optional: shells without one leave * desktop tools to the chat view. diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index e104d90588c..c7fe29230e8 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -66,9 +66,8 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/demo-requests/route.ts', // Input-less session-bound GET: nothing to validate; response is // contract-typed via `satisfies InvitationDetails` in the route. - // Public updater feeds (macOS and Windows): input-less GETs, session-less, - // returning YAML (not JSON), so they can't be JSON-contract-bound. Wrapped in - // withRouteHandler. + // Public updater feeds (macOS and Windows): input-less, session-less GETs + // returning YAML, so they can't be JSON-contract-bound. 'apps/sim/app/api/desktop/update/latest-mac.yml/route.ts', 'apps/sim/app/api/desktop/update/latest.yml/route.ts', 'apps/sim/app/api/invitations/route.ts', From c39ccf1dcb58150db00a620eaafe1a3d6915e8ab Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:52:30 -0700 Subject: [PATCH 34/67] fix(desktop): keep a stopped or silent terminal command from reading as a prompt ConPTY ends a silent command's capture with cursor and colour codes, which the prompt watchdog read as text left mid-line, so every quiet command on Windows came back as running and awaiting input after 2.5 s, and a Stop's outcome was lost. The watchdog now judges the text without escape codes, and after Ctrl-C it waits for the command to end rather than taking the ^C echo for a prompt. --- .../desktop/src/main/terminal/session.test.ts | 65 ++++++++++++++++--- apps/desktop/src/main/terminal/session.ts | 30 ++++++--- 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/main/terminal/session.test.ts b/apps/desktop/src/main/terminal/session.test.ts index 3f5b87c319e..3c7be49791c 100644 --- a/apps/desktop/src/main/terminal/session.test.ts +++ b/apps/desktop/src/main/terminal/session.test.ts @@ -178,6 +178,61 @@ describe('TerminalSession command lifecycle', () => { } }) + describe('handing back a command that looks stuck on a prompt', () => { + function promptSession(): TerminalSession { + return TerminalSession.create({ + terminalId: 'terminal-prompt', + cwd: '/tmp', + cols: 80, + rows: 24, + callbacks: { onData: () => {}, onState: () => {}, onCommand: () => {}, onExit: () => {} }, + }) + } + + /** Whether `promise` has settled after `ms` of quiet. */ + async function settledAfter(promise: Promise, ms: number): Promise { + let settled = false + void promise.then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(ms) + return settled + } + + it('keeps waiting on a silent command whose only output is escape codes', async () => { + vi.useFakeTimers() + const session = promptSession() + try { + const result = session.runCommand('sleep 60', 'tool-call-silent', 60_000) + // ConPTY repaints the cursor and resets colour after the command line is echoed. + ptyStub.dataHandler?.('\u001b]633;C;test-nonce\u0007\u001b[?25h\u001b[m') + + expect(await settledAfter(result, 5_000)).toBe(false) + ptyStub.dataHandler?.('\u001b]633;D;0;test-nonce\u0007') + await expect(result).resolves.toMatchObject({ status: 'completed' }) + } finally { + session.dispose() + } + }) + + it("never reads an interrupted command's ^C echo as a prompt", async () => { + vi.useFakeTimers() + const session = promptSession() + try { + const result = session.runCommand('stubborn', 'tool-call-stop', 60_000) + ptyStub.dataHandler?.('\u001b]633;C;test-nonce\u0007working\r\n') + session.kill('SIGINT') + ptyStub.dataHandler?.('^C') + + expect(await settledAfter(result, 5_000)).toBe(false) + ptyStub.dataHandler?.('\u001b]633;D;143;test-nonce\u0007') + await expect(result).resolves.toMatchObject({ status: 'completed', exitCode: 143 }) + } finally { + session.dispose() + } + }) + }) + it('stops pressing a batch of keys once the call is stopped', async () => { vi.useFakeTimers() const session = TerminalSession.create({ @@ -269,16 +324,6 @@ describe('TerminalSession on Windows', () => { } }) - it("reports Git Bash's directory in the form Windows opens", () => { - const session = windowsSession() - try { - ptyStub.dataHandler?.(marker('P;Cwd=/c/Users/me/repo')) - expect(session.currentCwd).toBe('C:\\Users\\me\\repo') - } finally { - session.dispose() - } - }) - it('names the tab after the last folder of a Windows path', () => { const session = windowsSession() try { diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts index 9d6517f04e3..9952bc15cf7 100644 --- a/apps/desktop/src/main/terminal/session.ts +++ b/apps/desktop/src/main/terminal/session.ts @@ -37,7 +37,6 @@ import { detectShell, ShellIntegrationParser, shellKind, - toWindowsPath, } from '@/main/terminal/shell-integration' import { resolveShellPath } from '@/main/terminal/shell-path' @@ -260,6 +259,11 @@ interface PendingCommand { /** Rolling tail kept once {@link MAX_CAPTURE_CHARS} is exceeded. */ overflow: string capturing: boolean + /** + * Set once Ctrl-C was sent. Whatever it leaves on screen (the tty's `^C` echo) is the + * interrupt landing, not a prompt, so the command is waited on to end, or for Stop to end it. + */ + interrupted: boolean timer: NodeJS.Timeout resolve(result: TerminalRunResult): void } @@ -488,6 +492,11 @@ export class TerminalSession { return this.shellName } + /** The shell's program name (`zsh`, `pwsh`, `cmd`), for reading its command lines. */ + get shellProgram(): string { + return shellKind(this.shellName) + } + get foreground(): string | null { return this.foregroundCommand } @@ -526,7 +535,7 @@ export class TerminalSession { running: this.foregroundCommand === null ? null : this.foregroundCommand.trim() || 'command', interactive: this.altScreen, active, - shell: shellKind(this.shellName), + shell: this.shellProgram, } } @@ -664,6 +673,7 @@ export class TerminalSession { // SIGINT is delivered as a keystroke so the foreground process group gets // it the way Ctrl-C would, rather than only the shell. if (signal === 'SIGINT') { + if (this.pendingCommand) this.pendingCommand.interrupted = true this.write('\u0003') return } @@ -698,6 +708,7 @@ export class TerminalSession { output: '', overflow: '', capturing: false, + interrupted: false, timer, resolve, } @@ -911,14 +922,12 @@ export class TerminalSession { case 'output-end': this.finishCommand(marker.exitCode) break - case 'cwd': { - const cwd = this.platform === 'win32' ? toWindowsPath(marker.cwd) : marker.cwd - if (cwd && cwd !== this.cwd) { - this.cwd = cwd + case 'cwd': + if (marker.cwd && marker.cwd !== this.cwd) { + this.cwd = marker.cwd this.emitState() } break - } } } @@ -981,13 +990,14 @@ export class TerminalSession { * Hands a command back early when it has stopped mid-line and gone quiet — * the shape of something sitting on a prompt. Output that ends with a * newline, or that is still arriving, is a command doing work and is left to - * run out the full wait window. + * run out the full wait window. Escape sequences are not text: ConPTY ends a + * silent command's capture with cursor and colour codes that print nothing. */ private checkForPrompt(): void { const pending = this.pendingCommand - if (!pending?.capturing) return + if (!pending?.capturing || pending.interrupted) return if (Date.now() - pending.lastActivityAt < PROMPT_IDLE_MS) return - const captured = this.capturedText(pending) + const captured = stripAnsi(this.capturedText(pending)) if (!captured.trim() || /[\r\n]$/.test(captured)) return this.resolveStillRunning(true) } From 7c2a4a586206ee5e372cbf6dc9a763729ff6147f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:52:30 -0700 Subject: [PATCH 35/67] fix(desktop): give Git Bash its MSYS environment and Windows cwd The generated bash init file now reads /etc/profile when the shell is an MSYS or Cygwin bash, as a Git Bash login shell does, so /usr/bin (sh, cygpath) is on PATH. Its cwd is reported through cygpath -w, which also maps mounts such as /tmp that the /c/... rewrite could not. --- .../main/terminal/shell-integration.test.ts | 15 ------------ .../src/main/terminal/shell-integration.ts | 23 ++++++++----------- 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/main/terminal/shell-integration.test.ts b/apps/desktop/src/main/terminal/shell-integration.test.ts index 5d02a1dc300..2829dcff803 100644 --- a/apps/desktop/src/main/terminal/shell-integration.test.ts +++ b/apps/desktop/src/main/terminal/shell-integration.test.ts @@ -7,7 +7,6 @@ import { buildShellLaunch, detectShell, ShellIntegrationParser, - toWindowsPath, } from '@/main/terminal/shell-integration' const NONCE = 'testnonce' @@ -128,20 +127,6 @@ describe('detectShell', () => { }) }) -describe('toWindowsPath', () => { - it('turns the drive paths Git Bash reports into ones Windows can open', () => { - expect(toWindowsPath('/c/Users/me/my repo')).toBe('C:\\Users\\me\\my repo') - expect(toWindowsPath('/cygdrive/d/work')).toBe('D:\\work') - expect(toWindowsPath('/c')).toBe('C:\\') - }) - - it('leaves Windows paths and MSYS-only directories alone', () => { - expect(toWindowsPath('C:\\Users\\me')).toBe('C:\\Users\\me') - expect(toWindowsPath('\\\\server\\share')).toBe('\\\\server\\share') - expect(toWindowsPath('/tmp')).toBe('/tmp') - }) -}) - /** * The generated startup files run in a real shell, with the user's own files standing in as a * temp home whose startup prints a line. The startup marker has to reach the terminal before that diff --git a/apps/desktop/src/main/terminal/shell-integration.ts b/apps/desktop/src/main/terminal/shell-integration.ts index c15af44397e..30b8a309cb8 100644 --- a/apps/desktop/src/main/terminal/shell-integration.ts +++ b/apps/desktop/src/main/terminal/shell-integration.ts @@ -84,19 +84,6 @@ function unescapeValue(value: string): string { ) } -/** - * The Windows form of a directory Git Bash reports. MSYS shows drives as - * `/c/Users/me` (Cygwin as `/cygdrive/c/...`), which nothing outside the shell - * can open. Anything else, including a path already in Windows form, is - * returned unchanged. - */ -export function toWindowsPath(cwd: string): string { - const drive = /^\/(?:cygdrive\/)?([a-zA-Z])(?=\/|$)(.*)$/.exec(cwd) - if (!drive) return cwd - const [, letter, rest] = drive - return `${letter.toUpperCase()}:${rest.replaceAll('/', '\\') || '\\'}` -} - /** * Incremental parser. PTY chunks split escape sequences at arbitrary byte * offsets, so a partial trailing sequence is held back until the rest arrives @@ -281,6 +268,14 @@ function writeBashFile(dir: string, nonce: string): string { writeFileSync( rcPath, `builtin printf '\\e]633;SimStartup;%s\\a' '${nonce}' +# MSYS and Cygwin (Git Bash) set up PATH, /usr/bin included, in /etc/profile, which only a login +# shell reads; their cwd is a POSIX path (/c/..., /tmp) that only cygpath can map to Windows. +if [ -x /usr/bin/cygpath ]; then + [ -f /etc/profile ] && builtin source /etc/profile + __sim_cwd() { /usr/bin/cygpath -w "$PWD" 2>/dev/null || builtin printf '%s' "$PWD"; } +else + __sim_cwd() { builtin printf '%s' "$PWD"; } +fi [ -f "$HOME/.bashrc" ] && builtin source "$HOME/.bashrc" __sim_nonce='${nonce}' @@ -305,7 +300,7 @@ __sim_precmd() { local st=$? # Cwd is reported before the finish marker so a \`cd\` is already visible by # the time the command's result is resolved. - builtin printf '\\e]633;P;Cwd=%s;%s\\a' "$(__sim_esc "$PWD")" "$__sim_nonce" + builtin printf '\\e]633;P;Cwd=%s;%s\\a' "$(__sim_esc "$(__sim_cwd)")" "$__sim_nonce" if [ -n "$__sim_in_cmd" ]; then builtin printf '\\e]633;D;%s;%s\\a' "$st" "$__sim_nonce" fi From ee2aca93b1530d077195d012b2c89298c904cc59 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:52:30 -0700 Subject: [PATCH 36/67] feat(desktop): name a Windows program in the close-running-terminal dialog The dialog passes the terminal's shell to describeRunningCommand, so a PowerShell or cmd line reads its backslashes as path separators. --- apps/desktop/src/main/terminal/index.ts | 6 +++--- apps/desktop/src/main/terminal/registry.ts | 4 ++-- apps/desktop/src/main/terminal/service.test.ts | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts index 52114ddfdc1..ae98f2d294c 100644 --- a/apps/desktop/src/main/terminal/index.ts +++ b/apps/desktop/src/main/terminal/index.ts @@ -592,7 +592,7 @@ export class TerminalService { shortcut: FocusedResourceShortcut, ownerWindow: BrowserWindow | null, emitRendererCommand: (command: TerminalShortcutCommand, terminalId: string) => void, - confirmCloseRunning?: (running: string) => boolean | Promise + confirmCloseRunning?: (running: string, shell: string) => boolean | Promise ): boolean { // Hard reload and history have no terminal meaning — leave them to the Browser or shell. if ( @@ -657,13 +657,13 @@ export class TerminalService { private async confirmCloseRunningTerminal( terminal: TerminalSession, running: string, - confirm: (running: string) => boolean | Promise + confirm: (running: string, shell: string) => boolean | Promise ): Promise { const id = this.activeId if (!id || this.pendingCloseConfirmations.has(id)) return this.pendingCloseConfirmations.add(id) try { - if (!(await confirm(running))) return + if (!(await confirm(running, terminal.shellProgram))) return if (this.sessions.get(id) !== terminal) return if (terminal.isBusy && (terminal.foreground ?? 'A process') !== running) return this.closeTerminal(id) diff --git a/apps/desktop/src/main/terminal/registry.ts b/apps/desktop/src/main/terminal/registry.ts index 22c91489830..c7aab37873f 100644 --- a/apps/desktop/src/main/terminal/registry.ts +++ b/apps/desktop/src/main/terminal/registry.ts @@ -312,12 +312,12 @@ export class TerminalRegistry { terminalId ) }, - async (running) => { + async (running, shell) => { if (!ownerWindow || ownerWindow.isDestroyed()) return false const { response } = await showShellDialog(ownerWindow, { type: 'warning', title: 'Close running terminal?', - message: `${describeRunningCommand(running)} is still running.`, + message: `${describeRunningCommand(running, shell)} is still running.`, detail: 'Closing this terminal will stop the process.', buttons: ['Close Terminal', 'Cancel'], primaryVariant: 'destructive', diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts index 6729a4455d9..d1046b50c38 100644 --- a/apps/desktop/src/main/terminal/service.test.ts +++ b/apps/desktop/src/main/terminal/service.test.ts @@ -198,6 +198,7 @@ vi.mock('@/main/terminal/session', async () => { return state.cwd }, shell: 'zsh', + shellProgram: 'zsh', get foreground() { return state.busy ? 'sleep 1' : null }, @@ -331,7 +332,7 @@ describe('focus-gated shortcuts', () => { terminal.handleFocusedShortcut('close-tab', renderer.window, vi.fn(), confirmClose) ).toBe(true) - expect(confirmClose).toHaveBeenCalledWith('sleep 1') + expect(confirmClose).toHaveBeenCalledWith('sleep 1', 'zsh') expect(terminal.getTabs().activeTerminalId).toBe(activeId) }) From 9c8306a8bd73cc67dae97c1c2e72ff6f58d22409 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:57:50 -0700 Subject: [PATCH 37/67] fix(desktop): run Windows terminals on the bundled ConPTY, which keeps output in order The ConPTY built into Windows passes OSC sequences through ahead of text it has not rendered yet, so the finish marker of a quick command often arrived before its output and the agent got an empty result (a quarter to two fifths of runs on Windows Server 2025). node-pty's bundled conpty.dll and OpenConsole keep the stream in order. The Windows suite now runs a dozen commands per shell so a lost output shows. --- apps/desktop/src/main/terminal/session.ts | 3 +++ .../src/main/terminal/windows-shells.test.ts | 26 ++++++++++++------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts index 9952bc15cf7..e0cd8c86f31 100644 --- a/apps/desktop/src/main/terminal/session.ts +++ b/apps/desktop/src/main/terminal/session.ts @@ -422,6 +422,9 @@ export class TerminalSession { rows: options.rows, cwd: options.cwd, env: shellEnv, + // The ConPTY built into Windows can deliver an OSC marker ahead of the text written before + // it, so a command's output lands after its finish marker; the bundled build keeps order. + ...(platform === 'win32' ? { useConptyDll: true } : {}), }) logger.info('Started terminal session', { shell: shellPath, instrumented: shell !== null }) diff --git a/apps/desktop/src/main/terminal/windows-shells.test.ts b/apps/desktop/src/main/terminal/windows-shells.test.ts index 1c9888ff5b0..274efa45b23 100644 --- a/apps/desktop/src/main/terminal/windows-shells.test.ts +++ b/apps/desktop/src/main/terminal/windows-shells.test.ts @@ -29,7 +29,8 @@ const WINDOWS_POWERSHELL = join( interface ShellUnderTest { name: string path: string - succeed: string + /** Prints `sim-ok-`. */ + succeed(n: number): string fail: string /** A cmdlet that fails, which sets no exit code of its own. */ failingCmdlet?: string @@ -41,7 +42,7 @@ interface ShellUnderTest { } const POWERSHELL: Omit = { - succeed: 'Write-Output sim-ok', + succeed: (n) => `Write-Output sim-ok-${n}`, fail: 'cmd /c exit 3', failingCmdlet: 'Get-Item -LiteralPath C:\\sim-does-not-exist', quote: (path) => `'${path}'`, @@ -55,7 +56,7 @@ const SHELLS: ShellUnderTest[] = [ { name: 'Git Bash', path: 'C:\\Program Files\\Git\\bin\\bash.exe', - succeed: 'echo sim-ok', + succeed: (n) => `echo sim-ok-${n}`, fail: "sh -c 'exit 3'", quote: (path) => `'${path.replaceAll('\\', '/')}'`, cd: 'cd', @@ -70,6 +71,9 @@ Set-Content -LiteralPath $PidFile -Value $PID Start-Sleep -Seconds 120 ` +/** Enough runs that output lost to a reordering ConPTY (a quarter of runs or more) shows. */ +const OUTPUT_RUNS = 12 + /** A shell's first start on a cold CI runner, then the Stop escalation, take a while. */ const SHELL_TEST_TIMEOUT_MS = 90_000 @@ -115,12 +119,16 @@ describe.runIf(process.platform === 'win32')('the agent terminal on Windows', () const installed = existsSync(shell.path) it.runIf(installed)( - "reports a successful command's output and exit code", + "reports each successful command's output and exit code", async () => { - const result = await run(open(shell), 'call-ok', shell.succeed) - - expect(result).toMatchObject({ status: 'completed', exitCode: 0 }) - expect(result.output).toContain('sim-ok') + const terminal = open(shell) + // Repeated: a ConPTY that reorders output past the finish marker loses it only some runs. + for (let n = 0; n < OUTPUT_RUNS; n++) { + const result = await run(terminal, `call-ok-${n}`, shell.succeed(n)) + + expect(result).toMatchObject({ status: 'completed', exitCode: 0 }) + expect(result.output).toContain(`sim-ok-${n}`) + } }, SHELL_TEST_TIMEOUT_MS ) @@ -190,7 +198,7 @@ describe.runIf(process.platform === 'win32')('the agent terminal on Windows', () await expect(terminal.cancelTool('call-stubborn')).resolves.toBe(true) expect(await running).toMatchObject({ status: 'completed' }) expect(isAlive(pid)).toBe(false) - expect(await run(terminal, 'call-after', shell.succeed)).toMatchObject({ exitCode: 0 }) + expect(await run(terminal, 'call-after', shell.succeed(0))).toMatchObject({ exitCode: 0 }) }, SHELL_TEST_TIMEOUT_MS ) From 40841c9bc3894e0656ab19ab87b0bbf8bfc9e5fe Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:57:50 -0700 Subject: [PATCH 38/67] test(desktop): keep skipping the unwritable-directory test on Windows chmod cannot make a Windows directory unwritable; the skip was dropped with the platform gates. Also updates the README for Git Bash's cwd. --- apps/desktop/README.md | 2 +- .../src/main/desktop-executor/service.test.ts | 32 +++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 93e007c617d..951a803724f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -207,7 +207,7 @@ The Windows build is a one-click, per-user NSIS installer (`Sim--x64.ex - Updates: the NSIS updater requests `latest.yml` from the same `/api/desktop/update` feed. `ready` is reached when electron-updater's own `update-downloaded` fires (after the publisher check); there is no native staging step. A confirmed restart runs the installer silently and relaunches. Builds without a packaged `app-update.yml` fall back to the manual-download pipeline. Deltas use `.exe.blockmap`. - Publication: Windows publishes independently of macOS. `desktop-signing-windows` probes the Azure configuration and skips with a warning until it exists; unsigned Windows builds are never published. No job waits on the Windows calls, so a Windows failure never blocks the macOS release, and the feed treats a release without `latest.yml` as having no Windows build (Windows shells keep the newest release that has one). `latest.yml` is uploaded last. - SmartScreen: a new signing identity starts without download reputation, so early installers can show "Windows protected your PC" until reputation builds. Keep the same certificate profile across releases. -- Terminal: runs PowerShell 7 (`pwsh.exe`) when it is on `Path`, else Windows PowerShell 5.1, else cmd, which has no shell integration, so the agent cannot run commands in it. `SHELL` set to a Windows path (`C:\Program Files\Git\bin\bash.exe`) selects Git Bash, whose `/c/...` directories are reported in Windows form. PowerShell starts with `-NoProfile -NoExit -EncodedCommand`: the script emits the startup marker, loads the user's profiles in PowerShell's order under their own execution policy, then wraps `prompt` and PSReadLine's `PSConsoleHostReadLine` to emit the OSC 633 markers (no PSReadLine, no integration). Stop sends Ctrl-C, then `taskkill /T /F` to each of the shell's child processes. tmux and terminal themes are macOS and Linux only. `src/main/terminal/windows-shells.test.ts` drives PowerShell 7, Windows PowerShell 5.1 and Git Bash through ConPTY on the Windows CI leg. +- Terminal: runs PowerShell 7 (`pwsh.exe`) when it is on `Path`, else Windows PowerShell 5.1, else cmd, which has no shell integration, so the agent cannot run commands in it. `SHELL` set to a Windows path (`C:\Program Files\Git\bin\bash.exe`) selects Git Bash; its init file reads `/etc/profile` as a login shell would (for the MSYS `PATH`) and reports the cwd through `cygpath -w`. PowerShell starts with `-NoProfile -NoExit -EncodedCommand`: the script emits the startup marker, loads the user's profiles in PowerShell's order under their own execution policy, then wraps `prompt` and PSReadLine's `PSConsoleHostReadLine` to emit the OSC 633 markers (no PSReadLine, no integration). Stop sends Ctrl-C, then `taskkill /T /F` to each of the shell's child processes. tmux and terminal themes are macOS and Linux only. `src/main/terminal/windows-shells.test.ts` drives PowerShell 7, Windows PowerShell 5.1 and Git Bash through ConPTY on the Windows CI leg. - Native local-file tools: ship with the checks described in [Local filesystem access](#local-filesystem-access). - Not available on Windows yet: native Computer Use and importing from other browsers. diff --git a/apps/desktop/src/main/desktop-executor/service.test.ts b/apps/desktop/src/main/desktop-executor/service.test.ts index 9542c34ff5e..0f4cb74559b 100644 --- a/apps/desktop/src/main/desktop-executor/service.test.ts +++ b/apps/desktop/src/main/desktop-executor/service.test.ts @@ -311,20 +311,26 @@ describe('desktop executor registration', () => { } }) - it('registers with no install id it could not save', async () => { - const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-')) - await chmod(userData, 0o500) - try { - const { sim, desktopExecutor } = await service(1, userData) - - desktopExecutor.start() - await sleep(200) - - expect(sim.requests.filter((request) => request.includes('/api/desktop/devices'))).toEqual([]) - } finally { - await chmod(userData, 0o700) + // chmod cannot make a Windows directory unwritable. + it.skipIf(process.platform === 'win32')( + 'registers with no install id it could not save', + async () => { + const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-')) + await chmod(userData, 0o500) + try { + const { sim, desktopExecutor } = await service(1, userData) + + desktopExecutor.start() + await sleep(200) + + expect(sim.requests.filter((request) => request.includes('/api/desktop/devices'))).toEqual( + [] + ) + } finally { + await chmod(userData, 0o700) + } } - }) + ) it('keeps its install id through a read failure instead of minting a new one', async () => { const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-')) From 3bb88d5d4352d5c88ed7d3afbd6861ae57e32a00 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:04:34 -0700 Subject: [PATCH 39/67] fix(desktop): answer the bundled ConPTY's startup query so Windows shells start at once The bundled ConPTY sends a device-attributes query as it starts and holds the shell's output for three seconds when nothing answers, which an agent's shell (no panel) never does. The session answers that first query as xterm.js would and keeps it from the panel, so it is answered once. --- .../desktop/src/main/terminal/session.test.ts | 50 ++++++++++++++++--- apps/desktop/src/main/terminal/session.ts | 24 ++++++++- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/terminal/session.test.ts b/apps/desktop/src/main/terminal/session.test.ts index 3c7be49791c..4c7f367196d 100644 --- a/apps/desktop/src/main/terminal/session.test.ts +++ b/apps/desktop/src/main/terminal/session.test.ts @@ -293,19 +293,57 @@ describe('TerminalSession command lifecycle', () => { describe('TerminalSession on Windows', () => { const marker = (body: string) => `\u001b]633;${body};test-nonce\u0007` - function windowsSession(): TerminalSession { + function sessionOn( + platform: NodeJS.Platform, + onData: (terminalId: string, data: string) => void = () => {} + ): TerminalSession { return TerminalSession.create({ terminalId: 'terminal-windows', cwd: 'C:\\Users\\me', cols: 80, rows: 24, - platform: 'win32', - callbacks: { onData: () => {}, onState: () => {}, onCommand: () => {}, onExit: () => {} }, + platform, + callbacks: { onData, onState: () => {}, onCommand: () => {}, onExit: () => {} }, }) } + const DEVICE_ATTRIBUTES_REPLY = '\u001b[?1;2c' + + it("answers ConPTY's startup query once, which otherwise holds the shell back for 3 s", async () => { + vi.useFakeTimers() + let painted = '' + const session = sessionOn('win32', (_id, data) => { + painted += data + }) + try { + ptyStub.dataHandler?.('\u001b[1t') + ptyStub.dataHandler?.('\u001b[c\u001b[?1004h') + // A program's own query later on is the panel's to answer. + ptyStub.dataHandler?.('\u001b[c') + await vi.advanceTimersByTimeAsync(50) + + expect(ptyStub.writes.filter((write) => write === DEVICE_ATTRIBUTES_REPLY)).toHaveLength(1) + expect(painted).toBe('\u001b[1t\u001b[?1004h\u001b[c') + } finally { + session.dispose() + } + }) + + it('leaves the startup query of a macOS shell to the panel', async () => { + vi.useFakeTimers() + const session = sessionOn('darwin') + try { + ptyStub.dataHandler?.('\u001b[c') + await vi.advanceTimersByTimeAsync(50) + + expect(ptyStub.writes).not.toContain(DEVICE_ATTRIBUTES_REPLY) + } finally { + session.dispose() + } + }) + it('starts the shell without the POSIX login flag, which Windows shells reject', () => { - const session = windowsSession() + const session = sessionOn('win32') try { expect(ptyStub.args).not.toContain('-l') } finally { @@ -315,7 +353,7 @@ describe('TerminalSession on Windows', () => { it('ends the shell for a terminating signal, which node-pty refuses to send there', () => { ptyStub.signalsSupported = false - const session = windowsSession() + const session = sessionOn('win32') try { session.kill('SIGTERM') expect(ptyStub.ended).toBe(true) @@ -325,7 +363,7 @@ describe('TerminalSession on Windows', () => { }) it('names the tab after the last folder of a Windows path', () => { - const session = windowsSession() + const session = sessionOn('win32') try { ptyStub.dataHandler?.(marker('P;Cwd=D:\\\\work\\\\project')) expect(session.tabState(true).title).toBe('project') diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts index e0cd8c86f31..ee81edfb28a 100644 --- a/apps/desktop/src/main/terminal/session.ts +++ b/apps/desktop/src/main/terminal/session.ts @@ -220,6 +220,15 @@ const ALT_SCREEN_ENTER = '\u001b[?1049h' /** Restoring the normal screen: the full-screen program has quit. */ const ALT_SCREEN_EXIT = '\u001b[?1049l' +/** + * The bundled ConPTY asks the terminal for its device attributes as it starts and holds the + * shell's first output until it hears back, for three seconds when nothing answers. The session + * answers that one query itself, as xterm.js would, and keeps it from the panel so the reply is + * never sent twice; an agent's shell has no panel to answer it at all. + */ +const DEVICE_ATTRIBUTES_QUERY = '\u001b[c' +const DEVICE_ATTRIBUTES_REPLY = '\u001b[?1;2c' + /** * Scrollback the on-demand emulator keeps while rendering a read. * @@ -356,6 +365,8 @@ export class TerminalSession { private startupBegunAt: number | null = null private readonly spawnedAt = Date.now() private altScreen = false + /** Whether ConPTY's startup query is still to be answered. */ + private conptyQueryPending: boolean private foregroundCommand: string | null = null private foregroundToolCallId: string | null = null /** @@ -390,6 +401,7 @@ export class TerminalSession { this.integrationDir = integrationDir this.shellName = shellName this.parser = new ShellIntegrationParser(nonce) + this.conptyQueryPending = platform === 'win32' this.pty.onData((chunk) => this.handleData(chunk)) this.pty.onExit(() => this.handleExit()) @@ -865,7 +877,7 @@ export class TerminalSession { } private handleData(chunk: string): void { - const { text, markers } = this.parser.parse(chunk) + const { text, markers } = this.parser.parse(this.answerConptyQuery(chunk)) for (const marker of markers) { this.applyMarker(marker) @@ -890,6 +902,16 @@ export class TerminalSession { } } + /** Answers and removes ConPTY's startup device-attributes query, the first time it appears. */ + private answerConptyQuery(chunk: string): string { + if (!this.conptyQueryPending) return chunk + const index = chunk.indexOf(DEVICE_ATTRIBUTES_QUERY) + if (index === -1) return chunk + this.conptyQueryPending = false + this.pty.write(DEVICE_ATTRIBUTES_REPLY) + return chunk.slice(0, index) + chunk.slice(index + DEVICE_ATTRIBUTES_QUERY.length) + } + private applyMarker( marker: ReturnType['markers'][number] ): void { From 9e66d979396374db67c819964186dcef0de03611 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:15:09 -0700 Subject: [PATCH 40/67] perf(desktop): interrupt a stopped command before reading its processes Ctrl-C goes first and the foreground is read only when it fails, so the usual Stop on Windows never starts PowerShell. Later rounds re-check the processes captured then (liveness on Windows), and Windows skips the SIGKILL round since its SIGTERM round is already a forced tree kill. The System32 and Windows PowerShell paths now come from shell-path's case-insensitive env lookup, and the platform's process groups are a module constant. --- apps/desktop/src/main/terminal/index.ts | 19 ++++---- .../src/main/terminal/process-group.ts | 42 ++++++++++++----- .../desktop/src/main/terminal/service.test.ts | 47 +++++++++++++++++-- apps/desktop/src/main/terminal/shell-path.ts | 21 +++++---- 4 files changed, 94 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts index ae98f2d294c..b352957d38a 100644 --- a/apps/desktop/src/main/terminal/index.ts +++ b/apps/desktop/src/main/terminal/index.ts @@ -957,19 +957,22 @@ export class TerminalService { /** * Interrupts the command a run started. Escalation is bound to that command's own processes, - * read while it still holds the foreground, and each signal goes only to those of them that still - * hold it while the same call does, so a command the user starts meanwhile is never hit. + * read only once Ctrl-C has failed (on Windows the lookup starts PowerShell) and while it still + * holds the foreground; each signal goes only to those of them still running while the same call + * does, so a command the user starts meanwhile is never hit. */ private async stopCommand(session: TerminalSession, toolCallId: string): Promise { if (session.agentCommandToolCallId !== toolCallId) return - const groups = this.options.processGroups ?? osProcessGroups(process.platform) - const command = await groups.foreground(session.pid) - if (session.agentCommandToolCallId !== toolCallId) return + const groups = this.options.processGroups ?? osProcessGroups session.kill('SIGINT') - for (const escalation of ['SIGTERM', 'SIGKILL'] as const) { + let command: number[] | undefined + for (const escalation of groups.escalations) { if (await this.commandEnds(session, toolCallId, STOP_ESCALATION_MS)) return - const foreground = await groups.foreground(session.pid) - const remaining = command.filter((id) => foreground.includes(id)) + const remaining = + command === undefined + ? await groups.foreground(session.pid) + : await groups.stillRunning(session.pid, command) + command ??= remaining if (remaining.length === 0) return if (session.agentCommandToolCallId !== toolCallId) return logger.info('Stopped command ignored the previous signal; escalating', { diff --git a/apps/desktop/src/main/terminal/process-group.ts b/apps/desktop/src/main/terminal/process-group.ts index e7daee01081..c2ce1a231f6 100644 --- a/apps/desktop/src/main/terminal/process-group.ts +++ b/apps/desktop/src/main/terminal/process-group.ts @@ -9,6 +9,7 @@ import { spawn } from 'node:child_process' import { win32 } from 'node:path' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { windowsPowerShellPath, windowsSystem32 } from '@/main/terminal/shell-path' const logger = createLogger('DesktopTerminalProcessGroup') @@ -25,8 +26,12 @@ export interface TerminalProcessGroups { * lookup failed. */ foreground(shellPid: number): Promise + /** Those of `ids`, from an earlier {@link foreground}, that still run the foreground command. */ + stillRunning(shellPid: number, ids: number[]): Promise /** Sends `signal` to each id {@link foreground} returned. */ signal(ids: number[], signal: NodeJS.Signals): void + /** The signals a command that ignored Ctrl-C is sent, in order, until one ends it. */ + escalations: readonly NodeJS.Signals[] } const POSIX_PROCESS_GROUPS: TerminalProcessGroups = { @@ -34,26 +39,32 @@ const POSIX_PROCESS_GROUPS: TerminalProcessGroups = { const pgid = await readForegroundProcessGroup(shellPid) return pgid === null ? [] : [pgid] }, + stillRunning: async (shellPid, pgids) => { + const foreground = await POSIX_PROCESS_GROUPS.foreground(shellPid) + return pgids.filter((pgid) => foreground.includes(pgid)) + }, signal: (pgids, signal) => { for (const pgid of pgids) signalProcessGroup(pgid, signal) }, + escalations: ['SIGTERM', 'SIGKILL'], } /** - * Every escalation is a forced tree kill: a Windows console program cannot be asked to terminate, - * and Ctrl-C, the one request it can trap, has already been sent. + * The one escalation is a forced tree kill: a Windows console program cannot be asked to + * terminate, and Ctrl-C, the one request it can trap, has already been sent. */ const WINDOWS_PROCESS_GROUPS: TerminalProcessGroups = { foreground: readChildProcesses, + stillRunning: async (_shellPid, pids) => pids.filter(isRunning), signal: (pids) => { for (const pid of pids) killProcessTree(pid) }, + escalations: ['SIGTERM'], } -/** The operating system's way of reading and stopping a terminal's foreground command. */ -export function osProcessGroups(platform: NodeJS.Platform): TerminalProcessGroups { - return platform === 'win32' ? WINDOWS_PROCESS_GROUPS : POSIX_PROCESS_GROUPS -} +/** This operating system's way of reading and stopping a terminal's foreground command. */ +export const osProcessGroups: TerminalProcessGroups = + process.platform === 'win32' ? WINDOWS_PROCESS_GROUPS : POSIX_PROCESS_GROUPS /** * The tty's foreground process group, read from `ps`. Null when it cannot be read, or when the @@ -77,16 +88,11 @@ function signalProcessGroup(pgid: number, signal: NodeJS.Signals): void { } } -/** `%SystemRoot%\System32`, named in full so a program on PATH cannot stand in for the system's. */ -function windowsSystem32(): string { - return win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32') -} - /** The shell's direct children, from the process table; each one's tree is what it runs. */ async function readChildProcesses(shellPid: number): Promise { if (!isProcessId(shellPid)) return [] const stdout = await readOutput( - win32.join(windowsSystem32(), 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + windowsPowerShellPath(process.env), [ '-NoLogo', '-NoProfile', @@ -103,7 +109,7 @@ async function readChildProcesses(shellPid: number): Promise { } function killProcessTree(pid: number): void { - const taskkill = win32.join(windowsSystem32(), 'taskkill.exe') + const taskkill = win32.join(windowsSystem32(process.env), 'taskkill.exe') const child = spawn(taskkill, ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore', windowsHide: true, @@ -113,6 +119,16 @@ function killProcessTree(pid: number): void { }) } +/** Whether `pid` still names a process; signal 0 tests for one without sending anything. */ +function isRunning(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + function isProcessId(value: number): boolean { return Number.isInteger(value) && value > 0 } diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts index d1046b50c38..4d9395adbdd 100644 --- a/apps/desktop/src/main/terminal/service.test.ts +++ b/apps/desktop/src/main/terminal/service.test.ts @@ -486,7 +486,11 @@ describe('stopping a tool call', () => { let foreground: number[] = [COMMAND_GROUP] const processGroups = { foreground: vi.fn(async (_shellPid: number) => foreground), + stillRunning: vi.fn(async (_shellPid: number, ids: number[]) => + ids.filter((id) => foreground.includes(id)) + ), signal: vi.fn((_ids: number[], _signal: NodeJS.Signals) => {}), + escalations: ['SIGTERM', 'SIGKILL'] as NodeJS.Signals[], } const terminal = new TerminalService({ loadCwd: () => '/tmp', processGroups }) const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 }) @@ -521,6 +525,7 @@ describe('stopping a tool call', () => { expect(session.kill).toHaveBeenCalledWith('SIGINT') await expect(running).resolves.toMatchObject({ ok: true, result: { exitCode: 130 } }) + expect(processGroups.foreground).not.toHaveBeenCalled() expect(processGroups.signal).not.toHaveBeenCalled() }) @@ -540,26 +545,58 @@ describe('stopping a tool call', () => { const { terminal, session, processGroups, setForeground } = cancellableService() session.setInterruptible(false) setForeground([101, 102]) - processGroups.signal.mockImplementation(() => session.finishRun(1)) + // One of a Windows pipeline's processes exits after the first escalation. + processGroups.signal.mockImplementationOnce(() => setForeground([102, 103])) + processGroups.signal.mockImplementationOnce(() => session.finishRun(1)) const { running } = await startRun(terminal, session, 'call-pipeline') - // One of a Windows pipeline's processes exits on Ctrl-C and its pid is handed to a new one. - session.kill.mockImplementation(() => setForeground([102, 103])) await terminal.cancelTool('call-pipeline') - expect(processGroups.signal).toHaveBeenCalledWith([102], 'SIGTERM') + expect(processGroups.signal).toHaveBeenNthCalledWith(1, [101, 102], 'SIGTERM') + expect(processGroups.signal).toHaveBeenNthCalledWith(2, [102], 'SIGKILL') + expect(processGroups.foreground).toHaveBeenCalledTimes(1) await running }) it('never signals a different command that took the foreground meanwhile', async () => { const { terminal, session, processGroups, setForeground } = cancellableService() session.setInterruptible(false) + processGroups.signal.mockImplementation(() => setForeground([9999])) const { running } = await startRun(terminal, session, 'call-replaced') - session.kill.mockImplementation(() => setForeground([9999])) await terminal.cancelTool('call-replaced') + expect(processGroups.signal).toHaveBeenCalledTimes(1) + expect(processGroups.signal).toHaveBeenCalledWith([COMMAND_GROUP], 'SIGTERM') + session.finishRun(0) + await running + }) + + it('signals nothing when the command ends while its processes are read', async () => { + const { terminal, session, processGroups } = cancellableService() + session.setInterruptible(false) + processGroups.foreground.mockImplementation(async () => { + session.finishRun(0) + return [COMMAND_GROUP] + }) + const { running } = await startRun(terminal, session, 'call-ended') + + await terminal.cancelTool('call-ended') + expect(processGroups.signal).not.toHaveBeenCalled() + await running + }) + + it('stops escalating after the last signal the platform has', async () => { + const { terminal, session, processGroups } = cancellableService() + session.setInterruptible(false) + const { running } = await startRun(terminal, session, 'call-windows') + processGroups.escalations = ['SIGTERM'] + + await terminal.cancelTool('call-windows') + + expect(processGroups.signal).toHaveBeenCalledTimes(1) + expect(processGroups.signal).toHaveBeenCalledWith([COMMAND_GROUP], 'SIGTERM') session.finishRun(0) await running }) diff --git a/apps/desktop/src/main/terminal/shell-path.ts b/apps/desktop/src/main/terminal/shell-path.ts index 6711cb6a464..8964f1cac0d 100644 --- a/apps/desktop/src/main/terminal/shell-path.ts +++ b/apps/desktop/src/main/terminal/shell-path.ts @@ -33,19 +33,22 @@ function configuredWindowsShell({ env, exists }: ShellPathContext): string | nul return shell && WINDOWS_ABSOLUTE_PATH.test(shell) && exists(shell) ? shell : null } +/** `%SystemRoot%\System32`, named in full so a program on PATH cannot stand in for the system's. */ +export function windowsSystem32(env: NodeJS.ProcessEnv): string { + return win32.join(windowsEnv(env, 'SystemRoot') || 'C:\\Windows', 'System32') +} + +/** The Windows PowerShell 5.1 every Windows ships. */ +export function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { + return win32.join(windowsSystem32(env), 'WindowsPowerShell', 'v1.0', 'powershell.exe') +} + function defaultWindowsShell(context: ShellPathContext): string { - const systemRoot = windowsEnv(context.env, 'SystemRoot') || 'C:\\Windows' - const windowsPowerShell = win32.join( - systemRoot, - 'System32', - 'WindowsPowerShell', - 'v1.0', - 'powershell.exe' - ) + const windowsPowerShell = windowsPowerShellPath(context.env) return ( findOnPath('pwsh.exe', context) ?? (context.exists(windowsPowerShell) ? windowsPowerShell : null) ?? - (windowsEnv(context.env, 'ComSpec') || win32.join(systemRoot, 'System32', 'cmd.exe')) + (windowsEnv(context.env, 'ComSpec') || win32.join(windowsSystem32(context.env), 'cmd.exe')) ) } From 9dec590b283e100114abfea5de49faf63f130274 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:16:13 -0700 Subject: [PATCH 41/67] perf(desktop): run Git Bash's cygpath only when the directory changes The bash prompt hook kept the last mapped cwd in a shell variable and escapes into a variable instead of a $(...) subshell, so a prompt under MSYS, where every fork is slow, starts no process unless the directory moved. --- .../src/main/terminal/shell-integration.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/terminal/shell-integration.ts b/apps/desktop/src/main/terminal/shell-integration.ts index 30b8a309cb8..467ffeea1df 100644 --- a/apps/desktop/src/main/terminal/shell-integration.ts +++ b/apps/desktop/src/main/terminal/shell-integration.ts @@ -270,29 +270,36 @@ function writeBashFile(dir: string, nonce: string): string { `builtin printf '\\e]633;SimStartup;%s\\a' '${nonce}' # MSYS and Cygwin (Git Bash) set up PATH, /usr/bin included, in /etc/profile, which only a login # shell reads; their cwd is a POSIX path (/c/..., /tmp) that only cygpath can map to Windows. +# Starting a process is slow there, so cygpath runs only when the directory changed. if [ -x /usr/bin/cygpath ]; then [ -f /etc/profile ] && builtin source /etc/profile - __sim_cwd() { /usr/bin/cygpath -w "$PWD" 2>/dev/null || builtin printf '%s' "$PWD"; } + __sim_cwd_of='' + __sim_cwd() { + [ "$PWD" = "$__sim_cwd_of" ] && return + __sim_cwd_of=$PWD + __sim_cwd_value=$(/usr/bin/cygpath -w "$PWD" 2>/dev/null) || __sim_cwd_value=$PWD + } else - __sim_cwd() { builtin printf '%s' "$PWD"; } + __sim_cwd() { __sim_cwd_value=$PWD; } fi [ -f "$HOME/.bashrc" ] && builtin source "$HOME/.bashrc" __sim_nonce='${nonce}' __sim_in_cmd='' +# Sets __sim_escaped rather than printing it, so no subshell is forked to read it. __sim_esc() { - local s=\${1//\\\\/\\\\\\\\} - s=\${s//;/\\\\x3b} - s=\${s//$'\\n'/\\\\x0a} - builtin printf '%s' "$s" + __sim_escaped=\${1//\\\\/\\\\\\\\} + __sim_escaped=\${__sim_escaped//;/\\\\x3b} + __sim_escaped=\${__sim_escaped//$'\\n'/\\\\x0a} } __sim_preexec() { case "$BASH_COMMAND" in __sim_*) return ;; esac [ -n "$__sim_in_cmd" ] && return __sim_in_cmd=1 - builtin printf '\\e]633;E;%s;%s\\a' "$(__sim_esc "$BASH_COMMAND")" "$__sim_nonce" + __sim_esc "$BASH_COMMAND" + builtin printf '\\e]633;E;%s;%s\\a' "$__sim_escaped" "$__sim_nonce" builtin printf '\\e]633;C;%s\\a' "$__sim_nonce" } @@ -300,7 +307,9 @@ __sim_precmd() { local st=$? # Cwd is reported before the finish marker so a \`cd\` is already visible by # the time the command's result is resolved. - builtin printf '\\e]633;P;Cwd=%s;%s\\a' "$(__sim_esc "$(__sim_cwd)")" "$__sim_nonce" + __sim_cwd + __sim_esc "$__sim_cwd_value" + builtin printf '\\e]633;P;Cwd=%s;%s\\a' "$__sim_escaped" "$__sim_nonce" if [ -n "$__sim_in_cmd" ]; then builtin printf '\\e]633;D;%s;%s\\a' "$st" "$__sim_nonce" fi From 6f95c5998f5f3f075f02ccf7d67caf52e41e84a9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:18:41 -0700 Subject: [PATCH 42/67] refactor(desktop): keep node-pty's platform differences in terminal/pty spawnTerminalPty picks the login arguments and the bundled ConPTY, drops the signal Windows cannot send, and answers ConPTY's startup device-attributes query before the session sees the output. The session computes its shell's program name once, and the shell path is cached under one rule for every platform. --- apps/desktop/src/main/terminal/pty.ts | 79 ++++++++++++++++++++ apps/desktop/src/main/terminal/session.ts | 87 ++++++++--------------- 2 files changed, 108 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/src/main/terminal/pty.ts diff --git a/apps/desktop/src/main/terminal/pty.ts b/apps/desktop/src/main/terminal/pty.ts new file mode 100644 index 00000000000..bb77b2e66b5 --- /dev/null +++ b/apps/desktop/src/main/terminal/pty.ts @@ -0,0 +1,79 @@ +/** + * The pseudo-terminal a shell runs in, with node-pty's platform differences kept here so the + * session above it reads the same everywhere. + * + * On Windows the shell runs on the bundled ConPTY: the one built into Windows can deliver an OSC + * marker ahead of the text written before it, so a command's output would land after its finish + * marker. That build asks for the terminal's device attributes as it starts and holds the shell's + * first output until it hears back, three seconds when nothing answers, so the pty answers that + * one query itself, as xterm.js would, and keeps it from the output so the reply is never sent + * twice; an agent's shell has no panel to answer it at all. node-pty there cannot send a signal + * either, only end the shell. + */ +import { spawn } from '@lydell/node-pty' + +const DEVICE_ATTRIBUTES_QUERY = '\u001b[c' +const DEVICE_ATTRIBUTES_REPLY = '\u001b[?1;2c' + +export interface TerminalPtyOptions { + platform: NodeJS.Platform + shellPath: string + /** The shell's arguments; omitted, it starts as a login shell where the platform has one. */ + args?: string[] + cols: number + rows: number + cwd: string + env: NodeJS.ProcessEnv +} + +/** A running shell's pseudo-terminal. */ +export interface TerminalPty { + readonly pid: number + /** Output as the shell wrote it, less the startup query the pty answered. Register once. */ + onData(handler: (data: string) => void): void + onExit(handler: () => void): void + write(data: string): void + resize(cols: number, rows: number): void + pause(): void + resume(): void + /** Sends `signal` to the shell; on Windows, which has no signals, ends it. */ + kill(signal?: NodeJS.Signals): void +} + +export function spawnTerminalPty(options: TerminalPtyOptions): TerminalPty { + const windows = options.platform === 'win32' + const pty = spawn(options.shellPath, options.args ?? (windows ? [] : ['-l']), { + name: 'xterm-256color', + cols: options.cols, + rows: options.rows, + cwd: options.cwd, + env: options.env, + ...(windows ? { useConptyDll: true } : {}), + }) + + let startupQueryPending = windows + const answerStartupQuery = (chunk: string): string => { + const index = chunk.indexOf(DEVICE_ATTRIBUTES_QUERY) + if (index === -1) return chunk + startupQueryPending = false + pty.write(DEVICE_ATTRIBUTES_REPLY) + return chunk.slice(0, index) + chunk.slice(index + DEVICE_ATTRIBUTES_QUERY.length) + } + + return { + get pid() { + return pty.pid + }, + onData: (handler) => { + pty.onData((chunk) => handler(startupQueryPending ? answerStartupQuery(chunk) : chunk)) + }, + onExit: (handler) => { + pty.onExit(() => handler()) + }, + write: (data) => pty.write(data), + resize: (cols, rows) => pty.resize(cols, rows), + pause: () => pty.pause(), + resume: () => pty.resume(), + kill: (signal) => (windows ? pty.kill() : pty.kill(signal)), + } +} diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts index ee81edfb28a..52b9791cd68 100644 --- a/apps/desktop/src/main/terminal/session.ts +++ b/apps/desktop/src/main/terminal/session.ts @@ -9,8 +9,6 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { IPty } from '@lydell/node-pty' -import { spawn } from '@lydell/node-pty' import { createLogger } from '@sim/logger' import { MAX_CAPTURE_CHARS, @@ -31,6 +29,7 @@ import { type IBufferLine, } from '@xterm/headless' import { readProcessCwd } from '@/main/terminal/process-cwd' +import { spawnTerminalPty, type TerminalPty } from '@/main/terminal/pty' import { buildShellLaunch, createNonce, @@ -220,15 +219,6 @@ const ALT_SCREEN_ENTER = '\u001b[?1049h' /** Restoring the normal screen: the full-screen program has quit. */ const ALT_SCREEN_EXIT = '\u001b[?1049l' -/** - * The bundled ConPTY asks the terminal for its device attributes as it starts and holds the - * shell's first output until it hears back, for three seconds when nothing answers. The session - * answers that one query itself, as xterm.js would, and keeps it from the panel so the reply is - * never sent twice; an agent's shell has no panel to answer it at all. - */ -const DEVICE_ATTRIBUTES_QUERY = '\u001b[c' -const DEVICE_ATTRIBUTES_REPLY = '\u001b[?1;2c' - /** * Scrollback the on-demand emulator keeps while rendering a read. * @@ -315,28 +305,34 @@ export interface TerminalSessionOptions { } /** - * The Windows shell, found once for each `SHELL`: the search stats every PATH + * The shell, found once for each platform and `SHELL`: on Windows the search stats every PATH * entry, and which shells are installed does not change while Sim runs. */ -let windowsShell: { configured: string | undefined; path: string } | null = null +let resolvedShell: { + platform: NodeJS.Platform + configured: string | undefined + path: string +} | null = null function shellPathFor(platform: NodeJS.Platform): string { - const context = { platform, env: process.env, exists: existsSync } - if (platform !== 'win32') return resolveShellPath(context) - if (windowsShell === null || windowsShell.configured !== process.env.SHELL) { - windowsShell = { configured: process.env.SHELL, path: resolveShellPath(context) } + const configured = process.env.SHELL + if (resolvedShell?.platform !== platform || resolvedShell.configured !== configured) { + const path = resolveShellPath({ platform, env: process.env, exists: existsSync }) + resolvedShell = { platform, configured, path } } - return windowsShell.path + return resolvedShell.path } export class TerminalSession { - private readonly pty: IPty + private readonly pty: TerminalPty /** The environment the shell was spawned with, for tmux to reuse. */ private readonly shellEnv: NodeJS.ProcessEnv private readonly parser: ShellIntegrationParser private readonly integrationDir: string private readonly callbacks: TerminalSessionCallbacks private readonly shellName: string + /** The shell's program name (`zsh`, `pwsh`, `cmd`), for reading its command lines. */ + readonly shellProgram: string readonly terminalId: string /** @@ -365,8 +361,6 @@ export class TerminalSession { private startupBegunAt: number | null = null private readonly spawnedAt = Date.now() private altScreen = false - /** Whether ConPTY's startup query is still to be answered. */ - private conptyQueryPending: boolean private foregroundCommand: string | null = null private foregroundToolCallId: string | null = null /** @@ -383,13 +377,12 @@ export class TerminalSession { private constructor( options: TerminalSessionOptions, - pty: IPty, + pty: TerminalPty, integrationDir: string, nonce: string, shellName: string, shellEnv: NodeJS.ProcessEnv, - private readonly instrumented: boolean, - private readonly platform: NodeJS.Platform + private readonly instrumented: boolean ) { this.callbacks = options.callbacks this.terminalId = options.terminalId @@ -400,8 +393,8 @@ export class TerminalSession { this.shellEnv = shellEnv this.integrationDir = integrationDir this.shellName = shellName + this.shellProgram = shellKind(shellName) this.parser = new ShellIntegrationParser(nonce) - this.conptyQueryPending = platform === 'win32' this.pty.onData((chunk) => this.handleData(chunk)) this.pty.onExit(() => this.handleExit()) @@ -421,22 +414,18 @@ export class TerminalSession { const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = process.env as Record // A shell we cannot instrument still gives the user a working terminal; - // the agent is refused separately via NO_SHELL_INTEGRATION. Windows shells - // have no login mode. - const launch = shell - ? buildShellLaunch(shell, integrationDir, nonce, env) - : { args: platform === 'win32' ? [] : ['-l'], env: {} } - - const shellEnv = { ...env, ...launch.env, TERM: 'xterm-256color', TERM_PROGRAM: 'Sim' } - const pty = spawn(shellPath, launch.args, { - name: 'xterm-256color', + // the agent is refused separately via NO_SHELL_INTEGRATION. + const launch = shell ? buildShellLaunch(shell, integrationDir, nonce, env) : null + + const shellEnv = { ...env, ...launch?.env, TERM: 'xterm-256color', TERM_PROGRAM: 'Sim' } + const pty = spawnTerminalPty({ + platform, + shellPath, + args: launch?.args, cols: options.cols, rows: options.rows, cwd: options.cwd, env: shellEnv, - // The ConPTY built into Windows can deliver an OSC marker ahead of the text written before - // it, so a command's output lands after its finish marker; the bundled build keeps order. - ...(platform === 'win32' ? { useConptyDll: true } : {}), }) logger.info('Started terminal session', { shell: shellPath, instrumented: shell !== null }) @@ -447,8 +436,7 @@ export class TerminalSession { nonce, shell ?? shellPath, shellEnv, - shell !== null, - platform + shell !== null ) } @@ -507,11 +495,6 @@ export class TerminalSession { return this.shellName } - /** The shell's program name (`zsh`, `pwsh`, `cmd`), for reading its command lines. */ - get shellProgram(): string { - return shellKind(this.shellName) - } - get foreground(): string | null { return this.foregroundCommand } @@ -693,9 +676,7 @@ export class TerminalSession { return } try { - // node-pty on Windows throws when given a signal: there it can only end the shell. - if (this.platform === 'win32') this.pty.kill() - else this.pty.kill(signal) + this.pty.kill(signal) } catch (error) { logger.warn('Failed to signal pty', { signal, error: (error as Error).message }) } @@ -877,7 +858,7 @@ export class TerminalSession { } private handleData(chunk: string): void { - const { text, markers } = this.parser.parse(this.answerConptyQuery(chunk)) + const { text, markers } = this.parser.parse(chunk) for (const marker of markers) { this.applyMarker(marker) @@ -902,16 +883,6 @@ export class TerminalSession { } } - /** Answers and removes ConPTY's startup device-attributes query, the first time it appears. */ - private answerConptyQuery(chunk: string): string { - if (!this.conptyQueryPending) return chunk - const index = chunk.indexOf(DEVICE_ATTRIBUTES_QUERY) - if (index === -1) return chunk - this.conptyQueryPending = false - this.pty.write(DEVICE_ATTRIBUTES_REPLY) - return chunk.slice(0, index) + chunk.slice(index + DEVICE_ATTRIBUTES_QUERY.length) - } - private applyMarker( marker: ReturnType['markers'][number] ): void { From 7fc52bf45d8e435cb1cd18a4790a79cc20a89e7b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:18:41 -0700 Subject: [PATCH 43/67] refactor(desktop): name shell programs with terminal-protocol's programName shellKind keeps only the login-dash strip and lowercase on top of the shared directory and .exe handling. SUPPORTED_SHELLS is a const tuple the union derives from, read without a cast, and encodePowerShellCommand is private. --- .../src/main/terminal/shell-integration.ts | 22 ++++++---------- packages/terminal-protocol/src/index.ts | 25 ++++++++++++++----- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/terminal/shell-integration.ts b/apps/desktop/src/main/terminal/shell-integration.ts index 467ffeea1df..fb8d648f3df 100644 --- a/apps/desktop/src/main/terminal/shell-integration.ts +++ b/apps/desktop/src/main/terminal/shell-integration.ts @@ -18,16 +18,12 @@ import { randomBytes } from 'node:crypto' import { mkdirSync, writeFileSync } from 'node:fs' import { join, sep } from 'node:path' +import { programName } from '@sim/terminal-protocol' -/** Shells we can install prompt hooks into. */ -export type SupportedShell = 'zsh' | 'bash' | 'pwsh' | 'powershell' +const SUPPORTED_SHELLS = ['zsh', 'bash', 'pwsh', 'powershell'] as const -const SUPPORTED_SHELLS: ReadonlySet = new Set([ - 'zsh', - 'bash', - 'pwsh', - 'powershell', -]) +/** Shells we can install prompt hooks into. */ +export type SupportedShell = (typeof SUPPORTED_SHELLS)[number] /** * A shell's program name, as it is reported to the agent: no directory (either @@ -35,16 +31,12 @@ const SUPPORTED_SHELLS: ReadonlySet = new Set([ * `powershell`, `-zsh` is `zsh`. */ export function shellKind(shellPath: string): string { - const name = shellPath.split(/[\\/]/).pop() ?? '' - return name - .replace(/^-/, '') - .replace(/\.exe$/i, '') - .toLowerCase() + return programName(shellPath).replace(/^-/, '').toLowerCase() } export function detectShell(shellPath: string): SupportedShell | null { const kind = shellKind(shellPath) - return SUPPORTED_SHELLS.has(kind) ? (kind as SupportedShell) : null + return SUPPORTED_SHELLS.find((shell) => shell === kind) ?? null } export function createNonce(): string { @@ -414,7 +406,7 @@ if (Test-Path -Path Function:\PSConsoleHostReadLine) { } /** PowerShell's `-EncodedCommand` takes base64 of UTF-16LE text. */ -export function encodePowerShellCommand(script: string): string { +function encodePowerShellCommand(script: string): string { return Buffer.from(script, 'utf16le').toString('base64') } diff --git a/packages/terminal-protocol/src/index.ts b/packages/terminal-protocol/src/index.ts index ad32d3a5e4b..0b666ca4da2 100644 --- a/packages/terminal-protocol/src/index.ts +++ b/packages/terminal-protocol/src/index.ts @@ -386,16 +386,29 @@ export function describeRunningCommand(running: string, shell?: string): string const label = (program - ? (program - .replace(/^['"]|['"]$/g, '') - .split(backslashEscapes ? '/' : /[\\/]/) - .filter(Boolean) - .pop() - ?.replace(/\.exe$/i, '') ?? '') + ? lastPathSegment(program.replace(/^['"]|['"]$/g, ''), backslashEscapes ? '/' : /[\\/]/) : '') || segment return truncate(label, MAX_RUNNING_COMMAND_LABEL, '…') } +/** + * A program's name from its path: the last segment, split on either separator as Windows reads + * both, without `.exe` in any case. `C:\Windows\System32\cmd.EXE` is `cmd`. + */ +export function programName(path: string): string { + return lastPathSegment(path, /[\\/]/) +} + +function lastPathSegment(path: string, separator: string | RegExp): string { + return ( + path + .split(separator) + .filter(Boolean) + .pop() + ?.replace(/\.exe$/i, '') ?? '' + ) +} + /** One tmux pane, as reported by the `panes` operation. */ export interface TerminalPaneState { /** tmux target (`session:window.pane`), usable as the `pane` argument. */ From c7a5094c73a6ef4232b7d46aa140f6efab5093e2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:19:42 -0700 Subject: [PATCH 44/67] refactor(desktop): read tmux support from the platform feature table A tmux row, off on Windows, replaces tmux.ts's own platform check, and the Linux row is written out in full rather than as macOS minus overrides. --- apps/desktop/src/main/terminal/tmux.ts | 11 +++++------ apps/desktop/src/shared/platform-features.ts | 11 ++++++++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/terminal/tmux.ts b/apps/desktop/src/main/terminal/tmux.ts index 9c7b729f2db..4f50ff697b2 100644 --- a/apps/desktop/src/main/terminal/tmux.ts +++ b/apps/desktop/src/main/terminal/tmux.ts @@ -26,6 +26,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { generateRandomHex } from '@sim/utils/random' +import { desktopFeatures } from '@/shared/platform-features' const logger = createLogger('DesktopTmux') @@ -97,13 +98,11 @@ export interface TmuxAttachment { */ let tmuxBinaryMissing = false -/** - * Whether tmux is off the table. Always on Windows: attaching matches tmux - * clients to the shell through POSIX ttys and `ps`, and an MSYS or Cygwin tmux - * on PATH lives in a process and path world the Windows shell is not part of. - */ +const TMUX_SUPPORTED = desktopFeatures(process.platform).tmux + +/** Whether tmux is off the table: never attached on this platform, or not installed. */ export function isTmuxUnavailable(): boolean { - return process.platform === 'win32' || tmuxBinaryMissing + return !TMUX_SUPPORTED || tmuxBinaryMissing } /** diff --git a/apps/desktop/src/shared/platform-features.ts b/apps/desktop/src/shared/platform-features.ts index 13b7438d0ad..f12d0304cb2 100644 --- a/apps/desktop/src/shared/platform-features.ts +++ b/apps/desktop/src/shared/platform-features.ts @@ -19,6 +19,12 @@ export interface DesktopFeatures { * Clipboard History and the cloud clipboard keep the text after Sim clears it. */ passwordCopy: boolean + /** + * Attaching the agent to tmux running in a terminal. Off on Windows: attaching matches tmux + * clients to the shell through POSIX ttys and `ps`, and an MSYS or Cygwin tmux on PATH lives in + * a process and path world the Windows shell is not part of. + */ + tmux: boolean } const MAC_FEATURES: DesktopFeatures = { @@ -27,6 +33,7 @@ const MAC_FEATURES: DesktopFeatures = { terminalThemes: true, browserImport: true, passwordCopy: true, + tmux: true, } const WINDOWS_FEATURES: DesktopFeatures = { @@ -35,15 +42,17 @@ const WINDOWS_FEATURES: DesktopFeatures = { terminalThemes: false, browserImport: false, passwordCopy: false, + tmux: false, } /** Linux is unpackaged: macOS's surfaces minus the macOS-only ones. */ const LINUX_FEATURES: DesktopFeatures = { - ...MAC_FEATURES, computerUse: false, helpSearch: false, terminalThemes: false, browserImport: false, + passwordCopy: true, + tmux: true, } /** The desktop surfaces available on `platform`. */ From ff5f251ff60082060115a80f53714223d34a6ba9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:20:27 -0700 Subject: [PATCH 45/67] refactor(desktop): declare each IPC channel's platform feature on its spec The channel table carries platformFeature on the terminal-themes, computer-use, browser-import, and browser-credentials import/copy entries, and the registration loop reads it in place of a channel-name prefix mapping. --- apps/desktop/src/main/ipc.ts | 39 +++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index fe2d7453fc7..18c2b344e40 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -399,20 +399,6 @@ export interface IpcDeps { platform?: NodeJS.Platform } -/** - * The platform feature a channel belongs to, or null when every platform has it. - * The preload omits these surfaces where the platform lacks them; main refuses - * them too, so a renderer that calls one anyway finds no handler. - */ -function platformFeatureFor(channel: string): keyof DesktopFeatures | null { - if (channel.startsWith('terminal-themes:')) return 'terminalThemes' - if (channel.startsWith('computer-use:')) return 'computerUse' - if (channel.startsWith('browser-import:') || channel === 'browser-credentials:import') - return 'browserImport' - if (channel === 'browser-credentials:copy') return 'passwordCopy' - return null -} - /** * Who may call a channel: * - `app-origin`: only the remote app origin (main window pages). @@ -438,6 +424,12 @@ type ChannelFeature = 'browser' | 'terminal' interface ChannelSpecBase { gate: ChannelGate + /** + * The platform feature the channel belongs to; absent when every platform has it. The preload + * omits these surfaces where the platform lacks them, and main never registers the channel + * there, so a renderer that calls one anyway finds no handler. + */ + platformFeature?: keyof DesktopFeatures passSender?: boolean requires?: ChannelFeature /** Account-bearing storage must be readable and writable before this channel can run. */ @@ -727,6 +719,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:get-activity': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, denied: null, handler: () => deps.computerUse?.getActivity() ?? null, @@ -734,6 +727,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:status': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, denied: null, handler: () => deps.computerUse?.getStatus(), @@ -741,6 +735,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:set-enabled': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, needsUserActivation: true, denied: null, @@ -753,6 +748,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:request-permission': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, needsUserActivation: true, denied: null, @@ -765,6 +761,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:list-permissions': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, denied: [], handler: () => deps.computerUse?.listAppPermissions() ?? [], @@ -772,6 +769,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:revoke-app': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, needsUserActivation: true, denied: undefined, @@ -784,6 +782,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:cancel': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', denied: undefined, handler: (toolCallId) => { if (toolCallId !== undefined && !isDesktopToolCallId(toolCallId)) @@ -794,6 +793,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'computer-use:execute-tool': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'computerUse', requiresAccountData: true, denied: undefined, handler: (scopeId, toolCallId, params) => { @@ -983,6 +983,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'terminal-themes:list-profiles': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'terminalThemes', requires: 'terminal', denied: [], handler: () => listTerminalThemeProfiles(), @@ -990,6 +991,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'terminal-themes:select-profile': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'terminalThemes', requires: 'terminal', needsUserActivation: true, denied: null, @@ -1477,6 +1479,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:list-profiles': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', requires: 'browser', denied: [], handler: () => listChromeImportProfiles(), @@ -1484,6 +1487,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:cookies': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', requires: 'browser', needsUserActivation: true, denied: { cookiesImported: 0, cookiesSkipped: 0, error: 'unknown' }, @@ -1500,6 +1504,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:all': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', requires: 'browser', needsUserActivation: true, denied: { @@ -1664,6 +1669,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:sites': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', requiresAccountData: true, deviationReason: 'a read of already-imported data; settings lists these hosts to show what an import brought over, which is what you look at while deciding whether to enable the browser', @@ -1686,6 +1692,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:copy': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'passwordCopy', deviationReason: 'OS-authenticated password copying does not require an active browser surface', requiresAccountData: true, @@ -1716,6 +1723,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:import': { kind: 'invoke', gate: 'app-origin', + platformFeature: 'browserImport', deviationReason: "unlike its siblings this WRITES new credentials by driving the embedded browser's import path, so it needs the surface the rest of the family deliberately does without", requires: 'browser', @@ -2156,8 +2164,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { const features = desktopFeatures(deps.platform ?? process.platform) for (const [channel, spec] of Object.entries(channels)) { - const platformFeature = platformFeatureFor(channel) - if (platformFeature && !features[platformFeature]) continue + if (spec.platformFeature && !features[spec.platformFeature]) continue if (spec.kind === 'invoke') { ipcMain.handle(channel, async (event, ...args) => { if ( From 2d226359f4932fed3a41a414e2eca74997a989eb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:20:50 -0700 Subject: [PATCH 46/67] refactor(desktop): drop copyCredential's repeat of the passwordCopy gate Its only caller is the browser-credentials:copy channel, which is never registered where passwordCopy is off. --- apps/desktop/src/main/browser-credentials/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/browser-credentials/index.ts b/apps/desktop/src/main/browser-credentials/index.ts index c2151f1a54c..bde2618e1ae 100644 --- a/apps/desktop/src/main/browser-credentials/index.ts +++ b/apps/desktop/src/main/browser-credentials/index.ts @@ -8,7 +8,6 @@ import { FillCoordinator, type FillCoordinatorDeps } from '@/main/browser-creden import { authorizeForSecret, revokeSecretAuthorization } from '@/main/browser-credentials/os-auth' import { CredentialVault } from '@/main/browser-credentials/vault' import { clearSites } from '@/main/browser-sites' -import { desktopFeatures } from '@/shared/platform-features' /** How long a copied password may sit on the clipboard before Sim clears it. */ const CLIPBOARD_CLEAR_MS = 30_000 @@ -105,10 +104,10 @@ export async function revealCredential(id: string): Promise { * * The clipboard is only cleared if it still holds this password — overwriting * whatever the user copied in the meantime would be its own small betrayal. - * Refused where clearing cannot reach every copy (`DesktopFeatures.passwordCopy`). + * Where clearing cannot reach every copy (`DesktopFeatures.passwordCopy`), its IPC channel is + * never registered. */ export async function copyCredential(id: string): Promise { - if (!desktopFeatures(process.platform).passwordCopy) return false const authorized = await authorizeForSecret({ credentialId: id, operation: 'copy', From 4a97d8d0481156e6fa132f45e72c7ac53b0e87ea Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:21:15 -0700 Subject: [PATCH 47/67] refactor(desktop): restore a minimized window inside bringWindowToFront Every caller now gets the restore, so showMainWindow drops its own, and the unused platform parameter goes. --- apps/desktop/src/main/index.ts | 3 --- apps/desktop/src/main/shell-window.ts | 22 +++++++++------------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 49419a0b05f..a95ded269c3 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -310,9 +310,6 @@ function main(): void { app.focus({ steal: true }) return } - if (win.isMinimized()) { - win.restore() - } bringWindowToFront(win) } diff --git a/apps/desktop/src/main/shell-window.ts b/apps/desktop/src/main/shell-window.ts index 34630ea2892..7f7ebee545d 100644 --- a/apps/desktop/src/main/shell-window.ts +++ b/apps/desktop/src/main/shell-window.ts @@ -37,25 +37,21 @@ export function attachShellWindowSizing( } /** - * Raises a window above whatever app currently has the foreground — the user - * just finished something in the browser or relaunched Sim and expects it in - * front. macOS activates the app (`steal` is macOS-only). Windows ignores - * focus requests from a background process and only flashes the taskbar - * button, so a momentary always-on-top lift puts the window in front first. + * Raises a window above whatever app currently has the foreground, restoring it if minimized — + * the user just finished something in the browser or relaunched Sim and expects it in front. + * macOS activates the app (`steal` is macOS-only). Windows ignores focus requests from a + * background process and only flashes the taskbar button, so a momentary always-on-top lift puts + * the window in front first. */ -export function bringWindowToFront( - win: BrowserWindow, - platform: NodeJS.Platform = process.platform -): void { - if (platform === 'win32') { - if (win.isMinimized()) win.restore() - win.show() +export function bringWindowToFront(win: BrowserWindow): void { + if (win.isMinimized()) win.restore() + win.show() + if (process.platform === 'win32') { win.setAlwaysOnTop(true) win.focus() win.setAlwaysOnTop(false) return } - win.show() win.focus() app.focus({ steal: true }) } From 2d95608511c9254707412866a9a923dca8dc2b62 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:21:24 -0700 Subject: [PATCH 48/67] refactor(desktop): drop stagedFileName's unused platform parameter --- apps/desktop/src/main/browser-agent/file-transfer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/file-transfer.ts b/apps/desktop/src/main/browser-agent/file-transfer.ts index 7bfb1fb27ff..009035bdfc6 100644 --- a/apps/desktop/src/main/browser-agent/file-transfer.ts +++ b/apps/desktop/src/main/browser-agent/file-transfer.ts @@ -41,12 +41,12 @@ function directoryName(value: string): string { } /** A file name that cannot leave the staging directory, and that Windows stores as spelled. */ -function stagedFileName(name: string, platform: NodeJS.Platform = process.platform): string { +function stagedFileName(name: string): string { const base = basename(name) .replace(/[\x00-\x1f\x7f]/g, '') .trim() .slice(0, 200) - const safe = platform === 'win32' ? toWin32SafeFileName(base) : base + const safe = process.platform === 'win32' ? toWin32SafeFileName(base) : base return safe && safe !== '.' && safe !== '..' ? safe : 'upload' } From ae793eb2d33e84b8eb038c1ee687a5f3f45065ba Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:21:33 -0700 Subject: [PATCH 49/67] refactor(desktop): derive win32-path's global name pattern from the single one --- apps/desktop/src/main/win32-path.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/main/win32-path.ts b/apps/desktop/src/main/win32-path.ts index 1a0df9a6c99..4002771ce11 100644 --- a/apps/desktop/src/main/win32-path.ts +++ b/apps/desktop/src/main/win32-path.ts @@ -8,7 +8,7 @@ const RESERVED_DEVICE_STEM = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³]| /** `:` opens an alternate data stream; the rest are invalid in any NTFS name. */ const INVALID_NAME_CHARACTERS = /[<>:"/\\|?*\x00-\x1f]/ -const INVALID_NAME_CHARACTERS_GLOBAL = /[<>:"/\\|?*\x00-\x1f]/g +const INVALID_NAME_CHARACTERS_GLOBAL = new RegExp(INVALID_NAME_CHARACTERS.source, 'g') function isReservedDeviceName(name: string): boolean { return RESERVED_DEVICE_STEM.test(name.split('.')[0].trimEnd()) From 4a046939e752ee371485765ee0cd96a2613a63ec Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:22:58 -0700 Subject: [PATCH 50/67] refactor(desktop): share file identity and entry kinds across the local-file backends NativeFileHandle gains statBigint, so local-files compares identities through the open handle instead of its own promisified fstat, and file-identity.ts holds the {dev, ino} type, its comparison, and the entry-kind mapping that win32-native-files, native-directory, and local-filesystem each had. --- apps/desktop/src/main/file-identity.ts | 29 ++++++++++++++ apps/desktop/src/main/local-files.ts | 10 ++--- apps/desktop/src/main/local-filesystem.ts | 41 +++++--------------- apps/desktop/src/main/native-directory.ts | 8 +++- apps/desktop/src/main/win32-native-files.ts | 43 ++++++--------------- 5 files changed, 59 insertions(+), 72 deletions(-) create mode 100644 apps/desktop/src/main/file-identity.ts diff --git a/apps/desktop/src/main/file-identity.ts b/apps/desktop/src/main/file-identity.ts new file mode 100644 index 00000000000..6b041c4290b --- /dev/null +++ b/apps/desktop/src/main/file-identity.ts @@ -0,0 +1,29 @@ +import type { LocalFilesystemEntryKind } from '@sim/desktop-bridge' + +/** + * A file's identity as `stat(path, { bigint: true })` reports it. Bigint because NTFS file IDs + * exceed 2^53, and rounded IDs could compare equal. + */ +export interface FileIdentity { + dev: bigint + ino: bigint +} + +/** What `fs.Stats` and `fs.Dirent` both say about a file's type. */ +export interface FileTypeInfo { + isFile(): boolean + isDirectory(): boolean + isSymbolicLink(): boolean +} + +export function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino +} + +/** The entry kind a listing reports for a stat or directory entry. */ +export function fileEntryKind(info: FileTypeInfo): LocalFilesystemEntryKind { + if (info.isFile()) return 'file' + if (info.isDirectory()) return 'directory' + if (info.isSymbolicLink()) return 'symlink' + return 'other' +} diff --git a/apps/desktop/src/main/local-files.ts b/apps/desktop/src/main/local-files.ts index 32f8466fc1a..b7423e4c048 100644 --- a/apps/desktop/src/main/local-files.ts +++ b/apps/desktop/src/main/local-files.ts @@ -1,7 +1,5 @@ -import { fstat } from 'node:fs' import { lstat, stat } from 'node:fs/promises' import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' -import { promisify } from 'node:util' import type { DesktopLocalFileEntry, DesktopLocalFileManifest, @@ -13,14 +11,13 @@ import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import { compareStrings } from '@sim/utils/string' import { PDFDocument } from 'pdf-lib' +import { sameFileIdentity } from '@/main/file-identity' import type { LocalFileAccess } from '@/main/local-filesystem' const CHUNK_BYTES = 8 * 1024 * 1024 const MAX_ENTRIES = 1000 const MAX_READ_BYTES = 64_000 -const statDescriptor = promisify(fstat) - export interface LocalFileAuthorization { toolName: string args: Record @@ -48,10 +45,9 @@ async function readApprovedDirectory(path: string, access: LocalFileAccess) { try { const listing = await handle.readDirectory(MAX_ENTRIES) const canonical = await access.resolve(path) - // bigint: NTFS file IDs exceed 2^53, and rounded IDs could compare equal. const current = await lstat(canonical, { bigint: true }) - const opened = await statDescriptor(handle.fd, { bigint: true }) - if (canonical !== path || current.dev !== opened.dev || current.ino !== opened.ino) + const opened = await handle.statBigint() + if (canonical !== path || !sameFileIdentity(current, opened)) throw new Error('The local directory changed while it was being read. Try again.') listing.entries.sort((left, right) => compareStrings(left.name, right.name)) return listing diff --git a/apps/desktop/src/main/local-filesystem.ts b/apps/desktop/src/main/local-filesystem.ts index 4577ae3b404..f69879433b0 100644 --- a/apps/desktop/src/main/local-filesystem.ts +++ b/apps/desktop/src/main/local-filesystem.ts @@ -4,7 +4,6 @@ import { basename, isAbsolute, relative, resolve, sep } from 'node:path' import type { LocalFilesystemData, LocalFilesystemEntry, - LocalFilesystemEntryKind, LocalFilesystemGrepMatch, LocalFilesystemMount, LocalFilesystemResponse, @@ -33,6 +32,7 @@ import { waitForAccountDataMutations, } from '@/main/account-data-generation' import { showShellDialog } from '@/main/dialogs' +import { type FileIdentity, fileEntryKind, sameFileIdentity } from '@/main/file-identity' import type { LocalFilesystemGrantStore, PersistedLocalFilesystemGrant, @@ -97,10 +97,8 @@ const GRANT_SCOPED_OPERATIONS: ReadonlySet = new Set([ 'stat', ]) -interface GrantedMount extends LocalFilesystemMount { +interface GrantedMount extends LocalFilesystemMount, FileIdentity { rootPath: string - dev: bigint - ino: bigint bookmark?: string stopAccessing?: () => void } @@ -130,17 +128,6 @@ export interface LocalFilesystemToolAuthorization { args: Record } -function entryKind(entry: { - isFile(): boolean - isDirectory(): boolean - isSymbolicLink(): boolean -}): LocalFilesystemEntryKind { - if (entry.isFile()) return 'file' - if (entry.isDirectory()) return 'directory' - if (entry.isSymbolicLink()) return 'symlink' - return 'other' -} - function encodeUriPath(relativePath: string): string { return relativePath .split('/') @@ -636,7 +623,7 @@ export class LocalFilesystemService { async grantDirectory( selected: SelectedDirectory, generation: number, - expected?: { dev: bigint; ino: bigint } + expected?: FileIdentity ): Promise { const stopAccessing = selected.bookmark ? this.startAccessingBookmark(selected.bookmark) @@ -660,12 +647,7 @@ export class LocalFilesystemService { throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The selected item is not a directory.') } - if ( - expected && - (rootPath !== selected.path || - rootStat.dev !== expected.dev || - rootStat.ino !== expected.ino) - ) { + if (expected && (rootPath !== selected.path || !sameFileIdentity(rootStat, expected))) { throw new LocalFilesystemError( 'ACCESS_DENIED', 'The folder changed while awaiting permission. Request access again.' @@ -770,11 +752,9 @@ export class LocalFilesystemService { const current = this.mounts.get(mount.id) if (!current || current.rootPath !== mount.rootPath) throw mountNotFound() if ( - current.dev !== mount.dev || - current.ino !== mount.ino || + !sameFileIdentity(current, mount) || !root.isDirectory() || - root.dev !== mount.dev || - root.ino !== mount.ino + !sameFileIdentity(root, mount) ) { throw new LocalFilesystemError( 'ACCESS_DENIED', @@ -976,8 +956,7 @@ export class LocalFilesystemService { if ( verified.realPath !== resolved.realPath || !currentMetadata.isFile() || - metadata.dev !== currentMetadata.dev || - metadata.ino !== currentMetadata.ino + !sameFileIdentity(metadata, currentMetadata) ) { throw new LocalFilesystemError( 'ACCESS_DENIED', @@ -1120,7 +1099,7 @@ export class LocalFilesystemService { return { name: directoryEntry.name, uri: localUri(resolvedPath.mount.id, childRelativePath), - kind: entryKind(directoryEntry), + kind: fileEntryKind(directoryEntry), size: metadata.size, modifiedAt: metadata.mtime.toISOString(), } @@ -1185,7 +1164,7 @@ export class LocalFilesystemService { entries.push({ name: child.name, uri: localUri(resolvedPath.mount.id, mountRelativePath), - kind: entryKind(child), + kind: fileEntryKind(child), size: metadata.size, modifiedAt: metadata.mtime.toISOString(), }) @@ -1475,7 +1454,7 @@ export class LocalFilesystemService { return { name: basename(resolvedPath.lexicalPath) || resolvedPath.mount.name, uri, - kind: entryKind(metadata), + kind: fileEntryKind(metadata), size: metadata.size, modifiedAt: metadata.mtime.toISOString(), } diff --git a/apps/desktop/src/main/native-directory.ts b/apps/desktop/src/main/native-directory.ts index 7aa7a4ec5b4..46c402cd4f8 100644 --- a/apps/desktop/src/main/native-directory.ts +++ b/apps/desktop/src/main/native-directory.ts @@ -1,8 +1,9 @@ -import { fstat, read, type Stats } from 'node:fs' +import { type BigIntStats, fstat, read, type Stats } from 'node:fs' import { join } from 'node:path' import { promisify } from 'node:util' import type { DesktopLocalFileRead } from '@sim/desktop-bridge' import { app } from 'electron' +import type { FileIdentity } from '@/main/file-identity' import { openWin32NativeFile } from '@/main/win32-native-files' /** At most `limit` entries of one directory, and whether more were left unread. */ @@ -15,6 +16,8 @@ export interface NativeDirectoryListing { export interface NativeFileHandle { readonly fd: number stat: () => Promise + /** {@link stat} with bigint fields, for comparing identities: NTFS file IDs exceed 2^53. */ + statBigint: () => Promise read: ( buffer: Buffer, offset: number, @@ -60,7 +63,7 @@ function nativeBridge(): NativeDirectoryBridge { export async function openPosixNativeFile( root: string, relativePath: string, - identity: { dev: bigint; ino: bigint }, + identity: FileIdentity, directory: boolean ): Promise { let descriptor = await nativeBridge().openApproved( @@ -75,6 +78,7 @@ export async function openPosixNativeFile( return descriptor }, stat: () => statDescriptor(descriptor), + statBigint: () => statDescriptor(descriptor, { bigint: true }), read: (buffer, offset, length, position) => readDescriptor(descriptor, buffer, offset, length, position), readDirectory: (limit) => nativeBridge().readDirectory(descriptor, limit), diff --git a/apps/desktop/src/main/win32-native-files.ts b/apps/desktop/src/main/win32-native-files.ts index d2a175415c5..a2c6e20e477 100644 --- a/apps/desktop/src/main/win32-native-files.ts +++ b/apps/desktop/src/main/win32-native-files.ts @@ -1,6 +1,8 @@ import type { Dir, Dirent } from 'node:fs' import { type FileHandle, lstat, open, opendir, realpath } from 'node:fs/promises' import { join, relative, sep } from 'node:path' +import type { LocalFilesystemEntryKind } from '@sim/desktop-bridge' +import { type FileIdentity, fileEntryKind, sameFileIdentity } from '@/main/file-identity' import type { NativeDirectoryListing, NativeFileHandle } from '@/main/native-directory' import { isWithinRoot } from '@/main/path-containment' import { isWin32SafeSegment } from '@/main/win32-path' @@ -13,45 +15,21 @@ export interface Win32NativeFileSystem { const NODE_FILE_SYSTEM: Win32NativeFileSystem = { open: (path) => open(path, 'r'), opendir } -type EntryKind = NativeDirectoryListing['entries'][number]['kind'] - -interface Identity { - dev: bigint - ino: bigint -} - -interface KindInfo { - isFile(): boolean - isDirectory(): boolean - isSymbolicLink(): boolean -} - const OUTSIDE_GRANT = 'The path must stay within the approved folder.' -function sameIdentity(left: Identity, right: Identity): boolean { - return left.dev === right.dev && left.ino === right.ino -} - -function isRequestedKind(info: KindInfo, directory: boolean): boolean { +function isRequestedKind(info: { isFile(): boolean; isDirectory(): boolean }, directory: boolean) { return directory ? info.isDirectory() : info.isFile() } -function kindOf(info: KindInfo): EntryKind { - if (info.isFile()) return 'file' - if (info.isDirectory()) return 'directory' - if (info.isSymbolicLink()) return 'symlink' - return 'other' -} - /** * Windows enumeration marks every reparse point as a link, OneDrive placeholders * included, though those are ordinary files. `lstat` reports only symlinks and * junctions as links, so it settles the kind without following anything. */ -async function entryKind(directory: string, entry: Dirent): Promise { - if (!entry.isSymbolicLink()) return kindOf(entry) +async function entryKind(directory: string, entry: Dirent): Promise { + if (!entry.isSymbolicLink()) return fileEntryKind(entry) try { - return kindOf(await lstat(join(directory, entry.name))) + return fileEntryKind(await lstat(join(directory, entry.name))) } catch { return 'other' } @@ -83,7 +61,7 @@ async function canonicalBeneath(rootReal: string, path: string): Promise export async function openWin32NativeFile( root: string, relativePath: string, - identity: Identity, + identity: FileIdentity, directory: boolean, fs: Win32NativeFileSystem = NODE_FILE_SYSTEM ): Promise { @@ -91,7 +69,7 @@ export async function openWin32NativeFile( if (!segments.every(isWin32SafeSegment)) throw new Error(OUTSIDE_GRANT) const assertRootCurrent = async () => { const info = await lstat(root, { bigint: true }) - if (!info.isDirectory() || !sameIdentity(info, identity)) + if (!info.isDirectory() || !sameFileIdentity(info, identity)) throw new Error('The approved folder changed. Request access again.') } await assertRootCurrent() @@ -104,7 +82,7 @@ export async function openWin32NativeFile( const handle = await fs.open(path) try { const opened = await handle.stat({ bigint: true }) - if (!sameIdentity(opened, checked) || !isRequestedKind(opened, directory)) + if (!sameFileIdentity(opened, checked) || !isRequestedKind(opened, directory)) throw new Error('The local path changed while it was being opened. Try again.') await canonicalBeneath(rootReal, path) await assertRootCurrent() @@ -118,6 +96,7 @@ export async function openWin32NativeFile( return handle.fd }, stat: () => handle.stat(), + statBigint: () => handle.stat({ bigint: true }), read: (buffer, offset, length, position) => handle.read(buffer, offset, length, position), readDirectory: async (limit) => { const entries: NativeDirectoryListing['entries'] = [] @@ -130,7 +109,7 @@ export async function openWin32NativeFile( entries.push({ name: entry.name, kind: await entryKind(path, entry) }) } const listed = await lstat(path, { bigint: true }) - if (!sameIdentity(listed, checked)) + if (!sameFileIdentity(listed, checked)) throw new Error('The local directory changed while it was being read. Try again.') await canonicalBeneath(rootReal, path) return { entries, truncated } From 19081e678cf889b4722a1ebef179de2cd91237ab Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:23:19 -0700 Subject: [PATCH 51/67] perf(desktop): classify a Windows listing's reparse points sixteen at a time Each reparse point (every OneDrive placeholder) needs its own lstat, and they were awaited one by one. The scan still stops at the cap, results keep the directory's order, and the identity re-checks run after the batches. --- apps/desktop/src/main/win32-native-files.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/win32-native-files.ts b/apps/desktop/src/main/win32-native-files.ts index a2c6e20e477..96e3ef7bce4 100644 --- a/apps/desktop/src/main/win32-native-files.ts +++ b/apps/desktop/src/main/win32-native-files.ts @@ -15,6 +15,12 @@ export interface Win32NativeFileSystem { const NODE_FILE_SYSTEM: Win32NativeFileSystem = { open: (path) => open(path, 'r'), opendir } +/** + * Reparse points classified at once while listing. A OneDrive folder is all placeholders, each of + * which needs its own `lstat`; awaiting them one by one makes a large folder slow to list. + */ +const ENTRY_KIND_CONCURRENCY = 16 + const OUTSIDE_GRANT = 'The path must stay within the approved folder.' function isRequestedKind(info: { isFile(): boolean; isDirectory(): boolean }, directory: boolean) { @@ -99,14 +105,20 @@ export async function openWin32NativeFile( statBigint: () => handle.stat({ bigint: true }), read: (buffer, offset, length, position) => handle.read(buffer, offset, length, position), readDirectory: async (limit) => { - const entries: NativeDirectoryListing['entries'] = [] + const scanned: Dirent[] = [] let truncated = false for await (const entry of await fs.opendir(path)) { - if (entries.length === limit) { + if (scanned.length === limit) { truncated = true break } - entries.push({ name: entry.name, kind: await entryKind(path, entry) }) + scanned.push(entry) + } + const entries: NativeDirectoryListing['entries'] = [] + for (let start = 0; start < scanned.length; start += ENTRY_KIND_CONCURRENCY) { + const batch = scanned.slice(start, start + ENTRY_KIND_CONCURRENCY) + const kinds = await Promise.all(batch.map((entry) => entryKind(path, entry))) + batch.forEach((entry, index) => entries.push({ name: entry.name, kind: kinds[index] })) } const listed = await lstat(path, { bigint: true }) if (!sameFileIdentity(listed, checked)) From 39d86772bc2e5870edcc61125c5b00622257a80e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:24:18 -0700 Subject: [PATCH 52/67] refactor(desktop): name the desktop platforms once in desktop-bridge DESKTOP_PLATFORMS is the tuple DesktopPlatform derives from; the chat request schema validates against it and imports the type instead of re-deriving it, and the desktop capabilities read the platform through getDesktopPlatform. --- apps/sim/lib/desktop/index.ts | 3 ++- apps/sim/lib/mothership/chat/post.ts | 4 ++-- packages/desktop-bridge/src/index.ts | 5 ++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/desktop/index.ts b/apps/sim/lib/desktop/index.ts index 2ebc7874b75..f4d0bb25edb 100644 --- a/apps/sim/lib/desktop/index.ts +++ b/apps/sim/lib/desktop/index.ts @@ -252,6 +252,7 @@ export async function getDesktopChatCapabilities( .then((state) => state.sessions) .catch(() => []) : [] + const platform = getDesktopPlatform() // Shells without a background executor (every build before it ships) leave the turn unbound. const executorDevice = bridge?.desktopExecutor ? await bridge.desktopExecutor.getDevice().catch(() => null) @@ -266,7 +267,7 @@ export async function getDesktopChatCapabilities( ...(browser ? { browser: true as const } : {}), ...(terminal ? { terminal: true as const } : {}), ...(computerUse ? { computerUse: true as const } : {}), - ...(bridge ? { platform: bridge.platform ?? 'darwin' } : {}), + ...(platform ? { platform } : {}), ...(terminals.length > 0 ? { terminals } : {}), ...(browserSessions.length > 0 ? { browserSessions } : {}), ...(executorDevice diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index 336bdddd081..60cf45022a1 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -1,5 +1,6 @@ import { context as otelContextApi } from '@opentelemetry/api' import type { SessionPrincipal } from '@sim/auth/principal' +import { DESKTOP_PLATFORMS, type DesktopPlatform } from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -165,7 +166,7 @@ const ChatMessageSchema = z terminal: z.boolean().optional(), computerUse: z.boolean().optional(), /** Optional: shells that predate it are all macOS. */ - platform: z.enum(['darwin', 'win32', 'linux']).optional(), + platform: z.enum(DESKTOP_PLATFORMS).optional(), terminals: z .array( z.object({ @@ -246,7 +247,6 @@ function backgroundExecutorDeviceId(body: UnifiedChatRequest): string | undefine type BrowserSessions = NonNullable['browserSessions'] type Terminals = NonNullable['terminals'] -type DesktopPlatform = NonNullable['platform'] type UnifiedChatBranch = | { kind: 'workflow' diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index 1b97f420a17..1e4f01cd2bd 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -1177,8 +1177,11 @@ interface SimDesktopExecutorApi { getDevice(): Promise } +/** The operating systems a desktop shell runs on. */ +export const DESKTOP_PLATFORMS = ['darwin', 'win32', 'linux'] as const + /** The operating system a desktop shell runs on. */ -export type DesktopPlatform = 'darwin' | 'win32' | 'linux' +export type DesktopPlatform = (typeof DESKTOP_PLATFORMS)[number] export interface SimDesktopApi { computerUse?: SimDesktopComputerUseApi From 7d3c850a8f142271c1918bf639ddb359bec98dac Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:25:12 -0700 Subject: [PATCH 53/67] refactor(desktop): name each platform's update artifacts once in desktop-bridge DESKTOP_UPDATE_ARTIFACTS holds the manifest and artifact names per platform; the shell's updater accepts only those and the deployment's feed serves them, so the two can no longer disagree on a release's files. --- apps/desktop/src/main/updater.ts | 22 ++++++------ apps/sim/lib/desktop/update-feed.ts | 36 +++++-------------- packages/desktop-bridge/src/index.ts | 1 + .../desktop-bridge/src/update-artifacts.ts | 26 ++++++++++++++ 4 files changed, 45 insertions(+), 40 deletions(-) create mode 100644 packages/desktop-bridge/src/update-artifacts.ts diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index df2e8ec7a9b..14482603416 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -1,7 +1,11 @@ import { execFile } from 'node:child_process' import { existsSync, unlinkSync } from 'node:fs' import { join } from 'node:path' -import type { DesktopUpdateState } from '@sim/desktop-bridge' +import { + DESKTOP_UPDATE_ARTIFACTS, + type DesktopUpdateArtifacts, + type DesktopUpdateState, +} from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' import { toStringOrNull } from '@sim/utils/coerce' import { getErrorMessage } from '@sim/utils/errors' @@ -94,11 +98,7 @@ const RELEASE_REPOSITORIES: Record = { * electron-updater requests, the artifacts a release may point it at, and how a * downloaded update becomes installable. */ -interface UpdatePlatform { - /** The manifest electron-updater requests from the feed for this platform. */ - manifest: 'latest-mac.yml' | 'latest.yml' - /** Every artifact a manifest for `version` may reference, most human-installable first. */ - assetNames(version: string): readonly string[] +interface UpdatePlatform extends DesktopUpdateArtifacts { /** * macOS hands the downloaded zip to Squirrel.Mac, which stages it and only * then reports `update-downloaded` on Electron's native updater. NSIS has no @@ -109,14 +109,12 @@ interface UpdatePlatform { } const MAC_UPDATE_PLATFORM: UpdatePlatform = { - manifest: 'latest-mac.yml', - assetNames: (version) => [`Sim-${version}-universal.dmg`, `Sim-${version}-universal.zip`], + ...DESKTOP_UPDATE_ARTIFACTS.mac, nativeStaging: true, } const WINDOWS_UPDATE_PLATFORM: UpdatePlatform = { - manifest: 'latest.yml', - assetNames: (version) => [`Sim-${version}-x64.exe`], + ...DESKTOP_UPDATE_ARTIFACTS.windows, nativeStaging: false, } @@ -140,7 +138,7 @@ function isReleaseAssetUrl( const repository = RELEASE_REPOSITORIES[channel] const releasePath = `/${repository}/releases/download/v${normalizedVersion}/` const assetName = decodeURIComponent(url.pathname.slice(releasePath.length)) - const expectedAssetNames = new Set(platform.assetNames(normalizedVersion)) + const expectedAssetNames = new Set(platform.artifactNames(normalizedVersion)) return ( url.origin === RELEASE_ASSET_ORIGIN && url.username === '' && @@ -985,7 +983,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { ) const nextDownloadUrl = platform - .assetNames(version.replace(/^v/, '')) + .artifactNames(version.replace(/^v/, '')) .map((name) => urls.find((url) => url.endsWith(`/${encodeURIComponent(name)}`))) .find((url) => url !== undefined) ?? urls[0] ?? diff --git a/apps/sim/lib/desktop/update-feed.ts b/apps/sim/lib/desktop/update-feed.ts index 51a703f57ce..9fe07784f1c 100644 --- a/apps/sim/lib/desktop/update-feed.ts +++ b/apps/sim/lib/desktop/update-feed.ts @@ -31,6 +31,7 @@ * release, so Windows shells keep the newest release that has one. */ +import { DESKTOP_UPDATE_ARTIFACTS, type DesktopUpdateArtifacts } from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { DesktopUpdatePlatform } from '@/lib/api/contracts/desktop-update' @@ -67,16 +68,7 @@ export function channelOfVersion(version: string): DesktopUpdateChannel { return 'latest' } -interface DesktopPlatformFeed { - /** - * The manifest asset a platform's build uploads. electron-builder's GitHub - * provider names it `latest-mac.yml` / `latest.yml` regardless of the - * version's prerelease tag (channels are a generic-provider concept); which - * channel a release belongs to is carried entirely by its tag. - */ - manifestAssetName: string - /** Every artifact the manifest may reference, most human-installable first. */ - artifactNames(version: string): readonly string[] +interface DesktopPlatformFeed extends DesktopUpdateArtifacts { /** * Whether every desktop release must carry this platform. A missing manifest * then marks a broken release; otherwise the release just lacks the platform. @@ -85,21 +77,12 @@ interface DesktopPlatformFeed { } const PLATFORM_FEEDS: Record = { - mac: { - manifestAssetName: 'latest-mac.yml', - artifactNames: (version) => [`Sim-${version}-universal.dmg`, `Sim-${version}-universal.zip`], - inEveryRelease: true, - }, - windows: { - manifestAssetName: 'latest.yml', - // x64 only: a manifest naming any other architecture is not one this feed ships. - artifactNames: (version) => [`Sim-${version}-x64.exe`], - inEveryRelease: false, - }, + mac: { ...DESKTOP_UPDATE_ARTIFACTS.mac, inEveryRelease: true }, + windows: { ...DESKTOP_UPDATE_ARTIFACTS.windows, inEveryRelease: false }, } -export const MANIFEST_ASSET_NAME = PLATFORM_FEEDS.mac.manifestAssetName -export const WINDOWS_MANIFEST_ASSET_NAME = PLATFORM_FEEDS.windows.manifestAssetName +export const MANIFEST_ASSET_NAME = PLATFORM_FEEDS.mac.manifest +export const WINDOWS_MANIFEST_ASSET_NAME = PLATFORM_FEEDS.windows.manifest export const MAX_DESKTOP_UPDATE_MANIFEST_BYTES = 256 * 1024 /** The subset of the GitHub releases API the feed needs. */ @@ -125,10 +108,7 @@ function releasesForChannel( const candidates: Array<{ release: DesktopReleaseCandidate; version: string }> = [] for (const release of releases) { if (release.draft) continue - if ( - !feed.inEveryRelease && - !release.assets?.some((asset) => asset.name === feed.manifestAssetName) - ) { + if (!feed.inEveryRelease && !release.assets?.some((asset) => asset.name === feed.manifest)) { continue } const version = release.tag_name.replace(/^v/, '') @@ -222,7 +202,7 @@ async function resolveReleaseAssets( platform: DesktopUpdatePlatform, fetchManifest: (url: string) => Promise ): Promise { - const manifestAssetName = PLATFORM_FEEDS[platform].manifestAssetName + const manifestAssetName = PLATFORM_FEEDS[platform].manifest const manifestAsset = release.assets?.find((asset) => asset.name === manifestAssetName) const installer = selectInstallerAsset(release, repository, platform) if (!manifestAsset || !installer) return null diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index 1e4f01cd2bd..8ae278e609b 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -1256,3 +1256,4 @@ export { type DesktopTitleBarMode, observeDesktopTitleBar, } from './title-bar' +export { DESKTOP_UPDATE_ARTIFACTS, type DesktopUpdateArtifacts } from './update-artifacts' diff --git a/packages/desktop-bridge/src/update-artifacts.ts b/packages/desktop-bridge/src/update-artifacts.ts new file mode 100644 index 00000000000..5d3f3898609 --- /dev/null +++ b/packages/desktop-bridge/src/update-artifacts.ts @@ -0,0 +1,26 @@ +/** + * The release artifacts each self-updating desktop platform publishes, shared by the shell's + * updater, which follows a manifest only to these names, and the deployment's update feed, which + * serves them. Both must agree, so they are named once here. + */ +export interface DesktopUpdateArtifacts { + /** + * The electron-updater manifest the build uploads and the shell requests. electron-builder + * names it the same for every channel; a release's channel is carried by its tag. + */ + manifest: 'latest-mac.yml' | 'latest.yml' + /** Every artifact a manifest for `version` may reference, most human-installable first. */ + artifactNames(version: string): readonly string[] +} + +export const DESKTOP_UPDATE_ARTIFACTS: Record<'mac' | 'windows', DesktopUpdateArtifacts> = { + mac: { + manifest: 'latest-mac.yml', + artifactNames: (version) => [`Sim-${version}-universal.dmg`, `Sim-${version}-universal.zip`], + }, + windows: { + manifest: 'latest.yml', + // x64 only: a manifest naming any other architecture is not one Sim ships. + artifactNames: (version) => [`Sim-${version}-x64.exe`], + }, +} From 7efa552ebbbee9c5d2ec9460063d8063091f76b2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:26:59 -0700 Subject: [PATCH 54/67] refactor(desktop): read every channel's identity from one shared table CHANNEL_IDENTITIES (src/shared/channel-identity.ts) holds each channel's name, app ID, and icons. The packager's channels.ts adds only the origin, install-local reuses it, and the running app reads its name, AppUserModelID, and icons from the same entries, so the runtime identity can no longer drift from the installer's. --- apps/desktop/scripts/channels.ts | 74 +++--------------- .../desktop/scripts/generate-windows-icons.ts | 3 +- apps/desktop/scripts/install-local.ts | 24 +++--- apps/desktop/src/main/config.ts | 46 +---------- apps/desktop/src/main/index.ts | 17 ++-- apps/desktop/src/main/tray.ts | 12 +-- apps/desktop/src/shared/channel-identity.ts | 78 +++++++++++++++++++ 7 files changed, 117 insertions(+), 137 deletions(-) create mode 100644 apps/desktop/src/shared/channel-identity.ts diff --git a/apps/desktop/scripts/channels.ts b/apps/desktop/scripts/channels.ts index 074ef40578e..25fa0cfe7d6 100644 --- a/apps/desktop/scripts/channels.ts +++ b/apps/desktop/scripts/channels.ts @@ -1,15 +1,12 @@ /** - * Build-time channel identity, derived from the origin a build is baked to. + * Build-time channel identity, derived from the origin a build is baked to. The identity itself + * is `CHANNEL_IDENTITIES` (src/shared/channel-identity.ts), which the running app claims too. * - * 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. + * {@link identityForOrigin} must stay in sync with channelForOrigin in src/main/config.ts: when + * the two disagree, 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. */ +import { CHANNEL_IDENTITIES, type DesktopChannelIdentity } from '@/shared/channel-identity' /** Canonical no-redirect origins per environment. */ export const PROD_ORIGIN = 'https://www.sim.ai' @@ -17,63 +14,16 @@ 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 +/** A channel's identity, with the origin its builds are baked to. */ +export interface ChannelIdentity extends DesktopChannelIdentity { /** Baked default origin + persisted settings origin. */ origin: string - /** Native Icon Composer source copied into the packager's generated path. */ - icon: string - /** Multi-size Windows icon copied into the packager's generated path. */ - windowsIcon: string - /** 512px source the Windows icon is generated from (`scripts/generate-windows-icons.ts`). */ - dockIcon: 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, - icon: 'build/icon.icon', - windowsIcon: 'build/icon.ico', - dockIcon: 'static/dock-icon.png', - slug: 'sim', -} -export const STAGING: ChannelIdentity = { - name: 'Sim Staging', - appId: 'ai.sim.desktop.staging', - origin: STAGING_ORIGIN, - icon: 'build/icon-staging.icon', - windowsIcon: 'build/icon-staging.ico', - dockIcon: 'static/dock-icon-staging.png', - slug: 'sim-staging', -} -export const DEV: ChannelIdentity = { - name: 'Sim Dev', - appId: 'ai.sim.desktop.dev', - origin: DEV_ORIGIN, - icon: 'build/icon-dev.icon', - windowsIcon: 'build/icon-dev.ico', - dockIcon: 'static/dock-icon-dev.png', - slug: 'sim-dev', -} -export const LOCAL: ChannelIdentity = { - name: 'Sim Local', - appId: 'ai.sim.desktop.local', - origin: LOCAL_ORIGIN, - icon: 'build/icon-local.icon', - windowsIcon: 'build/icon-local.ico', - dockIcon: 'static/dock-icon-local.png', - slug: 'sim-local', -} +export const PROD: ChannelIdentity = { ...CHANNEL_IDENTITIES.prod, origin: PROD_ORIGIN } +export const STAGING: ChannelIdentity = { ...CHANNEL_IDENTITIES.staging, origin: STAGING_ORIGIN } +export const DEV: ChannelIdentity = { ...CHANNEL_IDENTITIES.dev, origin: DEV_ORIGIN } +export const LOCAL: ChannelIdentity = { ...CHANNEL_IDENTITIES.local, origin: LOCAL_ORIGIN } /** Every channel, in the order a full run builds them. */ export const ALL_CHANNELS: readonly ChannelIdentity[] = [PROD, STAGING, DEV, LOCAL] diff --git a/apps/desktop/scripts/generate-windows-icons.ts b/apps/desktop/scripts/generate-windows-icons.ts index c7704ba3a71..d726759a205 100644 --- a/apps/desktop/scripts/generate-windows-icons.ts +++ b/apps/desktop/scripts/generate-windows-icons.ts @@ -17,6 +17,7 @@ * icon grid instead of rendering visibly smaller than neighboring apps. */ import { writeFileSync } from 'node:fs' +import { join } from 'node:path' import { createLogger } from '@sim/logger' import sharp from 'sharp' import { ALL_CHANNELS } from './channels' @@ -111,7 +112,7 @@ function encodeIco(frames: readonly IconFrame[]): Buffer { async function run(): Promise { for (const channel of ALL_CHANNELS) { - const trimmed = await sharp(channel.dockIcon).trim().png().toBuffer() + const trimmed = await sharp(join('static', channel.dockIcon)).trim().png().toBuffer() const frames: IconFrame[] = [] for (const size of FRAME_SIZES) { frames.push(await renderFrame(trimmed, size)) diff --git a/apps/desktop/scripts/install-local.ts b/apps/desktop/scripts/install-local.ts index 3620ec8ce4e..c743a45d9bd 100644 --- a/apps/desktop/scripts/install-local.ts +++ b/apps/desktop/scripts/install-local.ts @@ -22,6 +22,8 @@ import { execFileSync, spawnSync } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' +import { CHANNEL_IDENTITIES, type DesktopChannelIdentity } from '@/shared/channel-identity' +import { DEV, LOCAL, STAGING } from './channels' if (process.platform !== 'darwin') { console.error( @@ -30,31 +32,23 @@ if (process.platform !== 'darwin') { process.exit(1) } -interface ChannelIdentity { - /** Display + bundle name; also the userData directory name. */ - name: string - appId: string +interface InstallIdentity extends DesktopChannelIdentity { /** Baked default origin + persisted settings origin. Unset = leave as-is. */ origin?: string } -/** Must stay in sync with APP_NAME_FOR_CHANNEL in src/main/config.ts. */ -const CHANNEL_FLAGS: Record = { - '--local': { name: 'Sim Local', appId: 'ai.sim.desktop.local', origin: 'http://localhost:3000' }, - '--dev': { name: 'Sim Dev', appId: 'ai.sim.desktop.dev', origin: 'https://www.dev.sim.ai' }, - '--staging': { - name: 'Sim Staging', - appId: 'ai.sim.desktop.staging', - origin: 'https://www.staging.sim.ai', - }, +const CHANNEL_FLAGS: Record = { + '--local': LOCAL, + '--dev': DEV, + '--staging': STAGING, // Origin left unset, exactly as official prod builds do, so this install // resolves the same DEFAULT_ORIGIN (config.ts) and therefore the same cookie // partition as a real one. Naming an origin here re-creates the drift that // pinned prod to the apex while the server serves www. - '--prod': { name: 'Sim', appId: 'ai.sim.desktop' }, + '--prod': CHANNEL_IDENTITIES.prod, } -const DEFAULT_IDENTITY: ChannelIdentity = { name: 'Sim', appId: 'ai.sim.desktop' } +const DEFAULT_IDENTITY: InstallIdentity = CHANNEL_IDENTITIES.prod const channelFlags = process.argv.filter((arg) => arg in CHANNEL_FLAGS) if (channelFlags.length > 1) { diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 853ffe3ea33..3cd50f9ce22 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -3,6 +3,7 @@ import type { DesktopZoomPercent, TerminalAppearanceTheme } from '@sim/desktop-b import { createLogger } from '@sim/logger' import { isLoopbackHostname } from '@sim/security/ssrf' import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file' +import type { DesktopChannel } from '@/shared/channel-identity' /** settings.json is meant to be readable when a user opens it. */ const SETTINGS_INDENT = 2 @@ -42,15 +43,7 @@ export const DEFAULT_ORIGIN = isValidBakedOrigin(process.env.SIM_DESKTOP_DEFAULT ? process.env.SIM_DESKTOP_DEFAULT_ORIGIN : 'https://www.sim.ai' -/** - * The environment a build is keyed to, derived from its baked default origin. - * Channel drives the app's identity — its name and therefore its userData - * directory and single-instance lock — so one developer can keep a prod, - * staging, dev, and localhost install side by side, each with its own - * settings, sessions, and update feed. - */ -export type DesktopChannel = 'prod' | 'staging' | 'dev' | 'local' - +/** The channel a build baked to `origin` belongs to; unrecognized hosts are production. */ export function channelForOrigin(origin: string): DesktopChannel { try { const host = new URL(origin).hostname.toLowerCase() @@ -63,41 +56,6 @@ export function channelForOrigin(origin: string): DesktopChannel { } } -/** - * Per-channel app names. Prod keeps the plain name every existing install - * already has (its userData must not move); the others are distinct apps. - */ -export const APP_NAME_FOR_CHANNEL: Record = { - prod: 'Sim', - staging: 'Sim Staging', - dev: 'Sim Dev', - local: 'Sim Local', -} - -/** - * Per-channel installer app IDs. On Windows this is also the AppUserModelID: - * the running process must claim the same ID the installer stamped on its - * Start-menu shortcut, or toasts lose their attribution and taskbar pins - * group under a separate icon. - */ -export const APP_ID_FOR_CHANNEL: Record = { - prod: 'ai.sim.desktop', - staging: 'ai.sim.desktop.staging', - dev: 'ai.sim.desktop.dev', - local: 'ai.sim.desktop.local', -} - -/** - * Per-channel app icons under `static/`, for runs with no packaged bundle or - * executable icon to inherit, and for the Windows tray. - */ -export const APP_ICON_FOR_CHANNEL: Record = { - prod: 'dock-icon.png', - staging: 'dock-icon-staging.png', - dev: 'dock-icon-dev.png', - local: 'dock-icon-local.png', -} - export interface WindowBounds { x?: number y?: number diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a95ded269c3..1a489ba3c09 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -57,9 +57,6 @@ import { attachClientInfo } from '@/main/client-info' import { NativeComputerUseClient } from '@/main/computer-use/native-client' import { ComputerUseService } from '@/main/computer-use/service' import { - APP_ICON_FOR_CHANNEL, - APP_ID_FOR_CHANNEL, - APP_NAME_FOR_CHANNEL, channelForOrigin, createConfigStore, DEFAULT_ORIGIN, @@ -117,6 +114,7 @@ import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/m import { installBrowserUserAgent } from '@/main/user-agent' import { createMainWindow, handleSecondInstance, setupPermissionHandlers } from '@/main/window' import { attachWindowOpenPolicy, isPopupContents } from '@/main/windows' +import { CHANNEL_IDENTITIES } from '@/shared/channel-identity' import { desktopFeatures } from '@/shared/platform-features' const logger = createLogger('DesktopMain') @@ -259,7 +257,12 @@ function main(): void { // Packaged Windows builds inherit the executable's icon; a dev run would show Electron's. const unpackagedWindowIcon = process.platform === 'win32' && !app.isPackaged - ? join(__dirname, '..', 'static', APP_ICON_FOR_CHANNEL[channelForOrigin(processOrigin)]) + ? join( + __dirname, + '..', + 'static', + CHANNEL_IDENTITIES[channelForOrigin(processOrigin)].dockIcon + ) : undefined const windows = new Set() @@ -871,7 +874,7 @@ function main(): void { // bundle, so they still need the channel-specific development icon. if (process.platform === 'darwin' && !app.isPackaged) { const channel = channelForOrigin(config.getOrigin()) - app.dock?.setIcon(join(__dirname, '..', 'static', APP_ICON_FOR_CHANNEL[channel])) + app.dock?.setIcon(join(__dirname, '..', 'static', CHANNEL_IDENTITIES[channel].dockIcon)) } events.record('app_launch', { version: app.getVersion(), @@ -1150,9 +1153,9 @@ function main(): void { // The name follows the build's channel ("Sim", "Sim Dev", …) so one developer // can run one install per environment side by side — separate settings, // sessions, locks, and update feeds. -app.setName(APP_NAME_FOR_CHANNEL[channelForOrigin(DEFAULT_ORIGIN)]) +app.setName(CHANNEL_IDENTITIES[channelForOrigin(DEFAULT_ORIGIN)].name) if (process.platform === 'win32') { - app.setAppUserModelId(APP_ID_FOR_CHANNEL[channelForOrigin(DEFAULT_ORIGIN)]) + app.setAppUserModelId(CHANNEL_IDENTITIES[channelForOrigin(DEFAULT_ORIGIN)].appId) } if (process.env.SIM_DESKTOP_USER_DATA) { app.setPath('userData', process.env.SIM_DESKTOP_USER_DATA) diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts index 0e45d508442..37a16fc74e9 100644 --- a/apps/desktop/src/main/tray.ts +++ b/apps/desktop/src/main/tray.ts @@ -4,12 +4,8 @@ import { truncate } from '@sim/utils/string' import type { MenuItemConstructorOptions, NativeImage } from 'electron' import { app, Menu, nativeImage, session, Tray } from 'electron' import { newChatRoute } from '@/main/app-routes' -import { - APP_ICON_FOR_CHANNEL, - APP_NAME_FOR_CHANNEL, - channelForOrigin, - type DesktopChannel, -} from '@/main/config' +import { channelForOrigin } from '@/main/config' +import { CHANNEL_IDENTITIES, type DesktopChannel } from '@/shared/channel-identity' const logger = createLogger('DesktopTray') @@ -422,7 +418,7 @@ function macTrayIcon(channel: DesktopChannel): NativeImage | null { * scale gets its own downsample so the icon stays crisp at 125-200% DPI. */ function windowsTrayIcon(channel: DesktopChannel): NativeImage | null { - const iconPath = join(app.getAppPath(), 'static', APP_ICON_FOR_CHANNEL[channel]) + const iconPath = join(app.getAppPath(), 'static', CHANNEL_IDENTITIES[channel].dockIcon) const source = nativeImage.createFromPath(iconPath) if (source.isEmpty()) { logger.error('Tray icon missing; skipping tray install', { iconPath }) @@ -453,7 +449,7 @@ export function installTray(deps: TrayDeps): TrayHandle | null { const tray = new Tray(icon) tray.setIgnoreDoubleClickEvents(true) - tray.setToolTip(APP_NAME_FOR_CHANNEL[channel]) + tray.setToolTip(CHANNEL_IDENTITIES[channel].name) // Windows opens the context menu only on right-click; a left-click is "show me Sim". if (isWindows) tray.on('click', () => deps.openMainWindow()) diff --git a/apps/desktop/src/shared/channel-identity.ts b/apps/desktop/src/shared/channel-identity.ts new file mode 100644 index 00000000000..a2e23d40f97 --- /dev/null +++ b/apps/desktop/src/shared/channel-identity.ts @@ -0,0 +1,78 @@ +/** + * Each desktop channel's identity, in the one table both the packager and the running app read: + * `scripts/channels.ts` stamps it into the bundle and installer, and `src/main` claims it at + * startup (`app.setName`, which decides the userData directory and single-instance lock, and the + * Windows AppUserModelID). When the two disagree, the app on disk and the running app disagree + * about which app they are. + */ + +/** + * The environment a build is keyed to. Each is a separate app, so one developer can keep a prod, + * staging, dev, and localhost install side by side, each with its own settings, sessions, and + * update feed. + */ +export type DesktopChannel = 'prod' | 'staging' | 'dev' | 'local' + +export interface DesktopChannelIdentity { + /** + * Display and bundle name; also the userData directory name. Prod keeps the plain name every + * existing install already has, so its userData never moves. + */ + name: string + /** + * The installer's app ID. On Windows it is also the AppUserModelID: the running process must + * claim the ID the installer stamped on its Start-menu shortcut, or toasts lose their + * attribution and taskbar pins group under a separate icon. + */ + appId: string + /** + * The 512px app icon under `static/`, for runs with no packaged icon to inherit and for the + * Windows tray; the Windows icon is generated from it (`scripts/generate-windows-icons.ts`). + */ + dockIcon: string + /** Native Icon Composer source copied into the packager's generated path. */ + icon: string + /** Multi-size Windows icon copied into the packager's generated path. */ + windowsIcon: 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 CHANNEL_IDENTITIES: Record = { + prod: { + name: 'Sim', + appId: 'ai.sim.desktop', + dockIcon: 'dock-icon.png', + icon: 'build/icon.icon', + windowsIcon: 'build/icon.ico', + slug: 'sim', + }, + staging: { + name: 'Sim Staging', + appId: 'ai.sim.desktop.staging', + dockIcon: 'dock-icon-staging.png', + icon: 'build/icon-staging.icon', + windowsIcon: 'build/icon-staging.ico', + slug: 'sim-staging', + }, + dev: { + name: 'Sim Dev', + appId: 'ai.sim.desktop.dev', + dockIcon: 'dock-icon-dev.png', + icon: 'build/icon-dev.icon', + windowsIcon: 'build/icon-dev.ico', + slug: 'sim-dev', + }, + local: { + name: 'Sim Local', + appId: 'ai.sim.desktop.local', + dockIcon: 'dock-icon-local.png', + icon: 'build/icon-local.icon', + windowsIcon: 'build/icon-local.ico', + slug: 'sim-local', + }, +} From 0a02a0ecdce43adee69acebdb9cd1984c1190c3b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:27:26 -0700 Subject: [PATCH 55/67] refactor(desktop): split the application menu into shared items and per-platform layouts buildMenuTemplate assembles the items both platforms share once and hands them to buildWindowsMenu or buildMacMenu; each menu's contents are unchanged. --- apps/desktop/src/main/menu.ts | 180 ++++++++++++++++++++++++---------- 1 file changed, 127 insertions(+), 53 deletions(-) diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index 2d023e02886..72e3604a2fb 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -40,6 +40,26 @@ export interface MenuDeps { platform?: NodeJS.Platform } +type MenuClick = NonNullable + +/** The items both platforms' menus are assembled from. */ +interface SharedMenuItems { + viewSubmenu: MenuItemConstructorOptions[] + helpItems: MenuItemConstructorOptions[] + resourceFileItems: MenuItemConstructorOptions[] + aboutItem: MenuItemConstructorOptions + settingsItem: MenuItemConstructorOptions + serverItem: MenuItemConstructorOptions + checkForUpdatesItem: MenuItemConstructorOptions + signOutItem: MenuItemConstructorOptions + folderAccessItem: MenuItemConstructorOptions + newWindowItem: MenuItemConstructorOptions + newChatItem: MenuItemConstructorOptions + closeWindowItem: MenuItemConstructorOptions + goBack: MenuClick + goForward: MenuClick +} + /** * Builds the application menu: the macOS app-menu shape, or on Windows a * File/Edit/View/Help bar. Edit roles are load-bearing — without them @@ -47,6 +67,13 @@ export interface MenuDeps { * so the zoom level persists across launches. */ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { + const items = sharedMenuItems(deps) + return (deps.platform ?? process.platform) === 'win32' + ? buildWindowsMenu(items) + : buildMacMenu(items) +} + +function sharedMenuItems(deps: MenuDeps): SharedMenuItems { /** Utility windows must not redirect resource commands into the hidden main window. */ const focusedMainOrFallback = (focusedWindow: unknown): BrowserWindow | null => { if (focusedWindow instanceof BrowserWindow) { @@ -310,61 +337,108 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] }, ] - if ((deps.platform ?? process.platform) === 'win32') { - return [ - { - label: '&File', - submenu: [ - newWindowItem, - newChatItem, - { type: 'separator' }, - folderAccessItem, - settingsItem, - serverItem, - { type: 'separator' }, - signOutItem, - { type: 'separator' }, - closeWindowItem, - ...resourceFileItems, - { type: 'separator' }, - { role: 'quit', label: 'Exit' }, - ], - }, - { role: 'editMenu' }, - { - label: '&View', - submenu: [ - ...viewSubmenu, - // Alt+Left/Right are the Windows browser-history keys; hidden so View keeps one row each. - { - label: 'Back', - accelerator: 'Alt+Left', - visible: false, - click: traverseHistory('back'), - }, - { - label: 'Forward', - accelerator: 'Alt+Right', - visible: false, - click: traverseHistory('forward'), - }, - ], - }, - // No windowMenu role: off macOS it carries a Close item bound to Ctrl+W, - // which would steal the hidden Close Tab accelerator. - { - label: '&Help', - submenu: [ - ...helpItems, - { type: 'separator' }, - checkForUpdatesItem, - { type: 'separator' }, - aboutItem, - ], - }, - ] + return { + viewSubmenu, + helpItems, + resourceFileItems, + aboutItem, + settingsItem, + serverItem, + checkForUpdatesItem, + signOutItem, + folderAccessItem, + newWindowItem, + newChatItem, + closeWindowItem, + goBack: traverseHistory('back'), + goForward: traverseHistory('forward'), } +} + +function buildWindowsMenu({ + viewSubmenu, + helpItems, + resourceFileItems, + aboutItem, + settingsItem, + serverItem, + checkForUpdatesItem, + signOutItem, + folderAccessItem, + newWindowItem, + newChatItem, + closeWindowItem, + goBack, + goForward, +}: SharedMenuItems): MenuItemConstructorOptions[] { + return [ + { + label: '&File', + submenu: [ + newWindowItem, + newChatItem, + { type: 'separator' }, + folderAccessItem, + settingsItem, + serverItem, + { type: 'separator' }, + signOutItem, + { type: 'separator' }, + closeWindowItem, + ...resourceFileItems, + { type: 'separator' }, + { role: 'quit', label: 'Exit' }, + ], + }, + { role: 'editMenu' }, + { + label: '&View', + submenu: [ + ...viewSubmenu, + // Alt+Left/Right are the Windows browser-history keys; hidden so View keeps one row each. + { + label: 'Back', + accelerator: 'Alt+Left', + visible: false, + click: goBack, + }, + { + label: 'Forward', + accelerator: 'Alt+Right', + visible: false, + click: goForward, + }, + ], + }, + // No windowMenu role: off macOS it carries a Close item bound to Ctrl+W, + // which would steal the hidden Close Tab accelerator. + { + label: '&Help', + submenu: [ + ...helpItems, + { type: 'separator' }, + checkForUpdatesItem, + { type: 'separator' }, + aboutItem, + ], + }, + ] +} +function buildMacMenu({ + viewSubmenu, + helpItems, + resourceFileItems, + aboutItem, + settingsItem, + serverItem, + checkForUpdatesItem, + signOutItem, + folderAccessItem, + newWindowItem, + newChatItem, + closeWindowItem, +}: SharedMenuItems): MenuItemConstructorOptions[] { return [ { label: app.name, From 77b04be90a2d76173da9e055db0eba5743928c29 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:28:23 -0700 Subject: [PATCH 56/67] refactor(desktop): share the update routes' error responses and drop the feed's select feedErrorResponse answers both the manifest feed and the download redirect when no release can be offered, and resolveDeploymentRelease returns the release's assets for each route to read its own field. --- .../app/api/desktop/update/download/route.ts | 23 +++--------- .../app/api/desktop/update/manifest-feed.ts | 37 +++++++++++++------ apps/sim/lib/desktop/update-feed.ts | 32 ++++++++-------- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/apps/sim/app/api/desktop/update/download/route.ts b/apps/sim/app/api/desktop/update/download/route.ts index 1556e882891..02645b066e3 100644 --- a/apps/sim/app/api/desktop/update/download/route.ts +++ b/apps/sim/app/api/desktop/update/download/route.ts @@ -7,6 +7,7 @@ import { DESKTOP_FEED_REVALIDATE_SECONDS, resolveDeploymentRelease, } from '@/lib/desktop/update-feed' +import { feedErrorResponse } from '@/app/api/desktop/update/manifest-feed' /** * Redirects to the platform's installer for the newest release of this @@ -30,22 +31,10 @@ export const GET = withRouteHandler(async (request: NextRequest): Promise assets.installer, }) - switch (resolved.status) { - case 'unavailable': - return NextResponse.json({ error: 'Release feed unavailable' }, { status: 502 }) - case 'invalid': - return NextResponse.json({ error: 'Release installer unavailable' }, { status: 502 }) - case 'no-release': - return NextResponse.json( - { error: `No desktop release for channel ${resolved.channel}` }, - { status: 404 } - ) - case 'release': - return NextResponse.redirect(resolved.value.browser_download_url, { - status: 302, - headers: { 'cache-control': `public, max-age=${DESKTOP_FEED_REVALIDATE_SECONDS}` }, - }) - } + if (resolved.status !== 'release') return feedErrorResponse(resolved, 'installer') + return NextResponse.redirect(resolved.assets.installer.browser_download_url, { + status: 302, + headers: { 'cache-control': `public, max-age=${DESKTOP_FEED_REVALIDATE_SECONDS}` }, + }) }) diff --git a/apps/sim/app/api/desktop/update/manifest-feed.ts b/apps/sim/app/api/desktop/update/manifest-feed.ts index cc21fad660b..788010d3e26 100644 --- a/apps/sim/app/api/desktop/update/manifest-feed.ts +++ b/apps/sim/app/api/desktop/update/manifest-feed.ts @@ -3,6 +3,7 @@ import type { DesktopUpdatePlatform } from '@/lib/api/contracts/desktop-update' import { env } from '@/lib/core/config/env' import { DESKTOP_FEED_REVALIDATE_SECONDS, + type DesktopFeedResolution, resolveDeploymentRelease, } from '@/lib/desktop/update-feed' @@ -30,26 +31,38 @@ export async function serveDesktopUpdateManifest( environment: env.APPCONFIG_ENVIRONMENT, githubToken: env.GITHUB_TOKEN, platform, - select: (assets) => assets.manifest, }) + if (resolved.status !== 'release') { + return feedErrorResponse(resolved, 'manifest', { [FEED_STATUS_HEADER]: 'no-release' }) + } + return new NextResponse(resolved.assets.manifest, { + status: 200, + headers: { + 'content-type': 'text/yaml; charset=utf-8', + 'cache-control': `public, max-age=${DESKTOP_FEED_REVALIDATE_SECONDS}`, + [FEED_STATUS_HEADER]: 'release', + }, + }) +} + +/** + * The response to a feed with no release to offer: 502 when GitHub could not be read or no + * release carries a valid `artifact`, 404 (with `noReleaseHeaders`) when the channel has none. + */ +export function feedErrorResponse( + resolved: Exclude, + artifact: 'installer' | 'manifest', + noReleaseHeaders?: Record +): NextResponse { switch (resolved.status) { case 'unavailable': return NextResponse.json({ error: 'Release feed unavailable' }, { status: 502 }) case 'invalid': - return NextResponse.json({ error: 'Release manifest unavailable' }, { status: 502 }) + return NextResponse.json({ error: `Release ${artifact} unavailable` }, { status: 502 }) case 'no-release': return NextResponse.json( { error: `No desktop release for channel ${resolved.channel}` }, - { status: 404, headers: { [FEED_STATUS_HEADER]: 'no-release' } } + { status: 404, headers: noReleaseHeaders } ) - case 'release': - return new NextResponse(resolved.value, { - status: 200, - headers: { - 'content-type': 'text/yaml; charset=utf-8', - 'cache-control': `public, max-age=${DESKTOP_FEED_REVALIDATE_SECONDS}`, - [FEED_STATUS_HEADER]: 'release', - }, - }) } } diff --git a/apps/sim/lib/desktop/update-feed.ts b/apps/sim/lib/desktop/update-feed.ts index 9fe07784f1c..3ca22ff437b 100644 --- a/apps/sim/lib/desktop/update-feed.ts +++ b/apps/sim/lib/desktop/update-feed.ts @@ -249,13 +249,13 @@ export function releasesApiUrl(repository: DesktopReleaseRepository, page: numbe * through to the next version. `fetchPage` returning null remains fatal because * an unreadable page could hide a newer valid release. */ -async function resolveLatestRelease( +async function resolveLatestRelease( channel: DesktopUpdateChannel, platform: DesktopUpdatePlatform, fetchPage: (page: number) => Promise, - resolveCandidate: (release: DesktopReleaseCandidate) => T | null | Promise + resolveCandidate: (release: DesktopReleaseCandidate) => Promise ): Promise< - | { release: DesktopReleaseCandidate; value: T } + | { release: DesktopReleaseCandidate; assets: DesktopReleaseAssets } | { release: null; rejectedCandidates: boolean } | { error: 'fetch-failed' } > { @@ -264,8 +264,8 @@ async function resolveLatestRelease( const releases = await fetchPage(page) if (releases === null) return { error: 'fetch-failed' } for (const release of releasesForChannel(releases, channel, platform)) { - const value = await resolveCandidate(release) - if (value !== null) return { release, value } + const assets = await resolveCandidate(release) + if (assets !== null) return { release, assets } rejectedCandidates = true } // A short page is the end of the list; nothing older remains to walk. @@ -300,8 +300,8 @@ export function selectInstallerAsset( } /** How a deployment's feed resolved for one platform. */ -type DesktopFeedResolution = - | { status: 'release'; channel: DesktopUpdateChannel; value: T } +export type DesktopFeedResolution = + | { status: 'release'; channel: DesktopUpdateChannel; assets: DesktopReleaseAssets } /** The channel has no release for the platform yet: an authoritative answer, not an outage. */ | { status: 'no-release'; channel: DesktopUpdateChannel } /** Releases exist, but none carries a complete, valid artifact set. */ @@ -314,21 +314,20 @@ export const DESKTOP_FEED_REVALIDATE_SECONDS = 300 /** * Resolves the newest complete release a deployment offers to one platform, - * reading GitHub through Next's fetch cache, and projects it with `select` - * (the rewritten manifest for the feed, the installer for the download - * redirect) so both routes always agree on the release. + * reading GitHub through Next's fetch cache. The feed serves its rewritten + * manifest and the download redirect its installer, so both routes always + * agree on the release. * * `environment` is the server-controlled deployment environment, never a * request header: this public lookup must not let a caller pick which * app-identity release it serves. `githubToken` only raises the API quota. */ -export async function resolveDeploymentRelease(options: { +export async function resolveDeploymentRelease(options: { environment: string | undefined githubToken: string | undefined platform: DesktopUpdatePlatform - select: (assets: DesktopReleaseAssets) => T -}): Promise> { - const { githubToken, platform, select } = options +}): Promise { + const { githubToken, platform } = options const channel = channelForDeploymentEnvironment(options.environment) const releaseRepository = releaseRepositoryForChannel(channel) const resolved = await resolveLatestRelease( @@ -373,12 +372,11 @@ export async function resolveDeploymentRelease(options: { channel, platform, }) - return null } - return select(assets) + return assets } ) if ('error' in resolved) return { status: 'unavailable', channel } - if (resolved.release) return { status: 'release', channel, value: resolved.value } + if (resolved.release) return { status: 'release', channel, assets: resolved.assets } return { status: resolved.rejectedCandidates ? 'invalid' : 'no-release', channel } } From 0e142cec5bf4218f984ac4f0637dcbd0fd1166fd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:28:53 -0700 Subject: [PATCH 57/67] perf(desktop): hand the Windows tray icon's scales over as PNG buffers addRepresentation decodes a PNG buffer directly, so each scale skips the base64 data URL round trip. --- apps/desktop/src/main/tray.test.ts | 2 +- apps/desktop/src/main/tray.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/tray.test.ts b/apps/desktop/src/main/tray.test.ts index 1d1fe2b05b7..719b79f4128 100644 --- a/apps/desktop/src/main/tray.test.ts +++ b/apps/desktop/src/main/tray.test.ts @@ -147,7 +147,7 @@ describe('installTray', () => { // left-click on the icon does nothing at all. const resized = { addRepresentation: vi.fn(), - toDataURL: vi.fn(() => 'data:image/png;base64,'), + toPNG: vi.fn(() => Buffer.alloc(0)), setTemplateImage: vi.fn(), } vi.mocked(nativeImage.createFromPath).mockReturnValueOnce({ diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts index 37a16fc74e9..1da194ddc77 100644 --- a/apps/desktop/src/main/tray.ts +++ b/apps/desktop/src/main/tray.ts @@ -430,7 +430,7 @@ function windowsTrayIcon(channel: DesktopChannel): NativeImage | null { } const icon = sized(1) for (const scaleFactor of WINDOWS_TRAY_SCALE_FACTORS) { - icon.addRepresentation({ scaleFactor, dataURL: sized(scaleFactor).toDataURL() }) + icon.addRepresentation({ scaleFactor, buffer: sized(scaleFactor).toPNG() }) } return icon } From db4837a39a65ef5d3eb1af9baa260bd089c26de9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:29:00 -0700 Subject: [PATCH 58/67] refactor(desktop): upload the Windows release artifacts in one ordered loop The array already puts latest.yml last, so one loop keeps the manifest after the installer it names. --- .github/workflows/desktop-release.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 0d51ea60d82..c129aa2cc35 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -846,10 +846,9 @@ jobs: fi gh release upload "$VERSION" "$ARTIFACT" --repo "$RELEASE_REPOSITORY" } - for ARTIFACT in "${ARTIFACTS[@]:0:2}"; do + for ARTIFACT in "${ARTIFACTS[@]}"; do upload_or_verify "$ARTIFACT" done - upload_or_verify "${ARTIFACTS[2]}" - name: Upload artifacts to the workflow run if: ${{ !inputs.publish }} From 40c054e0d8b43b335532c49b4d8c1e3f5b474ada Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:29:51 -0700 Subject: [PATCH 59/67] refactor(desktop): keep pty options and file type info private to their modules --- apps/desktop/src/main/file-identity.ts | 2 +- apps/desktop/src/main/terminal/pty.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/file-identity.ts b/apps/desktop/src/main/file-identity.ts index 6b041c4290b..e7f2ccec720 100644 --- a/apps/desktop/src/main/file-identity.ts +++ b/apps/desktop/src/main/file-identity.ts @@ -10,7 +10,7 @@ export interface FileIdentity { } /** What `fs.Stats` and `fs.Dirent` both say about a file's type. */ -export interface FileTypeInfo { +interface FileTypeInfo { isFile(): boolean isDirectory(): boolean isSymbolicLink(): boolean diff --git a/apps/desktop/src/main/terminal/pty.ts b/apps/desktop/src/main/terminal/pty.ts index bb77b2e66b5..9b18d818d90 100644 --- a/apps/desktop/src/main/terminal/pty.ts +++ b/apps/desktop/src/main/terminal/pty.ts @@ -15,7 +15,7 @@ import { spawn } from '@lydell/node-pty' const DEVICE_ATTRIBUTES_QUERY = '\u001b[c' const DEVICE_ATTRIBUTES_REPLY = '\u001b[?1;2c' -export interface TerminalPtyOptions { +interface TerminalPtyOptions { platform: NodeJS.Platform shellPath: string /** The shell's arguments; omitted, it starts as a login shell where the platform has one. */ @@ -40,6 +40,7 @@ export interface TerminalPty { kill(signal?: NodeJS.Signals): void } +/** Spawns `options.shellPath` in a pseudo-terminal, set up for the platform it runs on. */ export function spawnTerminalPty(options: TerminalPtyOptions): TerminalPty { const windows = options.platform === 'win32' const pty = spawn(options.shellPath, options.args ?? (windows ? [] : ['-l']), { From 4352ba7a0df81d600434a023c62c99311b0a2b84 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:34:52 -0700 Subject: [PATCH 60/67] test(desktop): satisfy the test-pattern and unused-export audits --- apps/desktop/src/main/terminal/windows-shells.test.ts | 4 ---- apps/sim/lib/desktop/update-feed.ts | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/desktop/src/main/terminal/windows-shells.test.ts b/apps/desktop/src/main/terminal/windows-shells.test.ts index 274efa45b23..f1c29b5cb60 100644 --- a/apps/desktop/src/main/terminal/windows-shells.test.ts +++ b/apps/desktop/src/main/terminal/windows-shells.test.ts @@ -9,7 +9,6 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import type { TerminalRunResult } from '@sim/terminal-protocol' -import { sleep } from '@sim/utils/helpers' import { afterEach, describe, expect, it, vi } from 'vitest' import { TerminalService } from '@/main/terminal' @@ -106,7 +105,6 @@ describe.runIf(process.platform === 'win32')('the agent terminal on Windows', () afterEach(() => { service?.dispose() service = null - vi.unstubAllEnvs() }) function open(shell: ShellUnderTest, cwd = scratchDirectory()): TerminalService { @@ -192,8 +190,6 @@ describe.runIf(process.platform === 'win32')('the agent terminal on Windows', () interval: 100, }) const pid = Number.parseInt(readFileSync(pidFile, 'utf8'), 10) - // Ctrl-C is already a keystroke once the pid is written; this lets Start-Sleep begin. - await sleep(500) await expect(terminal.cancelTool('call-stubborn')).resolves.toBe(true) expect(await running).toMatchObject({ status: 'completed' }) diff --git a/apps/sim/lib/desktop/update-feed.ts b/apps/sim/lib/desktop/update-feed.ts index 3ca22ff437b..19fb42c65b3 100644 --- a/apps/sim/lib/desktop/update-feed.ts +++ b/apps/sim/lib/desktop/update-feed.ts @@ -190,7 +190,7 @@ export const DESKTOP_RELEASES_PAGE_SIZE = 100 */ export const MAX_DESKTOP_RELEASE_PAGES = 5 -export interface DesktopReleaseAssets { +interface DesktopReleaseAssets { manifest: string installer: { name: string; browser_download_url: string } } From ad4ec1719da354a987fbaa64d6edd4f051184ecc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:40:10 -0700 Subject: [PATCH 61/67] test(desktop): leave the Chromium profile readers out of the Windows suite Browser import does not ship on Windows; its platform gate stays covered by the import-service tests, which inject the platform. --- apps/desktop/vitest.config.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index 093e5dbce4e..5cc7eca9c66 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -9,11 +9,16 @@ export default mergeConfig( setupFiles: ['src/test/setup.ts'], include: ['src/**/*.test.ts'], // These suites drive subsystems Windows builds do not ship (tmux, the macOS computer-use - // helper); see src/shared/platform-features.ts. + // helper, the Chromium profile readers behind browser import); see + // src/shared/platform-features.ts. exclude: [ '**/e2e/**', ...(process.platform === 'win32' - ? ['src/main/terminal/tmux.test.ts', 'src/main/computer-use/native-client.test.ts'] + ? [ + 'src/main/terminal/tmux.test.ts', + 'src/main/computer-use/native-client.test.ts', + 'src/main/browser-import/chromium-*.test.ts', + ] : []), ], pool: 'threads', From 43c5e5cc6b667285f71966b468c0a7c611ad3b05 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:58:00 -0700 Subject: [PATCH 62/67] fix(desktop): stop only the stopped command's processes on Windows Stop selected every direct child of the shell, so a server the user had started in the background earlier in the same terminal was killed with the command. The session records when the agent's command was typed, and on Windows only children created since then are its processes. The Windows suite starts a background child before the stubborn command and checks it survives Stop. --- apps/desktop/src/main/terminal/index.ts | 5 +- .../src/main/terminal/process-group.test.ts | 17 +++++ .../src/main/terminal/process-group.ts | 65 ++++++++++++------- .../desktop/src/main/terminal/service.test.ts | 5 +- apps/desktop/src/main/terminal/session.ts | 9 +++ .../src/main/terminal/windows-shells.test.ts | 59 ++++++++++++----- 6 files changed, 117 insertions(+), 43 deletions(-) create mode 100644 apps/desktop/src/main/terminal/process-group.test.ts diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts index b352957d38a..e3821052216 100644 --- a/apps/desktop/src/main/terminal/index.ts +++ b/apps/desktop/src/main/terminal/index.ts @@ -962,7 +962,8 @@ export class TerminalService { * does, so a command the user starts meanwhile is never hit. */ private async stopCommand(session: TerminalSession, toolCallId: string): Promise { - if (session.agentCommandToolCallId !== toolCallId) return + const startedAt = session.agentCommandStartedAt + if (session.agentCommandToolCallId !== toolCallId || startedAt === null) return const groups = this.options.processGroups ?? osProcessGroups session.kill('SIGINT') let command: number[] | undefined @@ -970,7 +971,7 @@ export class TerminalService { if (await this.commandEnds(session, toolCallId, STOP_ESCALATION_MS)) return const remaining = command === undefined - ? await groups.foreground(session.pid) + ? await groups.foreground(session.pid, startedAt) : await groups.stillRunning(session.pid, command) command ??= remaining if (remaining.length === 0) return diff --git a/apps/desktop/src/main/terminal/process-group.test.ts b/apps/desktop/src/main/terminal/process-group.test.ts new file mode 100644 index 00000000000..fe525018a72 --- /dev/null +++ b/apps/desktop/src/main/terminal/process-group.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { windowsProcessGroups } from '@/main/terminal/process-group' + +const SHELL_PID = 100 +const COMMAND_TYPED_AT = 1_000_000 + +describe('the Windows foreground command', () => { + it('is the children started since the command was typed, never earlier background work', async () => { + const groups = windowsProcessGroups(async () => [ + { pid: 201, createdAt: COMMAND_TYPED_AT - 60_000 }, + { pid: 202, createdAt: COMMAND_TYPED_AT }, + { pid: 203, createdAt: COMMAND_TYPED_AT + 15 }, + ]) + + await expect(groups.foreground(SHELL_PID, COMMAND_TYPED_AT)).resolves.toEqual([202, 203]) + }) +}) diff --git a/apps/desktop/src/main/terminal/process-group.ts b/apps/desktop/src/main/terminal/process-group.ts index c2ce1a231f6..fe538919beb 100644 --- a/apps/desktop/src/main/terminal/process-group.ts +++ b/apps/desktop/src/main/terminal/process-group.ts @@ -3,7 +3,11 @@ * with Ctrl-C, including its children, and never the shell itself. * * On macOS and Linux that is the tty's foreground process group. Windows has no process groups or - * job control, so there it is the shell's child processes, each ended with its whole tree. + * job control, so there it is the shell's child processes created since the command was typed, + * each ended with its whole tree. Children from before then (a server the user started in the + * background earlier) belong to someone else's work and are left running. Creation times and the + * command's start come from the same system clock, and the shell only starts a program after it + * reads the command, so no tolerance is needed. */ import { spawn } from 'node:child_process' import { win32 } from 'node:path' @@ -21,11 +25,11 @@ const WINDOWS_LOOKUP_TIMEOUT_MS = 5_000 /** Reads and signals what holds a terminal's foreground. */ export interface TerminalProcessGroups { /** - * Ids of what runs the foreground command: process group ids on macOS and Linux, process ids on - * Windows. Empty when the shell itself holds the foreground (it is sitting at its prompt) or the - * lookup failed. + * Ids of what runs the foreground command, which was typed into the shell at `startedAt` (epoch + * ms): process group ids on macOS and Linux, process ids on Windows. Empty when the shell itself + * holds the foreground (it is sitting at its prompt) or the lookup failed. */ - foreground(shellPid: number): Promise + foreground(shellPid: number, startedAt: number): Promise /** Those of `ids`, from an earlier {@link foreground}, that still run the foreground command. */ stillRunning(shellPid: number, ids: number[]): Promise /** Sends `signal` to each id {@link foreground} returned. */ @@ -40,8 +44,8 @@ const POSIX_PROCESS_GROUPS: TerminalProcessGroups = { return pgid === null ? [] : [pgid] }, stillRunning: async (shellPid, pgids) => { - const foreground = await POSIX_PROCESS_GROUPS.foreground(shellPid) - return pgids.filter((pgid) => foreground.includes(pgid)) + const foreground = await readForegroundProcessGroup(shellPid) + return pgids.filter((pgid) => pgid === foreground) }, signal: (pgids, signal) => { for (const pgid of pgids) signalProcessGroup(pgid, signal) @@ -49,22 +53,37 @@ const POSIX_PROCESS_GROUPS: TerminalProcessGroups = { escalations: ['SIGTERM', 'SIGKILL'], } +/** A Windows process, as the process table reports it. */ +export interface WindowsChildProcess { + pid: number + /** Epoch milliseconds. */ + createdAt: number +} + /** - * The one escalation is a forced tree kill: a Windows console program cannot be asked to - * terminate, and Ctrl-C, the one request it can trap, has already been sent. + * Windows, given how to list a shell's child processes. The one escalation is a forced tree kill: + * a Windows console program cannot be asked to terminate, and Ctrl-C, the one request it can trap, + * has already been sent. */ -const WINDOWS_PROCESS_GROUPS: TerminalProcessGroups = { - foreground: readChildProcesses, - stillRunning: async (_shellPid, pids) => pids.filter(isRunning), - signal: (pids) => { - for (const pid of pids) killProcessTree(pid) - }, - escalations: ['SIGTERM'], +export function windowsProcessGroups( + readChildren: (shellPid: number) => Promise +): TerminalProcessGroups { + return { + foreground: async (shellPid, startedAt) => + (await readChildren(shellPid)) + .filter((child) => child.pid !== shellPid && child.createdAt >= startedAt) + .map((child) => child.pid), + stillRunning: async (_shellPid, pids) => pids.filter(isRunning), + signal: (pids) => { + for (const pid of pids) killProcessTree(pid) + }, + escalations: ['SIGTERM'], + } } /** This operating system's way of reading and stopping a terminal's foreground command. */ export const osProcessGroups: TerminalProcessGroups = - process.platform === 'win32' ? WINDOWS_PROCESS_GROUPS : POSIX_PROCESS_GROUPS + process.platform === 'win32' ? windowsProcessGroups(readChildProcesses) : POSIX_PROCESS_GROUPS /** * The tty's foreground process group, read from `ps`. Null when it cannot be read, or when the @@ -89,7 +108,7 @@ function signalProcessGroup(pgid: number, signal: NodeJS.Signals): void { } /** The shell's direct children, from the process table; each one's tree is what it runs. */ -async function readChildProcesses(shellPid: number): Promise { +async function readChildProcesses(shellPid: number): Promise { if (!isProcessId(shellPid)) return [] const stdout = await readOutput( windowsPowerShellPath(process.env), @@ -98,14 +117,14 @@ async function readChildProcesses(shellPid: number): Promise { '-NoProfile', '-NonInteractive', '-Command', - `(Get-CimInstance -ClassName Win32_Process -Filter 'ParentProcessId=${shellPid}').ProcessId`, + `Get-CimInstance -ClassName Win32_Process -Filter 'ParentProcessId=${shellPid}' | ForEach-Object { "$($_.ProcessId) $(([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds())" }`, ], WINDOWS_LOOKUP_TIMEOUT_MS ) - return (stdout ?? '') - .split(/\s+/) - .map((value) => Number.parseInt(value, 10)) - .filter((pid) => isProcessId(pid) && pid !== shellPid) + return (stdout ?? '').split(/\r?\n/).flatMap((line) => { + const [pid, createdAt] = line.trim().split(' ').map(Number) + return isProcessId(pid) && Number.isFinite(createdAt) ? [{ pid, createdAt }] : [] + }) } function killProcessTree(pid: number): void { diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts index 4d9395adbdd..832f5cc6d15 100644 --- a/apps/desktop/src/main/terminal/service.test.ts +++ b/apps/desktop/src/main/terminal/service.test.ts @@ -177,6 +177,9 @@ vi.mock('@/main/terminal/session', async () => { get agentCommandToolCallId() { return state.toolCallId }, + get agentCommandStartedAt() { + return state.toolCallId === null ? null : 0 + }, kill: vi.fn((signal: string) => { if (signal === 'SIGINT' && state.interruptible) finishRun(130) }), @@ -485,7 +488,7 @@ describe('stopping a tool call', () => { /** What holds the terminal's foreground as the OS would report it at each read. */ let foreground: number[] = [COMMAND_GROUP] const processGroups = { - foreground: vi.fn(async (_shellPid: number) => foreground), + foreground: vi.fn(async (_shellPid: number, _startedAt: number) => foreground), stillRunning: vi.fn(async (_shellPid: number, ids: number[]) => ids.filter((id) => foreground.includes(id)) ), diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts index 52b9791cd68..07d1c996a28 100644 --- a/apps/desktop/src/main/terminal/session.ts +++ b/apps/desktop/src/main/terminal/session.ts @@ -369,6 +369,8 @@ export class TerminalSession { * the command is still the agent's, so Stop and sign-out can still end it. */ private agentToolCallId: string | null = null + /** When that command was typed into the shell; anything it started began after this. */ + private agentCommandSentAt: number | null = null private pendingCommand: PendingCommand | null = null /** Command line reported by the shell but not yet bracketed by output-start. */ private announcedCommand: string | null = null @@ -509,6 +511,11 @@ export class TerminalSession { return this.agentToolCallId } + /** When the agent's still-running command was typed into the shell, in epoch milliseconds. */ + get agentCommandStartedAt(): number | null { + return this.agentCommandSentAt + } + /** * Tab-strip view of this terminal. The label prefers the running command, * which is what the user is actually waiting on, and falls back to the @@ -711,6 +718,7 @@ export class TerminalSession { this.foregroundCommand = command this.foregroundToolCallId = toolCallId this.agentToolCallId = toolCallId + this.agentCommandSentAt = Date.now() this.emitState() this.callbacks.onCommand({ terminalId: this.terminalId, phase: 'start', command, toolCallId }) @@ -1097,6 +1105,7 @@ export class TerminalSession { this.foregroundCommand = null this.foregroundToolCallId = null this.agentToolCallId = null + this.agentCommandSentAt = null this.announcedCommand = null this.altScreen = false this.emitState() diff --git a/apps/desktop/src/main/terminal/windows-shells.test.ts b/apps/desktop/src/main/terminal/windows-shells.test.ts index f1c29b5cb60..ea0c3b87428 100644 --- a/apps/desktop/src/main/terminal/windows-shells.test.ts +++ b/apps/desktop/src/main/terminal/windows-shells.test.ts @@ -38,6 +38,8 @@ interface ShellUnderTest { cd: string /** What runs a program named by a quoted path. */ call: string + /** Starts a quoted program with quoted arguments in the background, as a user would. */ + background(program: string, args: string[]): string } const POWERSHELL: Omit = { @@ -47,6 +49,8 @@ const POWERSHELL: Omit = { quote: (path) => `'${path}'`, cd: 'Set-Location -LiteralPath', call: '&', + background: (program, args) => + `Start-Process -WindowStyle Hidden -FilePath ${program} -ArgumentList ${args.join(',')}`, } const SHELLS: ShellUnderTest[] = [ @@ -60,6 +64,7 @@ const SHELLS: ShellUnderTest[] = [ quote: (path) => `'${path.replaceAll('\\', '/')}'`, cd: 'cd', call: '', + background: (program, args) => `${program} ${args.join(' ')} &`, }, ] @@ -80,6 +85,13 @@ function scratchDirectory(): string { return realpathSync.native(mkdtempSync(join(tmpdir(), 'sim-windows-shell-'))) } +/** The pid a stubborn script wrote once it is up. */ +async function readPid(pidFile: string): Promise { + const pid = () => (existsSync(pidFile) ? Number.parseInt(readFileSync(pidFile, 'utf8'), 10) : 0) + await vi.waitFor(() => expect(pid()).toBeGreaterThan(0), { timeout: 30_000, interval: 100 }) + return pid() +} + function isAlive(pid: number): boolean { try { process.kill(pid, 0) @@ -167,34 +179,47 @@ describe.runIf(process.platform === 'win32')('the agent terminal on Windows', () ) it.runIf(installed)( - 'Stop ends a command that ignores Ctrl-C and leaves the shell usable', + 'Stop ends a command that ignores Ctrl-C, spares earlier background work, and leaves the shell usable', async () => { const scratch = scratchDirectory() const script = join(scratch, 'stubborn.ps1') + const backgroundPidFile = join(scratch, 'background-pid') const pidFile = join(scratch, 'pid') writeFileSync(script, STUBBORN_SCRIPT) const terminal = open(shell, scratch) - const command = [ - shell.call, - shell.quote(WINDOWS_POWERSHELL), - '-NoProfile -ExecutionPolicy Bypass -File', + const stubbornArgs = (pidPath: string) => [ + "'-NoProfile'", + "'-ExecutionPolicy'", + "'Bypass'", + "'-File'", shell.quote(script), - shell.quote(pidFile), + shell.quote(pidPath), ] + const command = [shell.call, shell.quote(WINDOWS_POWERSHELL), ...stubbornArgs(pidFile)] .filter(Boolean) .join(' ') - const running = run(terminal, 'call-stubborn', command) - await vi.waitFor(() => expect(existsSync(pidFile)).toBe(true), { - timeout: 30_000, - interval: 100, - }) - const pid = Number.parseInt(readFileSync(pidFile, 'utf8'), 10) - - await expect(terminal.cancelTool('call-stubborn')).resolves.toBe(true) - expect(await running).toMatchObject({ status: 'completed' }) - expect(isAlive(pid)).toBe(false) - expect(await run(terminal, 'call-after', shell.succeed(0))).toMatchObject({ exitCode: 0 }) + // A server the user left running in this shell before the agent's command. + await run( + terminal, + 'call-background', + shell.background(shell.quote(WINDOWS_POWERSHELL), stubbornArgs(backgroundPidFile)) + ) + const backgroundPid = await readPid(backgroundPidFile) + try { + const running = run(terminal, 'call-stubborn', command) + const pid = await readPid(pidFile) + + await expect(terminal.cancelTool('call-stubborn')).resolves.toBe(true) + expect(await running).toMatchObject({ status: 'completed' }) + expect(isAlive(pid)).toBe(false) + expect(isAlive(backgroundPid)).toBe(true) + expect(await run(terminal, 'call-after', shell.succeed(0))).toMatchObject({ + exitCode: 0, + }) + } finally { + if (isAlive(backgroundPid)) process.kill(backgroundPid) + } }, SHELL_TEST_TIMEOUT_MS ) From fb3414f150cc314055c571d54656264e480bcc74 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:58:01 -0700 Subject: [PATCH 63/67] fix(desktop): answer ConPTY's startup query when it arrives split across chunks While the query is pending, a chunk's tail that could begin it is held until the next chunk, which either completes the query or releases it. --- apps/desktop/src/main/terminal/pty.ts | 26 +++++++++++-- .../desktop/src/main/terminal/session.test.ts | 39 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/terminal/pty.ts b/apps/desktop/src/main/terminal/pty.ts index 9b18d818d90..1e7837ec1c7 100644 --- a/apps/desktop/src/main/terminal/pty.ts +++ b/apps/desktop/src/main/terminal/pty.ts @@ -15,6 +15,18 @@ import { spawn } from '@lydell/node-pty' const DEVICE_ATTRIBUTES_QUERY = '\u001b[c' const DEVICE_ATTRIBUTES_REPLY = '\u001b[?1;2c' +/** How much of `data`'s end could be the start of the query, completed by the next chunk. */ +function trailingQueryPrefixLength(data: string): number { + for ( + let length = Math.min(DEVICE_ATTRIBUTES_QUERY.length - 1, data.length); + length > 0; + length-- + ) { + if (DEVICE_ATTRIBUTES_QUERY.startsWith(data.slice(-length))) return length + } + return 0 +} + interface TerminalPtyOptions { platform: NodeJS.Platform shellPath: string @@ -53,12 +65,20 @@ export function spawnTerminalPty(options: TerminalPtyOptions): TerminalPty { }) let startupQueryPending = windows + /** A chunk's tail that may be the query split across chunks, held until the next one. */ + let heldTail = '' const answerStartupQuery = (chunk: string): string => { - const index = chunk.indexOf(DEVICE_ATTRIBUTES_QUERY) - if (index === -1) return chunk + const data = heldTail + chunk + heldTail = '' + const index = data.indexOf(DEVICE_ATTRIBUTES_QUERY) + if (index === -1) { + const held = trailingQueryPrefixLength(data) + heldTail = data.slice(data.length - held) + return data.slice(0, data.length - held) + } startupQueryPending = false pty.write(DEVICE_ATTRIBUTES_REPLY) - return chunk.slice(0, index) + chunk.slice(index + DEVICE_ATTRIBUTES_QUERY.length) + return data.slice(0, index) + data.slice(index + DEVICE_ATTRIBUTES_QUERY.length) } return { diff --git a/apps/desktop/src/main/terminal/session.test.ts b/apps/desktop/src/main/terminal/session.test.ts index 4c7f367196d..cdf300410bc 100644 --- a/apps/desktop/src/main/terminal/session.test.ts +++ b/apps/desktop/src/main/terminal/session.test.ts @@ -329,6 +329,45 @@ describe('TerminalSession on Windows', () => { } }) + it.each([ + [['\u001b[1t\u001b', '[c\u001b[?1004h']], + [['\u001b[1t\u001b[', 'c\u001b[?1004h']], + [['\u001b[1t\u001b', '[', 'c\u001b[?1004h']], + ])('answers the startup query when ConPTY splits it across chunks: %j', async (chunks) => { + vi.useFakeTimers() + let painted = '' + const session = sessionOn('win32', (_id, data) => { + painted += data + }) + try { + for (const chunk of chunks) ptyStub.dataHandler?.(chunk) + await vi.advanceTimersByTimeAsync(50) + + expect(ptyStub.writes.filter((write) => write === DEVICE_ATTRIBUTES_REPLY)).toHaveLength(1) + expect(painted).toBe('\u001b[1t\u001b[?1004h') + } finally { + session.dispose() + } + }) + + it('releases a held escape the next chunk shows was not the query', async () => { + vi.useFakeTimers() + let painted = '' + const session = sessionOn('win32', (_id, data) => { + painted += data + }) + try { + ptyStub.dataHandler?.('\u001b[1t\u001b[') + ptyStub.dataHandler?.('?1004h') + await vi.advanceTimersByTimeAsync(50) + + expect(painted).toBe('\u001b[1t\u001b[?1004h') + expect(ptyStub.writes).not.toContain(DEVICE_ATTRIBUTES_REPLY) + } finally { + session.dispose() + } + }) + it('leaves the startup query of a macOS shell to the panel', async () => { vi.useFakeTimers() const session = sessionOn('darwin') From be091d06dfc21d7e7e0326f1b21a1f5715728805 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:58:02 -0700 Subject: [PATCH 64/67] fix(terminal-protocol): read each Windows shell's own escape and quoting cmd escapes with a caret, literal inside double quotes, and has no single quotes; PowerShell escapes with a backtick. --- packages/terminal-protocol/src/index.test.ts | 9 +++ packages/terminal-protocol/src/index.ts | 64 +++++++++++++++----- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/packages/terminal-protocol/src/index.test.ts b/packages/terminal-protocol/src/index.test.ts index 4b5348b4038..244091bade5 100644 --- a/packages/terminal-protocol/src/index.test.ts +++ b/packages/terminal-protocol/src/index.test.ts @@ -35,4 +35,13 @@ describe('describeRunningCommand', () => { it('reads a backslash as a path separator in Windows shells, never an escape', () => { expect(describeRunningCommand('cd C:\\; npm test', 'powershell')).toBe('npm') }) + + it("honors each Windows shell's own escape character", () => { + expect(describeRunningCommand('echo ^& npm test', 'cmd')).toBe('echo') + expect(describeRunningCommand('echo `; npm test', 'pwsh')).toBe('echo') + }) + + it('reads a single quote in cmd as an ordinary character', () => { + expect(describeRunningCommand("echo 'a & npm test'", 'cmd')).toBe('npm') + }) }) diff --git a/packages/terminal-protocol/src/index.ts b/packages/terminal-protocol/src/index.ts index 0b666ca4da2..cbc85f0505d 100644 --- a/packages/terminal-protocol/src/index.ts +++ b/packages/terminal-protocol/src/index.ts @@ -308,35 +308,67 @@ const COMMAND_PREFIX_WORDS = new Set([ /** `NAME=value`, the other thing that can sit in front of the program. */ const ENVIRONMENT_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/ -/** - * Windows shells, where a backslash separates path segments rather than - * escaping the next character (PowerShell escapes with a backtick, cmd with a - * caret). - */ -const LITERAL_BACKSLASH_SHELLS: ReadonlySet = new Set(['pwsh', 'powershell', 'cmd']) +/** What a shell line's quoting looks like, as far as finding its last command needs. */ +interface ShellSyntax { + /** The character that makes the next one literal. */ + escape: string + /** Whether {@link escape} still works inside double quotes. */ + escapesInDoubleQuotes: boolean + quotes: ReadonlySet + /** Windows shells read either slash as a path separator; POSIX ones only `/`. */ + windowsPaths: boolean +} + +const POSIX_SYNTAX: ShellSyntax = { + escape: '\\', + escapesInDoubleQuotes: true, + quotes: new Set(["'", '"']), + windowsPaths: false, +} + +const POWERSHELL_SYNTAX: ShellSyntax = { + escape: '`', + escapesInDoubleQuotes: true, + quotes: new Set(["'", '"']), + windowsPaths: true, +} + +/** cmd has no single quotes, and a caret is literal inside double quotes. */ +const CMD_SYNTAX: ShellSyntax = { + escape: '^', + escapesInDoubleQuotes: false, + quotes: new Set(['"']), + windowsPaths: true, +} + +const SHELL_SYNTAX: ReadonlyMap = new Map([ + ['pwsh', POWERSHELL_SYNTAX], + ['powershell', POWERSHELL_SYNTAX], + ['cmd', CMD_SYNTAX], +]) /** * The last command in a shell line, ignoring separators inside quotes. A * quote-blind split would cut `claude "a && b"` in half and report `b"` as the * program. */ -function lastCommandSegment(command: string, backslashEscapes: boolean): string { +function lastCommandSegment(command: string, syntax: ShellSyntax): string { let start = 0 - let quote: "'" | '"' | null = null + let quote: string | null = null for (let index = 0; index < command.length; index++) { const char = command[index] if (quote) { - // Only double quotes honor backslash escapes; inside single quotes a - // backslash is a literal character and cannot hide the closing quote. - if (char === '\\' && quote === '"' && backslashEscapes) index++ + // Only double quotes can honor escapes; inside single quotes the escape + // character is literal and cannot hide the closing quote. + if (char === syntax.escape && quote === '"' && syntax.escapesInDoubleQuotes) index++ else if (char === quote) quote = null continue } - if (char === "'" || char === '"') { + if (syntax.quotes.has(char)) { quote = char continue } - if (char === '\\' && backslashEscapes) { + if (char === syntax.escape) { index++ continue } @@ -372,8 +404,8 @@ export function describeRunningCommand(running: string, shell?: string): string const line = running.trim() if (!line) return 'a command' - const backslashEscapes = !LITERAL_BACKSLASH_SHELLS.has(shell ?? '') - const segment = lastCommandSegment(line, backslashEscapes) || line + const syntax = (shell && SHELL_SYNTAX.get(shell)) || POSIX_SYNTAX + const segment = lastCommandSegment(line, syntax) || line const program = segment .split(/\s+/) .filter(Boolean) @@ -386,7 +418,7 @@ export function describeRunningCommand(running: string, shell?: string): string const label = (program - ? lastPathSegment(program.replace(/^['"]|['"]$/g, ''), backslashEscapes ? '/' : /[\\/]/) + ? lastPathSegment(program.replace(/^['"]|['"]$/g, ''), syntax.windowsPaths ? /[\\/]/ : '/') : '') || segment return truncate(label, MAX_RUNNING_COMMAND_LABEL, '…') } From 4e030974e1c690c3a9249332f326eb24e22201f4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:58:02 -0700 Subject: [PATCH 65/67] docs(desktop): name the platform feature table's actual readers --- apps/desktop/src/shared/platform-features.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/shared/platform-features.ts b/apps/desktop/src/shared/platform-features.ts index f12d0304cb2..9b074ddc633 100644 --- a/apps/desktop/src/shared/platform-features.ts +++ b/apps/desktop/src/shared/platform-features.ts @@ -1,7 +1,9 @@ /** - * What the shell ships on each operating system. The preload, the IPC table, - * the background executor, and device registration all read this one table, - * so a surface cannot be advertised on one side and refused on the other. + * What the shell ships on each operating system. The preload (which bridge + * surfaces exist) and the IPC channel specs (`platformFeature`, which channels + * are registered) both read this one table, so a surface cannot be exposed on + * one side and refused on the other. Main also reads it for computer use, the + * Help menu search field, and tmux. * * Computer use additionally needs a recent macOS; that version check stays * with the service that knows the OS release. From 368079052e937048d8a29331f8e97e3d3706371d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 01:04:13 -0700 Subject: [PATCH 66/67] fix(desktop): find a Windows command behind a shell launcher Git for Windows' bin\bash.exe holds the pty and starts the real bash, so the shell's direct children predate every command and Stop found nothing to end. Stop now walks the shell's descendants and takes the outermost ones created since the command was typed. --- .../src/main/terminal/process-group.test.ts | 37 ++++++++--- .../src/main/terminal/process-group.ts | 61 +++++++++++++------ 2 files changed, 71 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/main/terminal/process-group.test.ts b/apps/desktop/src/main/terminal/process-group.test.ts index fe525018a72..4f8312e3566 100644 --- a/apps/desktop/src/main/terminal/process-group.test.ts +++ b/apps/desktop/src/main/terminal/process-group.test.ts @@ -1,17 +1,38 @@ import { describe, expect, it } from 'vitest' -import { windowsProcessGroups } from '@/main/terminal/process-group' +import { type WindowsProcess, windowsProcessGroups } from '@/main/terminal/process-group' const SHELL_PID = 100 const COMMAND_TYPED_AT = 1_000_000 +async function foreground(table: WindowsProcess[]): Promise { + const ids = await windowsProcessGroups(async () => table).foreground(SHELL_PID, COMMAND_TYPED_AT) + return [...ids].sort() +} + describe('the Windows foreground command', () => { - it('is the children started since the command was typed, never earlier background work', async () => { - const groups = windowsProcessGroups(async () => [ - { pid: 201, createdAt: COMMAND_TYPED_AT - 60_000 }, - { pid: 202, createdAt: COMMAND_TYPED_AT }, - { pid: 203, createdAt: COMMAND_TYPED_AT + 15 }, - ]) + it('is what the shell started since the command was typed, never earlier background work', async () => { + await expect( + foreground([ + { pid: 201, parentPid: SHELL_PID, createdAt: COMMAND_TYPED_AT - 60_000 }, + { pid: 202, parentPid: SHELL_PID, createdAt: COMMAND_TYPED_AT }, + { pid: 203, parentPid: SHELL_PID, createdAt: COMMAND_TYPED_AT + 15 }, + { pid: 300, parentPid: 1, createdAt: COMMAND_TYPED_AT + 15 }, + ]) + ).resolves.toEqual([202, 203]) + await expect( + foreground([{ pid: 201, parentPid: SHELL_PID, createdAt: COMMAND_TYPED_AT - 60_000 }]) + ).resolves.toEqual([]) + }) - await expect(groups.foreground(SHELL_PID, COMMAND_TYPED_AT)).resolves.toEqual([202, 203]) + it('finds the command behind a launcher, and names only the top of its tree', async () => { + // Git for Windows' bin\bash.exe holds the pty and starts the real usr\bin\bash.exe. + await expect( + foreground([ + { pid: 150, parentPid: SHELL_PID, createdAt: COMMAND_TYPED_AT - 90_000 }, + { pid: 201, parentPid: 150, createdAt: COMMAND_TYPED_AT - 60_000 }, + { pid: 202, parentPid: 150, createdAt: COMMAND_TYPED_AT + 5 }, + { pid: 210, parentPid: 202, createdAt: COMMAND_TYPED_AT + 40 }, + ]) + ).resolves.toEqual([202]) }) }) diff --git a/apps/desktop/src/main/terminal/process-group.ts b/apps/desktop/src/main/terminal/process-group.ts index fe538919beb..186c5e9652e 100644 --- a/apps/desktop/src/main/terminal/process-group.ts +++ b/apps/desktop/src/main/terminal/process-group.ts @@ -3,11 +3,13 @@ * with Ctrl-C, including its children, and never the shell itself. * * On macOS and Linux that is the tty's foreground process group. Windows has no process groups or - * job control, so there it is the shell's child processes created since the command was typed, - * each ended with its whole tree. Children from before then (a server the user started in the - * background earlier) belong to someone else's work and are left running. Creation times and the - * command's start come from the same system clock, and the shell only starts a program after it - * reads the command, so no tolerance is needed. + * job control, so there it is the processes the shell started since the command was typed, each + * ended with its whole tree. They are found among the shell's descendants rather than its + * children, since a launcher can stand between the pty and the real shell (Git for Windows' + * `bin\bash.exe` starts `usr\bin\bash.exe`). Processes from before then (a server the user started + * in the background earlier, the real shell behind a launcher) are someone else's and are left + * running. Creation times and the command's start come from the same system clock, and the shell + * only starts a program after it reads the command, so no tolerance is needed. */ import { spawn } from 'node:child_process' import { win32 } from 'node:path' @@ -54,25 +56,46 @@ const POSIX_PROCESS_GROUPS: TerminalProcessGroups = { } /** A Windows process, as the process table reports it. */ -export interface WindowsChildProcess { +export interface WindowsProcess { pid: number + parentPid: number /** Epoch milliseconds. */ createdAt: number } /** - * Windows, given how to list a shell's child processes. The one escalation is a forced tree kill: - * a Windows console program cannot be asked to terminate, and Ctrl-C, the one request it can trap, + * The outermost of the shell's descendants created at or after `startedAt`: the processes a + * command typed then started, each standing for its whole tree. + */ +function commandRoots(table: WindowsProcess[], shellPid: number, startedAt: number): number[] { + const children = new Map() + for (const entry of table) { + if (entry.pid === entry.parentPid) continue + children.set(entry.parentPid, [...(children.get(entry.parentPid) ?? []), entry]) + } + const roots: number[] = [] + const visited = new Set([shellPid]) + const pending = [...(children.get(shellPid) ?? [])] + for (let entry = pending.pop(); entry; entry = pending.pop()) { + if (visited.has(entry.pid)) continue + visited.add(entry.pid) + if (entry.createdAt >= startedAt) roots.push(entry.pid) + else pending.push(...(children.get(entry.pid) ?? [])) + } + return roots +} + +/** + * Windows, given how to read the process table. The one escalation is a forced tree kill: a + * Windows console program cannot be asked to terminate, and Ctrl-C, the one request it can trap, * has already been sent. */ export function windowsProcessGroups( - readChildren: (shellPid: number) => Promise + readProcesses: () => Promise ): TerminalProcessGroups { return { foreground: async (shellPid, startedAt) => - (await readChildren(shellPid)) - .filter((child) => child.pid !== shellPid && child.createdAt >= startedAt) - .map((child) => child.pid), + commandRoots(await readProcesses(), shellPid, startedAt), stillRunning: async (_shellPid, pids) => pids.filter(isRunning), signal: (pids) => { for (const pid of pids) killProcessTree(pid) @@ -83,7 +106,7 @@ export function windowsProcessGroups( /** This operating system's way of reading and stopping a terminal's foreground command. */ export const osProcessGroups: TerminalProcessGroups = - process.platform === 'win32' ? windowsProcessGroups(readChildProcesses) : POSIX_PROCESS_GROUPS + process.platform === 'win32' ? windowsProcessGroups(readProcessTable) : POSIX_PROCESS_GROUPS /** * The tty's foreground process group, read from `ps`. Null when it cannot be read, or when the @@ -107,9 +130,7 @@ function signalProcessGroup(pgid: number, signal: NodeJS.Signals): void { } } -/** The shell's direct children, from the process table; each one's tree is what it runs. */ -async function readChildProcesses(shellPid: number): Promise { - if (!isProcessId(shellPid)) return [] +async function readProcessTable(): Promise { const stdout = await readOutput( windowsPowerShellPath(process.env), [ @@ -117,13 +138,15 @@ async function readChildProcesses(shellPid: number): Promise { - const [pid, createdAt] = line.trim().split(' ').map(Number) - return isProcessId(pid) && Number.isFinite(createdAt) ? [{ pid, createdAt }] : [] + const [pid, parentPid, createdAt] = line.trim().split(' ').map(Number) + return isProcessId(pid) && Number.isInteger(parentPid) && Number.isFinite(createdAt) + ? [{ pid, parentPid, createdAt }] + : [] }) } From 9fa4316454d43c19347a3ac0f930f95059d1a37c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 01:21:50 -0700 Subject: [PATCH 67/67] fix(desktop): type the agent's next command only once the prompt is drawn Git Bash drops input typed while it is still switching from a native program back to its prompt, so a command run straight after a stopped one vanished. bash now reports its prompt's end (OSC 633 B, drawn by readline at the end of PS1), as PowerShell already did, and a run into a shell that reports it waits for the prompt, at most 2 s. The bash init file also sets PROMPT_COMMAND before its DEBUG trap, so that assignment is no longer announced as a command at startup. --- apps/desktop/src/main/terminal/index.ts | 1 + .../desktop/src/main/terminal/service.test.ts | 1 + .../desktop/src/main/terminal/session.test.ts | 62 +++++++++++++++++++ apps/desktop/src/main/terminal/session.ts | 41 +++++++++++- .../src/main/terminal/shell-integration.ts | 17 ++++- 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts index e3821052216..9cf7d40f8b0 100644 --- a/apps/desktop/src/main/terminal/index.ts +++ b/apps/desktop/src/main/terminal/index.ts @@ -1390,6 +1390,7 @@ export class TerminalService { if (!session.hasShellIntegration) { await this.awaitShellStartup(session, latch) } + await session.waitForPromptDrawn(latch.signal) if (session.isBusy) { throw new TerminalError( 'BUSY', diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts index 832f5cc6d15..24989c18bc3 100644 --- a/apps/desktop/src/main/terminal/service.test.ts +++ b/apps/desktop/src/main/terminal/service.test.ts @@ -184,6 +184,7 @@ vi.mock('@/main/terminal/session', async () => { if (signal === 'SIGINT' && state.interruptible) finishRun(130) }), waitForShellIntegration: async () => {}, + waitForPromptDrawn: async () => {}, /** Stands in for the user running `exit` or pressing Ctrl-D. */ exit: () => { state.disposed = true diff --git a/apps/desktop/src/main/terminal/session.test.ts b/apps/desktop/src/main/terminal/session.test.ts index cdf300410bc..3f2902f4557 100644 --- a/apps/desktop/src/main/terminal/session.test.ts +++ b/apps/desktop/src/main/terminal/session.test.ts @@ -290,6 +290,68 @@ describe('TerminalSession command lifecycle', () => { }) }) +describe('waiting for the prompt before typing a command', () => { + const marker = (body: string) => `\u001b]633;${body};test-nonce\u0007` + + function promptSession(): TerminalSession { + return TerminalSession.create({ + terminalId: 'terminal-prompt-drawn', + cwd: '/tmp', + cols: 80, + rows: 24, + callbacks: { onData: () => {}, onState: () => {}, onCommand: () => {}, onExit: () => {} }, + }) + } + + async function settledAfter(promise: Promise, ms: number): Promise { + let settled = false + void promise.then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(ms) + return settled + } + + it('waits, in a shell that reports it, until the next prompt is drawn', async () => { + vi.useFakeTimers() + const session = promptSession() + try { + ptyStub.dataHandler?.(`${marker('A')}$ ${marker('B')}`) + // The last command just finished: its prompt is not drawn yet. + ptyStub.dataHandler?.(`${marker('D;0')}${marker('A')}`) + const drawn = session.waitForPromptDrawn() + + expect(await settledAfter(drawn, 500)).toBe(false) + ptyStub.dataHandler?.(`$ ${marker('B')}`) + expect(await settledAfter(drawn, 0)).toBe(true) + } finally { + session.dispose() + } + }) + + it('never waits on a shell that does not report its prompt', async () => { + vi.useFakeTimers() + const session = promptSession() + try { + ptyStub.dataHandler?.(`${marker('D;0')}${marker('A')}`) + expect(await settledAfter(session.waitForPromptDrawn(), 0)).toBe(true) + } finally { + session.dispose() + } + }) + + it('types anyway once a prompt that reports itself is slow to come', async () => { + vi.useFakeTimers() + const session = promptSession() + try { + ptyStub.dataHandler?.(`${marker('A')}$ ${marker('B')}${marker('A')}`) + expect(await settledAfter(session.waitForPromptDrawn(), 2_100)).toBe(true) + } finally { + session.dispose() + } + }) +}) + describe('TerminalSession on Windows', () => { const marker = (body: string) => `\u001b]633;${body};test-nonce\u0007` diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts index 07d1c996a28..ade9f78774b 100644 --- a/apps/desktop/src/main/terminal/session.ts +++ b/apps/desktop/src/main/terminal/session.ts @@ -246,6 +246,9 @@ const KEYSTROKE_GAP_MS = 150 */ const KEYSTROKE_SETTLE_MAX_MS = 1_000 +/** Cap on waiting for a shell's prompt to be drawn before typing into it anyway. */ +const PROMPT_DRAW_MAX_MS = 2_000 + interface PendingCommand { command: string toolCallId: string @@ -374,8 +377,12 @@ export class TerminalSession { private pendingCommand: PendingCommand | null = null /** Command line reported by the shell but not yet bracketed by output-start. */ private announcedCommand: string | null = null - /** Pending shell-readiness waits, rechecked whenever the shell's startup moves on. */ + /** Pending readiness and prompt waits, rechecked whenever startup moves on or a prompt is drawn. */ private readonly readinessWaiters = new Set<() => void>() + /** Whether the shell says when its prompt is drawn (`B`); bash and PowerShell do, zsh does not. */ + private reportsPromptEnd = false + /** Whether the prompt is drawn and reading input, as far as the shell has said. */ + private promptDrawn = false private constructor( options: TerminalSessionOptions, @@ -600,6 +607,31 @@ export class TerminalSession { return now - this.startupBegunAt >= bounds.startingMs ? 'starting' : null } + /** + * Resolves once the prompt is drawn and reading input, for a shell that reports it; at once for + * one that does not. Git Bash drops what is typed while it is still switching from a native + * program back to its prompt, so a command typed straight after the last one finished can vanish. + * Also resolves after {@link PROMPT_DRAW_MAX_MS}, on exit, or when `signal` aborts. + */ + waitForPromptDrawn(signal?: AbortSignal): Promise { + return new Promise((resolve) => { + let timer: NodeJS.Timeout | null = null + const done = () => { + if (timer) clearTimeout(timer) + this.readinessWaiters.delete(check) + signal?.removeEventListener('abort', done) + resolve() + } + const check = () => { + if (this.promptDrawn || !this.reportsPromptEnd || this.disposed) done() + } + timer = setTimeout(done, PROMPT_DRAW_MAX_MS) + this.readinessWaiters.add(check) + signal?.addEventListener('abort', done) + check() + }) + } + private notifyReadinessWaiters(): void { for (const check of [...this.readinessWaiters]) check() } @@ -901,7 +933,13 @@ export class TerminalSession { this.notifyReadinessWaiters() } break + case 'prompt-end': + this.reportsPromptEnd = true + this.promptDrawn = true + this.notifyReadinessWaiters() + break case 'prompt-start': + this.promptDrawn = false if (!this.shellIntegration) { this.shellIntegration = true this.emitState() @@ -912,6 +950,7 @@ export class TerminalSession { this.announcedCommand = marker.command break case 'output-start': { + this.promptDrawn = false if (this.pendingCommand) { this.pendingCommand.capturing = true break diff --git a/apps/desktop/src/main/terminal/shell-integration.ts b/apps/desktop/src/main/terminal/shell-integration.ts index fb8d648f3df..575821eb08d 100644 --- a/apps/desktop/src/main/terminal/shell-integration.ts +++ b/apps/desktop/src/main/terminal/shell-integration.ts @@ -45,7 +45,8 @@ export function createNonce(): string { /** * Marker kinds we act on. `A` (prompt start) doubles as the "integration is - * live" signal; `C`/`D` bracket a command's output; `E` reports the exact + * live" signal; `B` (prompt end) says the prompt is drawn and reading input; + * `C`/`D` bracket a command's output; `E` reports the exact * command line; `P` tracks the working directory across `cd`. `SimStartup` is * ours, not VS Code's: the shell sends it before running the user's startup * files, so a shell that is still starting can be told from one we never @@ -54,6 +55,7 @@ export function createNonce(): string { export type ShellMarker = | { kind: 'startup' } | { kind: 'prompt-start' } + | { kind: 'prompt-end' } | { kind: 'command-line'; command: string } | { kind: 'output-start' } | { kind: 'output-end'; exitCode: number } @@ -145,6 +147,8 @@ export class ShellIntegrationParser { return { kind: 'startup' } case 'A': return { kind: 'prompt-start' } + case 'B': + return { kind: 'prompt-end' } case 'C': return { kind: 'output-start' } case 'D': { @@ -310,8 +314,17 @@ __sim_precmd() { return $st } +# The prompt-end marker rides at the end of PS1, so it is drawn by readline itself, once the +# prompt is up and reading. Appended after the user's PROMPT_COMMAND, which may rebuild PS1. +__sim_prompt_end() { + local st=$? marker="\\[\\e]633;B;\${__sim_nonce}\\a\\]" + case "$PS1" in *"$marker") ;; *) PS1="$PS1$marker" ;; esac + return $st +} + +# Set before the trap, so assigning it is not reported as a command. +PROMPT_COMMAND="__sim_precmd\${PROMPT_COMMAND:+; $PROMPT_COMMAND}; __sim_prompt_end" trap '__sim_preexec' DEBUG -PROMPT_COMMAND="__sim_precmd\${PROMPT_COMMAND:+; $PROMPT_COMMAND}" ` ) return rcPath