Files
sim/apps/desktop
Waleed 2223b63851 chore(utils): consolidate record guards and remove dead code (#6715)
* refactor: consolidate local isRecord guards onto shared isRecordLike

Nineteen files had re-declared a local `isRecord` guard rather than using the
shared one from `@sim/utils/object`, drift that reappeared after #5061 first
consolidated them. Two more imported the shared guard under an `isRecordLike as
isRecord` alias.

The copies were not interchangeable. Nine matched `isRecordLike` exactly. The
rest omitted the array exclusion (`typeof x === 'object' && x !== null`, or
`Boolean(x) && typeof x === 'object'`), so arrays passed the guard. Each of
those call sites was reviewed individually: in every case the guard is followed
by string/number field checks that an array fails anyway, so the outcome is
unchanged.

The one exception is `isOptionsTagData`, where `Object.values` on an array of
option items really did make an array-form `<options>` tag render. It now
accepts arrays explicitly rather than by accident.

`executor/handlers/pi/search/extension-source.ts` keeps its own copy: it is
source text written into an E2B/Daytona sandbox at runtime and cannot import.

* refactor: replace inline record guards with isRecordLike

103 inline `typeof x === 'object' && x !== null && !Array.isArray(x)` guards
(and the `x &&` / `Boolean(x)` spellings of the same conjunction) now call the
shared guard. Inside a conjunction that already asserts `typeof x === 'object'`,
`x &&` and `x !== null` are interchangeable, so all three orderings are the same
predicate at runtime.

Only exactly-equivalent conjunctions were converted. Matching required the three
clauses to be one adjacent conjunction over the same operand, so a nearby but
unrelated clause cannot be absorbed — generic-handler.ts, where the array case is
handled inside the block rather than excluded by the guard, is correctly left
alone.

Two sites were reverted after type-check rejected them: instagram/server-utils.ts
and workflows/[id]/log/route.ts both cast straight to a specific interface, which
is legal from `object` but not from `Record<string, unknown>`. Their narrowing is
genuinely not identical, so they keep the inline form rather than acquiring a
double cast.

Left as-is: `packages/ts-sdk` (published with no runtime dependencies) and the two
sandbox sources written into E2B/Daytona as text, which cannot import.

* refactor: consolidate duplicate record coercion helpers onto @sim/utils

A second sweep found 28 more local record helpers hiding under names the
previous `isRecord` grep never matched — `asRecord`, `toRecord`,
`toRecordOrNull`, `asObject`, `isJsonObject`. rabbitmq defined the same
`asRecord` twice within one service; dynatrace had three variants in one file.

Fourteen of them were re-deriving the same two shapes, so those shapes now live
in `@sim/utils/object` beside the guards they wrap:

  toRecord(value)        // isRecordLike(value) ? value : {}
  toRecordOrNull(value)  // isRecordLike(value) ? value : null

Both preserve identity on a hit, so no call site starts copying.

Eighteen local definitions are gone. `tools/instantly/utils.ts` keeps its
exported `asRecord` because its return type is the local `JsonRecord` alias, but
its body now delegates. `app/api/mcp/serve/[serverId]/route.ts` had an
`isJsonObject` with zero call sites — deleted outright.

Six helpers were deliberately left alone because they are NOT equivalent:
`pagerduty`/`zendesk`/`gitlab` do `(value as Record) || {}`, which type-checks
nothing at all, and `copilot/resources/extraction.ts`,
`edit-workflow/validation.ts`, `pi/core/events.ts` omit the array exclusion.
Those sit on webhook ingress and copilot paths where tightening is a behavior
change, not a cleanup; they are audited separately.

Two guards were removed rather than substituted, each proven dominated by an
earlier check: the bedrock streaming `toolUse.input` guard was unreachable
(`parseToolInput` already throws on non-objects before the loop builds
`assembledToolUses`), and four `driver.ts` re-narrows follow an
`if (!isRecordLike(x) || ...) throw` that dominates the later use.

* fix(webhooks): guard non-string GitLab ref, and consolidate the last record helpers

Two audits covered the six helpers held back from the previous commit for not
being equivalent to isRecordLike. Five are now migrated; one is deliberately not.

`gitlab.ts` carried a real crash path, independent of the guard work:

    const ref = (b.ref as string) || ''
    const branch = ref.replace('refs/heads/', '')

The cast is unchecked and `|| ''` only catches falsy values, so a truthy
non-string body field — `{"ref": 12345}` — reaches `.replace` and throws
`TypeError: ref.replace is not a function` inside `formatInput`. That runs in the
background worker after the webhook is already 200-ACKed, and GitLab does not
auto-retry, so the delivery is lost silently. Now checks `typeof`.

`pagerduty`/`zendesk`/`gitlab` each defined `asRecord` as
`(value as Record<string, unknown>) || {}`, which type-checks nothing — a string
or array passed through and was then spread into the workflow trigger payload
(`gitlab.ts:114`) as character- or index-keyed garbage. All three now use the
shared `toRecord`. These sit behind `verifyProviderAuth`, so reaching them
requires the shared secret; this is robustness, not authorization.

`copilot/resources/extraction.ts` and `pi/core/events.ts` were array-permissive
but provably inert — extraction.ts has no key enumeration or spread anywhere,
and events.ts only diverges by returning `null` instead of `{type:'other'}` for
an array, which every consumer already no-ops on. Pinned with a test.

`edit-workflow/validation.ts` is left permissive ON PURPOSE. Its `Object.entries`
walk mirrors the unguarded walk in `operations.ts:86,191`, so an array-shaped
`nestedNodes` from the model is currently visited by both. Tightening only the
validation side would stop `collectHostedApiKeyInput` from stripping
platform-managed API keys while the apply side still creates those child blocks.
Both paths have to change together, with tests, in their own PR.

* refactor: delete 31 unreachable module-local functions

Removes 815 lines of provably dead code: module-local (non-exported)
declarations whose identifier appears exactly once in their own file — the
declaration itself. A non-exported symbol cannot be reached by an import, a
barrel, a dynamic import, or a framework convention, so "unreferenced in its own
file" is a complete proof of deadness rather than a heuristic.

Notable removals include whole abandoned code paths: `findWebhookAndWorkflow`
(78 lines), `calculateBillingProjection` and `initializeUserUsageLimit`
(80 lines), `removeCredits` + `deductFromCredits` (54), `sendBatchSMS`,
`executeToolBatch`, and four unused `async-runs/repository.ts` queries.

Spans come from the TypeScript AST, not a regex. A regex cannot find a
declaration's extent — a first-brace scan cuts inside a return-type annotation
such as `Promise<{ canCreate: boolean }>` and silently corrupts the file. The
AST pass also re-derives deadness from identifier nodes, which caught two
wealthbox helpers that a regex export-check had wrongly reported as local.

Note the runtime TypeScript API here is `@typescript/typescript6`; the bare
`typescript` specifier resolves to the native compiler, which exposes no
`createSourceFile`.

* refactor: delete 434 stranded exported symbols

Removes ~5,930 lines of unreachable code across 166 files: exported symbols that
no other file in the repo mentions and that are unused inside their own file.
Whole abandoned surfaces go with them — unused React Query hooks
(useOrganizations, useOrganizationMembers, useUpgradeSubscription, ...), unused
admin route contracts, dead executor constants and reference builders, and the
landing-page StageWorkflow/LandingPreviewMount components. Three files left with
no remaining code were removed outright.

Candidates came from an AST pass; each was then verified individually against an
UNFILTERED repo-wide search plus the reachability paths a name search misses:
string-keyed tool/block registries, dynamic imports, `export *` barrels, and the
docs generator's source-text parsing of tool files.

29 candidates were verified LIVE and kept. Those exposed a flaw in the candidate
generator: it indexed only .ts/.tsx, while apps/docs/content/**/*.mdx imports
React components directly — ActionImage appears in ~190 MDX pages and ActionVideo
in ~115, and both scanned as dead. `app/global-error.tsx` was likewise kept,
since Next.js reaches it by filename and its default export can never have a name
reference. All 434 deleted names were afterwards cross-checked against every
.mdx/.md/.json/.yaml in the repo: no hits.

Verified with turbo type-check (23 workspaces), the full apps/sim suite
(25,219 tests), all 26 audits, and a production `next build` — the last of these
being what actually exercises route- and component-level reachability.
2026-08-14 17:02:02 -07:00
..
2026-07-28 19:25:59 -07:00
2026-07-28 19:25:59 -07:00

Sim Desktop (macOS)

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.

Layout

src/main/           # main process (bundled to dist/main.cjs)
  index.ts          # lifecycle + wiring
  ipc.ts            # the single channel table: gate, version floor, handler
  config.ts         # origin + settings store (userData/settings.json)
  app-routes.ts     # Sim routes the shell navigates to (menu + tray share them)
  atomic-json-file.ts # crash-safe write for the encrypted userData stores
  navigation.ts     # navigation classifier + openExternalSafe
  windows.ts        # window.open policy (full app windows, MCP popup, blank children)
  window.ts         # secure BrowserWindow, permissions, crash/hang recovery
  security-guards.ts# global web-contents guards, TLS policy
  csp.ts            # Content-Security-Policy fallback header
  handoff.ts        # 127.0.0.1 loopback login handoff + token redeem
  session-lifecycle.ts # sign-out teardown, 401 watcher, connect intercept
  load-health.ts    # offline/error page, auto-retry, watchdog
  local-filesystem.ts # session-scoped read-only directory grants + localfs:// broker
  local-filesystem-grant-store.ts # those grants, encrypted at rest
  desktop-settings.ts # renderer-facing settings surface
  downloads.ts      # will-download handling
  context-menu.ts   # native right-click + spellcheck
  telemetry-policy.ts # third-party analytics blocking
  observability.ts  # JSONL event log (userData/logs/desktop-events.log)
  updater.ts        # electron-updater wiring, channels, downgrade/block guards
  menu.ts           # role-based macOS menus
  tray.ts           # tray icon, recent-chat menu, environment marker
  browser-agent/    # the agent browser: tab lifecycle, panel geometry, CDP driver
  terminal/         # the agent terminal: PTY sessions, tmux, shell integration
  browser-credentials/ # saved passwords, OS-auth gated, safeStorage at rest
  browser-sites/    # imported site directory, safeStorage at rest
  browser-import/   # one-shot import of profiles, cookies and passwords
src/preload/        # contextBridge IPC bridge (bundled to dist/preload.cjs)
native/             # Node-API/AppKit bridge for native macOS Help docs search
static/             # bundled local pages (offline.html)
e2e/                # Playwright _electron smoke suite

Local development

bun install                 # workspace root
cd apps/desktop
bun run dev                 # bundle + launch against https://www.sim.ai
SIM_DESKTOP_ORIGIN=http://localhost:3000 bun run dev   # against local sim
  • bun run test — vitest unit suite (electron is mocked; runs anywhere).
  • bun run test:e2e — Playwright _electron smoke suite against a fixture origin (macOS, real Electron window).
  • bun run type-check / lint:check — standard workspace checks; CI picks these up automatically via turbo run.
  • SIM_DESKTOP_USER_DATA=<dir> isolates settings/partition state (used by e2e).

Everything is bundled by esbuild into dist/main.cjs + dist/preload.cjs — including electron-updater and the @sim/* packages — so the packaged app has no runtime node_modules and electron-builder needs no lockfile/npmRebuild step (this is the deliberate workaround for Bun ↔ electron-builder friction; there is no package-lock.json).

Auth model (read before touching auth)

  • The app loads the hosted origin top-level; better-auth session cookies live in a persistent partition (persist:sim, per-origin for self-hosts). Email/password and verified-lenient providers (GitHub) sign in fully in-window.
  • Google / Microsoft / SSO cannot OAuth inside an embedded browser (disallowed_useragent; UA spoofing is fingerprint-defeated — do not ship it). Navigation to those hosts from an auth surface is intercepted and rerouted through the system-browser handoff:
    1. App starts a one-shot 127.0.0.1 loopback listener on an ephemeral port and opens <origin>/desktop/auth?state=<random>&port=<port> in the browser. The state is single-use, in-memory (the app is always running when the callback returns, so nothing is persisted), and constant-time compared.
    2. apps/sim/app/desktop/auth/page.tsx requires a browser session (redirects through /login?callbackUrl=…) and renders a Continue gesture gate; only that click mints a one-time token (POST /api/desktop/auth/handoff) and sends the browser to the loopback (http://127.0.0.1:<port>/auth/callback?token=…&state=…, RFC 8252 §7.3). The gate is the security boundary — state and port are attacker-choosable in a crafted link, so a bare GET must never mint. The loopback is the single hand-back channel: no OS scheme registration, works identically in dev and packaged builds. Interception of loopback is mitigated the way PKCE mitigates it: the token is single-use, short-TTL, and bound to a 128-bit state the app compares in constant time. (RFC 8252's most-preferred callback is claimed-https / macOS universal links, which bind the OS to a verified app identity — a future hardening step that needs an associated-domains entitlement + apple-app-site-association on the origin.)
    3. The loopback fires the callback in the main process: the app validates the state, then a renderer in the app partition POSTs the token to /api/auth/one-time-token/verify (same-origin ⇒ trustedOrigins/CSRF pass; better-auth sets the session cookie and burns the token) and loads /workspace.
  • The app gets its own session, never the browser's. The token is minted by POST /api/desktop/auth/handoff (apps/sim/lib/auth/desktop-handoff.ts), which creates a new session row for the user and points the one-time token at that. Better-auth's own generateOneTimeToken binds to the calling session, so using it directly made the app and the authorizing browser share one row: signing out of either deleted the row the other was still presenting, and — because the session cookie cache is not revalidated against the database — the survivor kept looking signed in while every database-backed session resolution failed (the socket handshake logged Session not found and 401-looped). A session per device is what better-auth's own device authorization grant does and what RFC 8252 assumes: sign-out, revocation, and expiry apply to one surface at a time. Do not "simplify" this back to generateOneTimeToken.
  • Integration connects are same-window redirects (client.oauth2.link), not popups. Unknown provider hosts stay in-window (lenient default); Google/Microsoft connects get a native dialog offering to finish in the browser — the browser is signed in after the login handoff, tokens land server-side, the app just refreshes.
  • The MCP OAuth popup (mcp-oauth-*) is allowed as a same-partition child so window.opener.postMessage keeps working.
  • Sign-out revokes server-side first (POST /api/auth/sign-out from the app-origin renderer — a main-process session.fetch would be rejected by better-auth's origin check), then clears cookies/localStorage/IndexedDB/cache/service workers plus any pending handoff state. The revoke is not optional: since the app owns its own session row, clearing the partition alone would strand a live 30-day credential that nothing can revoke. Two signals trigger teardown: the /login?fromLogout=true navigation (fast path) and deletion of the better-auth session cookie confirmed by a get-session probe (robust backstop — catches every sign-out path, not just the settings one, and rotation can't cause a false teardown). API 401s (probe-confirmed) surface a native re-auth prompt.

Deviations from the original plan doc (deliberate):

  • One hand-back channel, not two. The plan proposed a sim:// deep link with a loopback fallback; that was collapsed to loopback-only. The app is always running when the callback returns (it started the loopback), so the custom scheme added complexity and a dev-only failure mode without buying anything — loopback works identically everywhere. No sim:// scheme is registered.
  • No launch-time session probe; the server's own redirect to /login covers the signed-out launch, and the last route is restored otherwise.
  • Browser-initiated /desktop/auth visits without a valid state+port render a friendly error and never mint a token.

Provider matrix (U5 spike — keep current)

The host list lives in src/main/navigation.ts (SYSTEM_BROWSER_IDP_HOSTS) and now applies to integration connects only — sign-in always goes through the system-browser handoff whatever the IdP, so no provider needs to be classified as embed-tolerant for login. (The old IN_WINDOW_IDP_HOSTS list existed to keep GitHub sign-in in-window; embedding a login is a one-way door in a chrome-less shell and splits better-auth's OAuth state across two cookie jars, so it was removed.) Verified so far: Google + Microsoft block embedding by policy. Before GA, run the spike for connects: a sample of integration connects (Notion, Slack, Linear, Atlassian, Box, Dropbox) plus SSO and Turnstile-on-signup in a packaged build, then update the list and this section.

Web-app coupling contract (audit on web-app changes)

A thin shell over a hosted web app unavoidably knows a few of the web app's conventions. They are listed here so a web-app change that would break the desktop app is auditable in one place. Each is a documented, deliberate coupling — not accidental. The robust long-term de-coupling for all of them is a two-way preload bridge (see "Desktop-only features" below): the web app signals intent (signalLogout(), markAuthSurface()) instead of the shell inferring it.

Shell code Depends on Breaks if the web app… Failure mode Mitigation today
session-lifecycle.ts isLogoutNavigation /login?fromLogout=true on sign-out renames the param/route fast-path teardown misses Cookie backstop (session-cookie deletion + probe) still tears down — no residue
session-lifecycle.ts isSessionCookieName better-auth cookie ends session_token changes the cookie name/prefix backstop misses (fast path still works for settings sign-out) better-auth library contract; stable. Revisit on better-auth major
navigation.ts AUTH_SURFACE_PREFIXES auth routes /login /signup /sso /reset-password /verify adds/renames an auth route SSO from the new route gets the connect dialog instead of login Update the list; unknown hosts from non-auth pages still default sensibly
navigation.ts IdP host lists provider OAuth hostnames + embedded-UA policy a provider changes hostnames/policy that provider's sign-in/connect misroutes until a new release Ships with the app; the U5 spike + upgrade checklist re-verify. Server-delivered config is the future fix
session-lifecycle.ts / handoff.ts /workspace default /workspace is the post-login home changes the default landing route post-login/last-route restore lands on a redirect/404 Web app's own routing usually redirects; low blast radius
navigation.ts mcp-oauth-* frame name hooks/queries/mcp.ts opens mcp-oauth-${id} renames the popup frame MCP popup treated as generic → opener lost, flow hangs String contract; add a shared constant if it churns
window.ts theme probe document.documentElement.classList.contains('dark') (next-themes attribute='class') drops the dark class convention pre-paint background may flash once Cosmetic only; self-corrects on next load
handoff.ts redeem POST /api/auth/one-time-token/verify sets the cookie better-auth changes the endpoint handoff sign-in fails better-auth built-in endpoint; pinned by the better-auth version
apps/sim/lib/auth/desktop-handoff.ts mint better-auth stores one-time tokens as verification rows keyed one-time-token:<token>, unhashed (storeToken: 'plain' default) better-auth renames the namespace or hashes by default every redeem fails with Invalid token Single constant in one module; the covering test asserts the identifier shape. Revisit on better-auth major

Overall this is within normal thin-wrapper coupling — every item is either backstopped (sign-out), cosmetic (theme), or a stable library/route contract. The only one that genuinely can't self-heal without a release is the IdP host list, which is inherent to the "pin Chromium, ship a binary" model and is managed by the upgrade program.

Packaging & release

Local unsigned build: bun run package:dir (app in release/mac-universal/). Signed: bun run package:mac with CSC_LINK/CSC_KEY_PASSWORD exported.

Pre-release share (no Developer ID yet): SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.sim.ai bun run package:share builds a DMG whose fresh installs default to that origin (baked at build time; official builds leave it unset → prod) and skips per-file signature timestamps. Recipients must clear quarantine once: xattr -cr /Applications/Sim.app.

The build also derives the app icon from SIM_DESKTOP_DEFAULT_ORIGIN. Every channel uses the exact production icon with its white background and black sim mark. Non-production channels add a thin outline using existing platform colors: dev uses orange, staging uses Loop blue, and localhost uses Workflow violet. The macOS menu-bar icon also carries a compact D, S, or L subscript for those environments; production remains unmarked. Native Icon Composer assets live in build/; scripts/build.ts copies the selected variant to the ignored build/generated-icon.icon path consumed by electron-builder. Electron-builder compiles it to Assets.car and derives the legacy .icns fallback from the same source. Matching 512px PNGs in static/ provide the Dock icon for unpackaged runs.

CI (.github/workflows/desktop-release.yml, wired into ci.yml):

  • Stable builds run only after create-release on a vX.Y.Z: commit to main — never before: scripts/create-single-release.ts skips creation if the tag exists, so a desktop job publishing first would eat the changelog. Stable assets remain on simstudioai/sim; dev/staging assets publish to the public simstudioai/sim-desktop-releases repository so source-repository followers are not notified for internal shell builds. The job builds --publish never and uploads assets with gh release upload --clobber (idempotent re-runs).
  • Secrets gate: check-desktop-signing in ci.yml probes the six Apple secrets and skips the desktop job with a warning until they exist — releases never fail on a missing Apple account, and the first release after the secrets land ships desktop artifacts automatically. Manual/one-off builds: Actions → "Desktop Release (macOS)" → Run workflow with a vX.Y.Z version (publish: false uploads artifacts to the run instead of the release).
  • The product semver is injected from the release tag into apps/desktop/package.json at build time (repo package versions are placeholders). A mismatch guard fails the build.
  • Fuses are flipped at package time (electronFuses in electron-builder.yml): runAsNode off, NODE_OPTIONS off, inspect args off, ASAR-only + integrity validation, cookie encryption on, strictlyRequireAllFuses so new fuses fail loudly on Electron bumps.
  • Cookie-encryption go/no-go: on every Electron bump, verify a packaged build keeps its session across relaunch (there are historical cookie-persistence bugs with the EnableCookieEncryption fuse). If it reproduces, set enableCookieEncryption: false and record it here.

Required repo secrets (owner: whoever holds the Apple Developer account; calendar the expiries — an expired cert/API key breaks every release):

Secret Contents
CSC_LINK base64 of the Developer ID Application .p12
CSC_KEY_PASSWORD .p12 password
APPLE_API_KEY_P8 App Store Connect API key file contents (.p8)
APPLE_API_KEY_ID API key ID
APPLE_API_ISSUER API issuer ID
APPLE_TEAM_ID Developer team ID
DESKTOP_RELEASE_TOKEN Fine-grained GitHub token with Contents: write on only simstudioai/sim-desktop-releases; used to create, upload, publish, and prune dev/staging releases

Desktop-only features (how to add them cleanly)

Yes — the architecture has a single, clean seam for native features, and nothing about "the renderer is the hosted web app" gets in the way. The rules:

  1. One bridge. The preload (src/preload/index.ts) exposes window.simDesktop via contextBridge on the main window. This is the only channel between web content and native capability. It exposes narrow, typed methods — never raw ipcRenderer (Electron security checklist item 20).
  2. Feature-detect, never assume. The same web app is served to browsers and to the desktop from one origin, so a desktop feature is progressive enhancement: if (window.simDesktop) { … }. In a browser window.simDesktop is undefined and the feature is simply absent. (isHosted already tags these sessions for analytics.)
  3. Gate in main. Every channel is validated in src/main/ipc.ts by sender frame — app-origin for capability calls, bundled file: pages for shell-control calls (checklist item 17). A new native feature adds one gated channel there.
  4. Single-source the contract. apps/sim cannot import from apps/desktop (monorepo rule: apps/* → packages/* only). The bridge interface lives in the shared types-only packages/desktop-bridge package, which both the preload and web app consume.

Concrete example — a "Reveal in Finder" button:

// packages/desktop-bridge/index.ts  (shared contract)
export interface SimDesktopApi { showItemInFolder(path: string): void /* …existing methods… */ }

// apps/desktop/src/preload/index.ts   (implement)
showItemInFolder: (path: string) => ipcRenderer.send('desktop:show-item', path),

// apps/desktop/src/main/ipc.ts        (gate)
ipcMain.on('desktop:show-item', (event, path) => {
  if (!isAppOriginSender(event, deps.appOrigin()) || typeof path !== 'string') return
  shell.showItemInFolder(path)
})

// apps/sim  (consume — progressive enhancement)
const desktop = useDesktop()
{desktop && <Button onClick={() => desktop.showItemInFolder(file.path)}>Reveal in Finder</Button>}

Good fits for the bridge: OS notifications + dock badge on workflow completion, global shortcuts, "reveal in Finder", tray, secure OS-keychain storage. Anything that touches the server/DB still goes through normal APIs — the bridge is only for native capability. This same bridge is also the robust way to retire the web-app couplings in the table above: have the web app tell the shell (signalLogout(), markAuthSurface()) instead of the shell inferring from URLs.

Local filesystem access

Copilot can inspect user-selected local directories through the ordinary VFS tools. Granted folders appear beneath the top-level user-local/ namespace, and glob, grep, and read are routed to Electron only when their path/pattern is explicitly scoped there. This capability is:

  • Explicit and read-only: only a user click may open the native folder picker or revoke a grant; model tool calls cannot do either. There are no write/delete/execute/upload operations.
  • Remembered securely: grants are encrypted in Electron's private app data with OS-backed safeStorage and restored with the same opaque URI after a normal app restart. (A security-scoped bookmark is stored alongside each grant, but it is a no-op in the current Developer ID build — only the macOS App Sandbox consumes it — and is kept purely for forward-compatibility should a sandboxed/MAS build ever ship.) There is no plaintext fallback: when secure storage is unavailable, the returned mount has remembered: false and lasts only for that app session.
  • Revocable: Desktop settings removes one grant. All grants are removed on explicit sign-out or server-origin change so another Sim account or server cannot inherit them. Normal app quit only releases active OS handles and keeps the encrypted grants.
  • Opaque: the model sees canonical paths such as user-local/Project--<mount-id>/README.md, never host paths or internal localfs:// URIs. Electron resolves every request, checks lexical and realpath containment, and refuses symlink escapes.
  • Desktop-only: the web app advertises desktopCapabilities.localFilesystem only when the Electron bridge is present. Mothership adds the user-local/ prompt surface and per-call client routing only for that capability, including delegated and resumed work.
  • Bound to a live Copilot call: before a native read/search or browser action, Electron asks the authenticated Sim origin for the pending tool-call record. Local requests must exactly match its persisted operation, path, and options; browser actions run with the persisted arguments rather than renderer-supplied ones. Completed, failed, and aborted runs are rejected.
  • Abort-aware and bounded: stop/cancel propagates to active native scans and reads. File size, aggregate grep bytes, line, result, traversal-depth, and scan-count limits remain enforced in Electron, and unsafe regular expressions are rejected before execution.

Raw local file bytes are never exposed through the preload bridge and cannot be staged or uploaded by a model. Bounded text read/search results are returned to the active Copilot request; a user must use the normal attachment UI when they want the file itself to leave the device.

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. Install is prompt-based (Restart Now / Later; Later installs on quit) — never forced mid-session.
  • 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.
  • Staged rollout: after publishing, edit stagingPercentage: 10 into the release's latest-mac.yml, 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.

Self-hosting

  • Point Settings… (Cmd+,) at your instance. HTTPS required (HTTP for localhost only); each origin gets an isolated cookie partition.
  • Deploy the /desktop/auth page (ships with apps/sim) and include your desktop users' origin in TRUSTED_ORIGINS if it differs from NEXT_PUBLIC_APP_URL.
  • TLS must be system-trusted — the shell hard-rejects certificate errors (no in-app bypass). Install private CA roots in the macOS keychain.
  • DISABLE_AUTH instances: the web app serves an anonymous session; the shell needs no special handling, but understand that anyone with the app and your origin has full access.
  • Forks: repoint publish.owner/repo in electron-builder.yml or strip the updater.

Known caveats

  • Microphone and camera are denied by design (the permission matrix grants only sanitized clipboard writes to the app origin).
  • Default Electron ships H.264/AAC/MP3 — do not swap in the codec-free ffmpeg build.
  • Third-party web analytics (GTM/GA) are blocked at the network layer by default (blockThirdPartyAnalytics); first-party PostHog /ingest is untouched.
  • Cmd+F find-in-page overlay is not implemented (Monaco and tables ship their own finds); revisit if users ask.
  • Sign-in uses only the 127.0.0.1 loopback callback, which needs no OS registration — so it completes identically under bun run dev (unpackaged) and in a packaged build. There is no custom URL scheme.

Electron upgrades

Cadence: Electron ships a major every ~8 weeks and supports the latest 3 — budget a bump every ~4–6 months and adopt security patches within ~2 weeks. Follow docs/electron-upgrade-checklist.md; the desktop-e2e.yml canary leg (electron@latest) is the early-warning signal.